From f357fa48a3ae0659235a510e48ca326e441c2743 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Tue, 28 Jan 2025 18:17:57 +0000 Subject: [PATCH] Add is_executable() function 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. --- src/function/eval.rs | 85 +++++++++++++++++++++++++++++++++++++++++ src/function/mod.rs | 84 ++++++++++++++++++++++++++++++++++++++++ src/function/parse.rs | 26 ++++++++++++- src/function/version.rs | 4 ++ 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/src/function/eval.rs b/src/function/eval.rs index fcc07a9..9a5e96d 100644 --- a/src/function/eval.rs +++ b/src/function/eval.rs @@ -72,6 +72,10 @@ fn evaluate_readable(state: &State, path: &Path) -> Result { } } +fn evaluate_is_executable(state: &State, path: &Path) -> Result { + Ok(Version::is_readable(&resolve_path(state, path))) +} + fn evaluate_many(state: &State, parent_path: &Path, regex: &Regex) -> Result { // Share the found_one state across all data paths because they're all // treated as if they were merged into one directory. @@ -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), @@ -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")); diff --git a/src/function/mod.rs b/src/function/mod.rs index ac1c7cf..8ccf81a 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -40,6 +40,7 @@ pub enum Function { FilePath(PathBuf), FileRegex(PathBuf, Regex), Readable(PathBuf), + IsExecutable(PathBuf), ActivePath(PathBuf), ActiveRegex(Regex), IsMaster(PathBuf), @@ -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()), @@ -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()), @@ -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); } @@ -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()); @@ -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!( @@ -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()); diff --git a/src/function/parse.rs b/src/function/parse.rs index 3cdb236..2c50715 100644 --- a/src/function/parse.rs +++ b/src/function/parse.rs @@ -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(\"")), @@ -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"), } } @@ -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(); diff --git a/src/function/version.rs b/src/function/version.rs index 258326b..0d3dc40 100644 --- a/src/function/version.rs +++ b/src/function/version.rs @@ -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 Option>( file_path: &Path, formatter: F,