Skip to content

Commit

Permalink
Add is_executable() function
Browse files Browse the repository at this point in the history
To guard against calling product_version() or version() on (non-plugin) files that unexpectedly aren't executables, e.g. Starfield.exe from the Microsoft Store.
  • Loading branch information
Ortham committed Jan 28, 2025
1 parent 784af50 commit f357fa4
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 1 deletion.
85 changes: 85 additions & 0 deletions src/function/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ fn evaluate_readable(state: &State, path: &Path) -> Result<bool, Error> {
}
}

fn evaluate_is_executable(state: &State, path: &Path) -> Result<bool, Error> {
Ok(Version::is_readable(&resolve_path(state, path)))
}

fn evaluate_many(state: &State, parent_path: &Path, regex: &Regex) -> Result<bool, Error> {
// Share the found_one state across all data paths because they're all
// treated as if they were merged into one directory.
Expand Down Expand Up @@ -279,6 +283,7 @@ impl Function {
Function::FilePath(f) => evaluate_file_path(state, f),
Function::FileRegex(p, r) => evaluate_file_regex(state, p, r),
Function::Readable(p) => evaluate_readable(state, p),
Function::IsExecutable(p) => evaluate_is_executable(state, p),
Function::ActivePath(p) => evaluate_active_path(state, p),
Function::ActiveRegex(r) => evaluate_active_regex(state, r),
Function::IsMaster(p) => evaluate_is_master(state, p),
Expand Down Expand Up @@ -613,6 +618,86 @@ mod tests {
assert!(!function.eval(&state).unwrap());
}

#[test]
fn function_is_executable_should_be_false_for_a_path_that_does_not_exist() {
let state = state(".");
let function = Function::IsExecutable("missing".into());

assert!(!function.eval(&state).unwrap());
}

#[test]
fn function_is_executable_should_be_false_for_a_directory() {
let state = state(".");
let function = Function::IsExecutable("tests".into());

assert!(!function.eval(&state).unwrap());
}

#[cfg(windows)]
#[test]
fn function_is_executable_should_be_false_for_a_file_that_cannot_be_read() {
use std::os::windows::fs::OpenOptionsExt;

let tmp_dir = tempdir().unwrap();
let data_path = tmp_dir.path().join("Data");
let state = state(data_path);

let relative_path = "unreadable";
let file_path = state.data_path.join(relative_path);

// Create a file and open it with exclusive access so that the readable
// function eval isn't able to open the file in read-only mode.
let _file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.share_mode(0)
.open(&file_path);

assert!(file_path.exists());

let function = Function::IsExecutable(PathBuf::from(relative_path));

assert!(!function.eval(&state).unwrap());
}

#[cfg(not(windows))]
#[test]
fn function_is_executable_should_be_false_for_a_file_that_cannot_be_read() {
let tmp_dir = tempdir().unwrap();
let data_path = tmp_dir.path().join("Data");
let state = state(data_path);

let relative_path = "unreadable";
let file_path = state.data_path.join(relative_path);

std::fs::write(&file_path, "").unwrap();
make_path_unreadable(&file_path);

assert!(file_path.exists());

let function = Function::IsExecutable(PathBuf::from(relative_path));

assert!(!function.eval(&state).unwrap());
}

#[test]
fn function_is_executable_should_be_false_for_a_file_that_is_not_an_executable() {
let state = state(".");
let function = Function::IsExecutable("Cargo.toml".into());

assert!(!function.eval(&state).unwrap());
}

#[test]
fn function_is_executable_should_be_true_for_a_file_that_is_an_executable() {
let state = state(".");
let function = Function::IsExecutable("tests/libloot_win32/loot.dll".into());

assert!(function.eval(&state).unwrap());
}

#[test]
fn function_active_path_eval_should_be_true_if_the_path_is_an_active_plugin() {
let function = Function::ActivePath(PathBuf::from("Blank.esp"));
Expand Down
84 changes: 84 additions & 0 deletions src/function/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub enum Function {
FilePath(PathBuf),
FileRegex(PathBuf, Regex),
Readable(PathBuf),
IsExecutable(PathBuf),
ActivePath(PathBuf),
ActiveRegex(Regex),
IsMaster(PathBuf),
Expand All @@ -57,6 +58,7 @@ impl fmt::Display for Function {
FilePath(p) => write!(f, "file(\"{}\")", p.display()),
FileRegex(p, r) => write!(f, "file(\"{}/{}\")", p.display(), r),
Readable(p) => write!(f, "readable(\"{}\")", p.display()),
IsExecutable(p) => write!(f, "is_executable(\"{}\")", p.display()),
ActivePath(p) => write!(f, "active(\"{}\")", p.display()),
ActiveRegex(r) => write!(f, "active(\"{}\")", r),
IsMaster(p) => write!(f, "is_master(\"{}\")", p.display()),
Expand All @@ -80,6 +82,9 @@ impl PartialEq for Function {
eq(r1.as_str(), r2.as_str()) && eq(&p1.to_string_lossy(), &p2.to_string_lossy())
}
(Readable(p1), Readable(p2)) => eq(&p1.to_string_lossy(), &p2.to_string_lossy()),
(IsExecutable(p1), IsExecutable(p2)) => {
eq(&p1.to_string_lossy(), &p2.to_string_lossy())
}
(ActivePath(p1), ActivePath(p2)) => eq(&p1.to_string_lossy(), &p2.to_string_lossy()),
(ActiveRegex(r1), ActiveRegex(r2)) => eq(r1.as_str(), r2.as_str()),
(IsMaster(p1), IsMaster(p2)) => eq(&p1.to_string_lossy(), &p2.to_string_lossy()),
Expand Down Expand Up @@ -117,6 +122,9 @@ impl Hash for Function {
Readable(p) => {
p.to_string_lossy().to_lowercase().hash(state);
}
IsExecutable(p) => {
p.to_string_lossy().to_lowercase().hash(state);
}
ActivePath(p) => {
p.to_string_lossy().to_lowercase().hash(state);
}
Expand Down Expand Up @@ -185,6 +193,16 @@ mod tests {
assert_eq!("readable(\"subdir/Blank.esm\")", &format!("{}", function));
}

#[test]
fn function_fmt_for_is_executable_should_format_correctly() {
let function = Function::IsExecutable("subdir/Blank.esm".into());

assert_eq!(
"is_executable(\"subdir/Blank.esm\")",
&format!("{}", function)
);
}

#[test]
fn function_fmt_for_active_path_should_format_correctly() {
let function = Function::ActivePath("Blank.esm".into());
Expand Down Expand Up @@ -337,6 +355,40 @@ mod tests {
);
}

#[test]
fn function_eq_for_is_executable_should_check_pathbuf() {
assert_eq!(
Function::IsExecutable("Blank.esm".into()),
Function::IsExecutable("Blank.esm".into())
);

assert_ne!(
Function::IsExecutable("Blank.esp".into()),
Function::IsExecutable("Blank.esm".into())
);
}

#[test]
fn function_eq_for_is_executable_should_be_case_insensitive_on_pathbuf() {
assert_eq!(
Function::IsExecutable("Blank.esm".into()),
Function::IsExecutable("blank.esm".into())
);
}

#[test]
fn function_eq_for_is_executable_should_not_be_equal_to_file_path_or_is_executable_with_same_pathbuf(
) {
assert_ne!(
Function::IsExecutable("Blank.esm".into()),
Function::FilePath("Blank.esm".into())
);
assert_ne!(
Function::IsExecutable("Blank.esm".into()),
Function::Readable("Blank.esm".into())
);
}

#[test]
fn function_eq_for_active_path_should_check_pathbuf() {
assert_eq!(
Expand Down Expand Up @@ -677,6 +729,38 @@ mod tests {
assert_ne!(hash(function1), hash(function2));
}

#[test]
fn function_hash_is_executable_should_hash_pathbuf() {
let function1 = Function::IsExecutable("Blank.esm".into());
let function2 = Function::IsExecutable("Blank.esm".into());

assert_eq!(hash(function1), hash(function2));

let function1 = Function::IsExecutable("Blank.esm".into());
let function2 = Function::IsExecutable("Blank.esp".into());

assert_ne!(hash(function1), hash(function2));
}

#[test]
fn function_hash_is_executable_should_be_case_insensitive() {
let function1 = Function::IsExecutable("Blank.esm".into());
let function2 = Function::IsExecutable("blank.esm".into());

assert_eq!(hash(function1), hash(function2));
}

#[test]
fn function_hash_file_path_and_readable_and_is_executable_should_not_have_equal_hashes() {
let function1 = Function::FilePath("Blank.esm".into());
let function2 = Function::Readable("Blank.esm".into());
let function3 = Function::IsExecutable("Blank.esm".into());

assert_ne!(hash(function1.clone()), hash(function2.clone()));
assert_ne!(hash(function3.clone()), hash(function1));
assert_ne!(hash(function3), hash(function2));
}

#[test]
fn function_hash_active_path_should_hash_pathbuf() {
let function1 = Function::ActivePath("Blank.esm".into());
Expand Down
26 changes: 25 additions & 1 deletion src/function/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ impl Function {
),
Function::Readable,
),
map(
delimited(
map_err(tag("is_executable(\"")),
parse_non_regex_path,
map_err(tag("\")")),
),
Function::IsExecutable,
),
map(
delimited(
map_err(tag("active(\"")),
Expand Down Expand Up @@ -331,7 +339,7 @@ mod tests {
assert!(output.0.is_empty());
match output.1 {
Function::Readable(f) => assert_eq!(Path::new("Cargo.toml"), f),
_ => panic!("Expected a file path function"),
_ => panic!("Expected a readable function"),
}
}

Expand All @@ -340,6 +348,22 @@ mod tests {
assert!(Function::parse("readable(\"../../Cargo.toml\")").is_err());
}

#[test]
fn function_parse_should_parse_an_is_executable_function() {
let output = Function::parse("is_executable(\"Cargo.toml\")").unwrap();

assert!(output.0.is_empty());
match output.1 {
Function::IsExecutable(f) => assert_eq!(Path::new("Cargo.toml"), f),
_ => panic!("Expected an is_executable function"),
}
}

#[test]
fn function_parse_should_error_if_the_is_executable_path_is_outside_the_game_directory() {
assert!(Function::parse("is_executable(\"../../Cargo.toml\")").is_err());
}

#[test]
fn function_parse_should_parse_an_active_path_function() {
let output = Function::parse("active(\"Cargo.toml\")").unwrap();
Expand Down
4 changes: 4 additions & 0 deletions src/function/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ impl Version {
})
}

pub fn is_readable(file_path: &Path) -> bool {
Self::read_version(file_path, |_| None).is_ok()
}

fn read_version<F: Fn(&VersionInfo) -> Option<String>>(
file_path: &Path,
formatter: F,
Expand Down

0 comments on commit f357fa4

Please sign in to comment.