Skip to content

Commit 4e71574

Browse files
committed
Avoid constructing paths using string literals
To avoid the chance of an issue involving a case-sensitive filesystem and hardcoded string literals in loot-condition-interpreter. However, I'm pretty sure this was a waste of time, because the library input strings come from condition strings that do not necessarily reflect the casing of filenames and paths in the filesystem that the conditions are evaluated against, and that's much more likely to cause issues.
1 parent d8af231 commit 4e71574

File tree

5 files changed

+188
-26
lines changed

5 files changed

+188
-26
lines changed

benches/eval.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,47 @@ fn generate_plugin_versions() -> Vec<(String, String)> {
2323

2424
fn criterion_benchmark(c: &mut Criterion) {
2525
c.bench_function("Expression.eval() file(path)", |b| {
26-
let state = State::new(GameType::Oblivion, ".".into());
27-
let expression = Expression::from_str("file(\"Cargo.toml\")").unwrap();
26+
let tmp_dir = tempfile::tempdir().unwrap();
27+
let data_path = tmp_dir.path().join("Data");
28+
std::fs::create_dir(&data_path).unwrap();
29+
let mut state = State::new(GameType::Oblivion, data_path.clone());
30+
31+
for entry in std::fs::read_dir("tests/testing-plugins/Oblivion/Data").unwrap() {
32+
let entry = entry.unwrap();
33+
std::fs::copy(entry.path(), data_path.join(entry.file_name())).unwrap();
34+
}
35+
36+
state.clear_condition_cache().unwrap();
37+
38+
let expression = Expression::from_str("file(\"Blank.esp\")").unwrap();
2839

2940
b.iter(|| {
3041
assert!(expression.eval(&state).unwrap());
3142
});
3243
});
3344

45+
c.bench_function("Expression.eval() file(path) with missing plugin", |b| {
46+
let tmp_dir = tempfile::tempdir().unwrap();
47+
let data_path = tmp_dir.path().join("Data");
48+
std::fs::create_dir(&data_path).unwrap();
49+
let mut state = State::new(GameType::Oblivion, data_path.clone());
50+
51+
for entry in std::fs::read_dir("tests/testing-plugins/Oblivion/Data").unwrap() {
52+
let entry = entry.unwrap();
53+
let mut ghosted = entry.file_name();
54+
ghosted.push(".ghost");
55+
std::fs::copy(entry.path(), data_path.join(ghosted)).unwrap();
56+
}
57+
58+
state.clear_condition_cache().unwrap();
59+
60+
let expression = Expression::from_str("file(\"plugin.esp\")").unwrap();
61+
62+
b.iter(|| {
63+
assert!(!expression.eval(&state).unwrap());
64+
});
65+
});
66+
3467
c.bench_function("Expression.eval() file(regex)", |b| {
3568
let state = State::new(GameType::Oblivion, ".".into());
3669
let expression = Expression::from_str("file(\"Cargo.*\")").unwrap();

src/function/eval.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ impl Function {
316316
mod tests {
317317
use super::*;
318318

319+
use std::collections::HashMap;
320+
use std::ffi::OsString;
319321
use std::fs::{copy, create_dir, remove_file};
320322
use std::path::PathBuf;
321323
use std::sync::RwLock;
@@ -376,6 +378,7 @@ mod tests {
376378
.map(|(p, v)| (p.to_lowercase(), v.to_string()))
377379
.collect(),
378380
condition_cache: RwLock::default(),
381+
ghosted_plugins: HashMap::default(),
379382
}
380383
}
381384

@@ -407,7 +410,8 @@ mod tests {
407410
fn function_file_path_eval_should_return_true_if_given_a_plugin_that_is_ghosted() {
408411
let tmp_dir = tempdir().unwrap();
409412
let data_path = tmp_dir.path().join("Data");
410-
let state = state(data_path);
413+
let mut state = state(&data_path);
414+
state.ghosted_plugins.insert(data_path.clone(), vec![OsString::from("blank.esp.ghost")]);
411415

412416
copy(
413417
Path::new("tests/testing-plugins/Oblivion/Data/Blank.esp"),
@@ -818,11 +822,12 @@ mod tests {
818822
fn function_checksum_eval_should_support_checking_the_crc_of_a_ghosted_plugin() {
819823
let tmp_dir = tempdir().unwrap();
820824
let data_path = tmp_dir.path().join("Data");
821-
let state = state(data_path);
825+
let mut state = state(&data_path);
826+
state.ghosted_plugins.insert(data_path.clone(), vec![OsString::from("blank.esm.ghost")]);
822827

823828
copy(
824829
Path::new("tests/testing-plugins/Oblivion/Data/Blank.esm"),
825-
&state.data_path.join("Blank.esm.ghost"),
830+
&state.data_path.join("blank.esm.ghost"),
826831
)
827832
.unwrap();
828833

src/function/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use unicase::eq;
88

99
pub mod eval;
1010
pub mod parse;
11-
mod path;
11+
pub mod path;
1212
mod version;
1313

1414
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]

src/function/path.rs

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{
2-
ffi::OsStr,
2+
collections::HashMap,
3+
ffi::{OsStr, OsString},
34
path::{Path, PathBuf},
45
};
56

@@ -21,25 +22,24 @@ fn has_unghosted_plugin_file_extension(game_type: GameType, path: &Path) -> bool
2122
}
2223
}
2324

24-
pub fn has_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
25+
pub fn has_ghosted_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
2526
match path.extension() {
2627
Some(ext) if ext.eq_ignore_ascii_case(GHOST_EXTENSION) => path
2728
.file_stem()
2829
.map(|s| has_unghosted_plugin_file_extension(game_type, Path::new(s)))
2930
.unwrap_or(false),
30-
Some(ext) => is_unghosted_plugin_file_extension(game_type, ext),
3131
_ => false,
3232
}
3333
}
3434

35-
fn add_ghost_extension(path: PathBuf) -> PathBuf {
35+
pub fn has_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
3636
match path.extension() {
37-
Some(e) => {
38-
let mut new_extension = e.to_os_string();
39-
new_extension.push(GHOST_EXTENSION_WITH_PERIOD);
40-
path.with_extension(&new_extension)
41-
}
42-
None => path.with_extension(GHOST_EXTENSION),
37+
Some(ext) if ext.eq_ignore_ascii_case(GHOST_EXTENSION) => path
38+
.file_stem()
39+
.map(|s| has_unghosted_plugin_file_extension(game_type, Path::new(s)))
40+
.unwrap_or(false),
41+
Some(ext) => is_unghosted_plugin_file_extension(game_type, ext),
42+
_ => false,
4343
}
4444
}
4545

@@ -61,6 +61,36 @@ pub fn normalise_file_name(game_type: GameType, name: &OsStr) -> &OsStr {
6161
name
6262
}
6363

64+
fn get_ghosted_filename(path: &Path) -> Option<OsString> {
65+
let mut filename = path.file_name()?.to_os_string();
66+
filename.push(GHOST_EXTENSION_WITH_PERIOD);
67+
Some(filename)
68+
}
69+
70+
fn add_ghost_extension(
71+
path: &Path,
72+
ghosted_plugins: &HashMap<PathBuf, Vec<OsString>>,
73+
) -> Option<PathBuf> {
74+
// Can't just append a .ghost extension as the filesystem may be case-sensitive and the ghosted
75+
// file may have a .GHOST extension (for example). Instead loop through the other files in the
76+
// same parent directory and look for one that's unicode-case-insensitively-equal.
77+
let expected_filename = get_ghosted_filename(&path)?;
78+
let expected_filename = expected_filename.to_str()?;
79+
let parent_path = path.parent()?;
80+
81+
let ghosted_plugins = ghosted_plugins.get(&parent_path.to_path_buf())?;
82+
83+
for ghosted_plugin in ghosted_plugins {
84+
let ghosted_plugin_str = ghosted_plugin.to_str()?;
85+
86+
if unicase::eq(expected_filename, ghosted_plugin_str) {
87+
return Some(parent_path.join(ghosted_plugin));
88+
}
89+
}
90+
91+
None
92+
}
93+
6494
pub fn resolve_path(state: &State, path: &Path) -> PathBuf {
6595
// First check external data paths, as files there may override files in the main data path.
6696
for data_path in &state.additional_data_paths {
@@ -71,7 +101,9 @@ pub fn resolve_path(state: &State, path: &Path) -> PathBuf {
71101
}
72102

73103
if has_unghosted_plugin_file_extension(state.game_type, &path) {
74-
path = add_ghost_extension(path);
104+
if let Some(ghosted_path) = add_ghost_extension(&path, &state.ghosted_plugins) {
105+
path = ghosted_path
106+
}
75107
}
76108

77109
if path.exists() {
@@ -83,7 +115,7 @@ pub fn resolve_path(state: &State, path: &Path) -> PathBuf {
83115
let path = state.data_path.join(path);
84116

85117
if !path.exists() && has_unghosted_plugin_file_extension(state.game_type, &path) {
86-
add_ghost_extension(path)
118+
add_ghost_extension(&path, &state.ghosted_plugins).unwrap_or(path)
87119
} else {
88120
path
89121
}
@@ -395,15 +427,52 @@ mod tests {
395427
}
396428

397429
#[test]
398-
fn add_ghost_extension_should_add_dot_ghost_to_an_existing_extension() {
399-
let path = add_ghost_extension("plugin.esp".into());
400-
assert_eq!(PathBuf::from("plugin.esp.ghost"), path);
430+
fn add_ghost_extension_should_return_none_if_the_given_parent_path_is_not_in_hashmap() {
431+
let path = Path::new("subdir/plugin.esp");
432+
let result = add_ghost_extension(path, &HashMap::new());
433+
434+
assert!(result.is_none());
401435
}
402436

403437
#[test]
404-
fn add_ghost_extension_should_add_dot_ghost_to_an_a_path_with_no_extension() {
405-
let path = add_ghost_extension("plugin".into());
406-
assert_eq!(PathBuf::from("plugin.ghost"), path);
438+
fn add_ghost_extension_should_return_none_if_the_given_parent_path_has_no_ghosted_plugins() {
439+
let path = Path::new("subdir/plugin.esp");
440+
let mut map = HashMap::new();
441+
map.insert(PathBuf::from("subdir"), Vec::new());
442+
443+
let result = add_ghost_extension(path, &map);
444+
445+
assert!(result.is_none());
446+
}
447+
448+
#[test]
449+
fn add_ghost_extension_should_return_none_if_the_given_parent_path_has_no_matching_ghosted_plugins(
450+
) {
451+
let path = Path::new("subdir/plugin.esp");
452+
let mut map = HashMap::new();
453+
map.insert(
454+
PathBuf::from("subdir"),
455+
vec![OsString::from("plugin.esm.ghost")],
456+
);
457+
let result = add_ghost_extension(path, &map);
458+
459+
assert!(result.is_none());
460+
}
461+
462+
#[test]
463+
fn add_ghost_extension_should_return_some_if_the_given_parent_path_has_a_case_insensitively_equal_ghosted_plugin(
464+
) {
465+
let path = Path::new("subdir/plugin.esp");
466+
let ghosted_plugin = "Plugin.ESp.GHoST";
467+
let mut map = HashMap::new();
468+
map.insert(
469+
PathBuf::from("subdir"),
470+
vec![OsString::from(ghosted_plugin)],
471+
);
472+
let result = add_ghost_extension(path, &map);
473+
474+
assert!(result.is_some());
475+
assert_eq!(Path::new("subdir").join(ghosted_plugin), result.unwrap());
407476
}
408477

409478
#[test]
@@ -436,7 +505,9 @@ mod tests {
436505
fn resolve_path_should_return_the_given_data_relative_path_plus_a_ghost_extension_if_the_plugin_path_does_not_exist(
437506
) {
438507
let data_path = PathBuf::from(".");
439-
let state = State::new(GameType::Skyrim, data_path.clone());
508+
let mut state = State::new(GameType::Skyrim, data_path.clone());
509+
state.ghosted_plugins.insert(data_path.clone(), vec![OsString::from("plugin.esp.ghost")]);
510+
440511
let input_path = Path::new("plugin.esp");
441512
let resolved_path = resolve_path(&state, input_path);
442513

src/lib.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ mod error;
22
mod function;
33

44
use std::collections::{HashMap, HashSet};
5+
use std::ffi::OsString;
56
use std::fmt;
67
use std::ops::DerefMut;
7-
use std::path::PathBuf;
8+
use std::path::{Path, PathBuf};
89
use std::str;
910
use std::sync::{PoisonError, RwLock, RwLockWriteGuard};
1011

@@ -18,6 +19,7 @@ use nom::IResult;
1819

1920
use error::ParsingError;
2021
pub use error::{Error, ParsingErrorKind};
22+
use function::path::has_ghosted_plugin_file_extension;
2123
use function::Function;
2224

2325
type ParsingResult<'a, T> = IResult<&'a str, T, ParsingError<&'a str>>;
@@ -62,10 +64,47 @@ pub struct State {
6264
plugin_versions: HashMap<String, String>,
6365
/// Conditions that have already been evaluated, and their results.
6466
condition_cache: RwLock<HashMap<Function, bool>>,
67+
/// Ghosted plugin filenames that have been found in data paths, organised by data path.
68+
ghosted_plugins: HashMap<PathBuf, Vec<OsString>>,
69+
}
70+
71+
fn find_ghosted_plugins(game_type: GameType, data_path: &Path) -> Vec<OsString> {
72+
std::fs::read_dir(data_path)
73+
.into_iter()
74+
.flatten()
75+
.flat_map(|e| e.ok())
76+
.filter(|e| e.metadata().map(|m| m.is_file()).unwrap_or(false))
77+
.map(|e| e.file_name())
78+
.filter(|p| has_ghosted_plugin_file_extension(game_type, Path::new(p)))
79+
.collect()
80+
}
81+
82+
fn build_ghosted_plugins_cache(
83+
game_type: GameType,
84+
data_path: &Path,
85+
additional_data_paths: &[PathBuf],
86+
) -> HashMap<PathBuf, Vec<OsString>> {
87+
let mut map: HashMap<PathBuf, Vec<OsString>> = HashMap::new();
88+
89+
map.insert(
90+
data_path.to_path_buf(),
91+
find_ghosted_plugins(game_type, data_path),
92+
);
93+
94+
for additional_data_path in additional_data_paths {
95+
map.insert(
96+
additional_data_path.clone(),
97+
find_ghosted_plugins(game_type, &additional_data_path),
98+
);
99+
}
100+
101+
map
65102
}
66103

67104
impl State {
68105
pub fn new(game_type: GameType, data_path: PathBuf) -> Self {
106+
let ghosted_plugins = build_ghosted_plugins_cache(game_type, &data_path, &[]);
107+
69108
State {
70109
game_type,
71110
data_path,
@@ -74,6 +113,7 @@ impl State {
74113
crc_cache: RwLock::default(),
75114
plugin_versions: HashMap::default(),
76115
condition_cache: RwLock::default(),
116+
ghosted_plugins,
77117
}
78118
}
79119

@@ -123,11 +163,23 @@ impl State {
123163
pub fn clear_condition_cache(
124164
&mut self,
125165
) -> Result<(), PoisonError<RwLockWriteGuard<HashMap<Function, bool>>>> {
166+
self.refresh_ghosted_plugins();
167+
126168
self.condition_cache.write().map(|mut c| c.clear())
127169
}
128170

129171
pub fn set_additional_data_paths(&mut self, additional_data_paths: Vec<PathBuf>) {
130172
self.additional_data_paths = additional_data_paths;
173+
174+
self.refresh_ghosted_plugins()
175+
}
176+
177+
fn refresh_ghosted_plugins(&mut self) {
178+
self.ghosted_plugins = build_ghosted_plugins_cache(
179+
self.game_type,
180+
&self.data_path,
181+
&self.additional_data_paths,
182+
);
131183
}
132184
}
133185

@@ -297,6 +349,7 @@ mod tests {
297349
crc_cache: RwLock::default(),
298350
plugin_versions: HashMap::default(),
299351
condition_cache: RwLock::default(),
352+
ghosted_plugins: HashMap::default(),
300353
}
301354
}
302355

0 commit comments

Comments
 (0)