perf(scan): skip extensionless files by default

This commit is contained in:
bfloat16
2026-08-11 19:24:32 +08:00
parent e9ead4dc5f
commit ab1a14c9d1
3 changed files with 47 additions and 22 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ fn print_help() {
); );
println!( println!(
" --scan-all probe every file in a folder, including ones the scan\n\ " --scan-all probe every file in a folder, including ones the scan\n\
\x20 pre-filter skips (under 4128 bytes, or a bulk-asset\n\ \x20 pre-filter skips (under 4128 bytes, extensionless,\n\
\x20 extension like .ab/.xml/.acb). Much slower on game trees." \x20 or a bulk-asset extension). Much slower on large trees."
); );
} }
+3 -3
View File
@@ -387,9 +387,9 @@ pub fn run_folder_v(
/// ///
/// When `scan_all` is true every regular file under `root` is opened and /// When `scan_all` is true every regular file under `root` is opened and
/// content-probed, instead of skipping ones the free directory metadata already /// content-probed, instead of skipping ones the free directory metadata already
/// rules out (too small to hold a Crackproof key table, or a bulk-asset /// rules out (extensionless, too small to hold a Crackproof key table, or a
/// extension). See [`crate::scan::find_targets_opts`] — exhaustive scanning is /// bulk-asset extension). See [`crate::scan::find_targets_opts`] — exhaustive
/// dramatically slower on asset-heavy game trees and finds the same targets. /// scanning is dramatically slower on asset-heavy trees.
pub fn run_folder_opts( pub fn run_folder_opts(
root: &Path, root: &Path,
out_dir: Option<&Path>, out_dir: Option<&Path>,
+42 -17
View File
@@ -28,12 +28,11 @@ const MIN_SIZE: u64 = 4128;
/// File extensions that are bulk data by construction and can never be a PE /// File extensions that are bulk data by construction and can never be a PE
/// image or an il2cpp metadata blob. /// image or an il2cpp metadata blob.
/// ///
/// This is deliberately a **deny**-list, not an allow-list: the default is to /// This is deliberately a **deny**-list, not an executable allow-list: unknown
/// probe, so anything unrecognised is still opened. Targets are recognised by /// extensions are still probed. Extensionless files are handled separately by
/// content, not extension, and can carry arbitrary names — there is no closed /// [`denied_name`] because asset stores commonly contain tens of thousands of
/// set of target extensions an allow-list of `exe`/`dll` could enumerate. /// extensionless chunks; exhaustive probing remains available through
/// Only extensions that are bulk asset or text formats by construction appear /// `--scan-all`.
/// here.
/// ///
/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless. /// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless.
const DENY_EXT: &[&str] = &[ const DENY_EXT: &[&str] = &[
@@ -90,11 +89,12 @@ const DENY_EXT: &[&str] = &[
"sr", "sr",
]; ];
/// Whether `path`'s extension is on [`DENY_EXT`]. Extensionless files are never /// Whether `path` can be skipped from its name alone. Extensionless files and
/// denied (they could be anything). /// files whose extension is on [`DENY_EXT`] are not opened during a default
fn denied_ext(path: &Path) -> bool { /// scan. `--scan-all` remains available when exhaustive probing is required.
fn denied_name(path: &Path) -> bool {
let Some(ext) = path.extension() else { let Some(ext) = path.extension() else {
return false; return true;
}; };
let Some(ext) = ext.to_str() else { let Some(ext) = ext.to_str() else {
return false; return false;
@@ -206,12 +206,17 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
continue; continue;
} }
if !scan_all { if !scan_all {
// Name checks come first so extensionless asset chunks never
// trigger even an explicit metadata query.
if denied_name(entry.path()) {
continue;
}
// Skip on directory metadata alone — never open these. // Skip on directory metadata alone — never open these.
let too_small = entry let too_small = entry
.metadata() .metadata()
.map(|m| m.len() < MIN_SIZE) .map(|m| m.len() < MIN_SIZE)
.unwrap_or(false); .unwrap_or(false);
if too_small || denied_ext(entry.path()) { if too_small {
continue; continue;
} }
} }
@@ -329,29 +334,49 @@ mod tests {
#[test] #[test]
fn denies_bulk_asset_extensions_case_insensitively() { fn denies_bulk_asset_extensions_case_insensitively() {
for p in ["a.ab", "a.XML", "a.Acb", "a.ma2", "a.manifest", "a.PNG"] { for p in ["a.ab", "a.XML", "a.Acb", "a.ma2", "a.manifest", "a.PNG"] {
assert!(denied_ext(Path::new(p)), "{p} should be denied"); assert!(denied_name(Path::new(p)), "{p} should be denied");
}
}
#[test]
fn denies_extensionless_files() {
for p in ["asset", "level0", "0123456789abcdef"] {
assert!(denied_name(Path::new(p)), "{p} should be denied");
} }
} }
#[test] #[test]
fn never_denies_what_a_target_can_be_named() { fn never_denies_what_a_target_can_be_named() {
// Targets are recognised by content, not name — a protected module // Unknown extensions must still be probed. This keeps the filter a
// can carry any extension, or none — so names like these must always // narrow deny-list rather than an executable-extension allow-list.
// be probed. An allow-list would have skipped them.
for p in [ for p in [
"app.exe.bak", "app.exe.bak",
"managed.dll.bak", "managed.dll.bak",
"daemon.exe", "daemon.exe",
"GameLib.dll", "GameLib.dll",
"global-metadata.dat", "global-metadata.dat",
"noextension",
"a.so", "a.so",
"a.bin", "a.bin",
] { ] {
assert!(!denied_ext(Path::new(p)), "{p} must still be probed"); assert!(!denied_name(Path::new(p)), "{p} must still be probed");
} }
} }
#[test]
fn extensionless_targets_require_exhaustive_scan() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
let mut blob = vec![0u8; MIN_SIZE as usize + 1];
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
std::fs::write(root.join("metadata"), &blob).unwrap();
let (_, filtered, _) = find_targets_opts(root, false);
assert!(filtered.is_empty());
let (_, exhaustive, _) = find_targets_opts(root, true);
assert_eq!(exhaustive.len(), 1);
}
/// A file below the Crackproof key-table bound is skipped without being /// A file below the Crackproof key-table bound is skipped without being
/// opened, but a large non-asset file is still probed. /// opened, but a large non-asset file is still probed.
#[test] #[test]