diff --git a/senbei-cli/src/main.rs b/senbei-cli/src/main.rs index f9591c9..cc8c7e4 100644 --- a/senbei-cli/src/main.rs +++ b/senbei-cli/src/main.rs @@ -10,6 +10,7 @@ fn main() -> std::process::ExitCode { let mut no_log = false; let mut verbose = false; let mut scan_all = false; + let mut strict_integrity = false; while let Some(a) = args.next() { match a.as_str() { @@ -26,6 +27,7 @@ fn main() -> std::process::ExitCode { "--no-pause" => no_pause = true, "--no-log" => no_log = true, "--scan-all" => scan_all = true, + "--strict-integrity" => strict_integrity = true, "--out" => match args.next() { Some(v) if !v.starts_with('-') => out = Some(v), _ => { @@ -77,7 +79,7 @@ fn main() -> std::process::ExitCode { println!("{}", summary.line()); println!("done in {} ms", summary.duration_ms); } - if summary.errors > 0 { 1 } else { 0 } + exit_code(&summary, strict_integrity) } Err(error) => { if quiet < 2 { @@ -93,9 +95,22 @@ fn main() -> std::process::ExitCode { std::process::ExitCode::from(code as u8) } +/// Process status for a finished run. +/// +/// Suspect outputs are still written. By default a suspect file is a warning +/// and the process stays successful; strict mode treats it like an error so a +/// caller can fail the run. An error fails the run either way. +fn exit_code(summary: &job::Summary, strict_integrity: bool) -> u8 { + if summary.errors > 0 || (strict_integrity && summary.suspect > 0) { + 1 + } else { + 0 + } +} + fn print_help() { println!( - "senbei [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]" + "senbei [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--no-log] [--no-pause] [-V|--version] [-h|--help]" ); println!( " input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\ @@ -107,4 +122,53 @@ fn print_help() { " --scan-all probe selected .exe/.dll/.so/metadata names below the\n\ \x20 size floor; other filenames remain excluded." ); + println!( + " --strict-integrity\n\ + \x20 exit non-zero when any output fails the static integrity\n\ + \x20 check (a suspect) or a file errors. Off by default: a\n\ + \x20 suspect is still written and the process exits 0 unless\n\ + \x20 a file errors. Quiet levels are unchanged." + ); +} + +#[cfg(test)] +mod strict_integrity { + use super::exit_code; + use senbei_io::job::Summary; + + fn summary(errors: usize, suspect: usize) -> Summary { + Summary { + errors, + suspect, + ..Summary::default() + } + } + + #[test] + fn clean_run_exits_0_in_both_modes() { + let summary = summary(0, 0); + assert_eq!(exit_code(&summary, false), 0); + assert_eq!(exit_code(&summary, true), 0); + } + + #[test] + fn errors_exit_1_whether_or_not_strict() { + let summary = summary(2, 0); + assert_eq!(exit_code(&summary, false), 1); + assert_eq!(exit_code(&summary, true), 1); + } + + #[test] + fn suspect_exits_0_by_default_and_1_when_strict() { + let summary = summary(0, 1); + assert_eq!(exit_code(&summary, false), 0); + assert_eq!(exit_code(&summary, true), 1); + } + + #[test] + fn errors_with_suspects_exit_1_in_both_modes() { + let summary = summary(1, 3); + assert_eq!(exit_code(&summary, false), 1); + assert_eq!(exit_code(&summary, true), 1); + } } diff --git a/senbei-cli/tests/strict_integrity.rs b/senbei-cli/tests/strict_integrity.rs new file mode 100644 index 0000000..15bfb6b --- /dev/null +++ b/senbei-cli/tests/strict_integrity.rs @@ -0,0 +1,193 @@ +//! CLI behavior of `--strict-integrity`. +//! +//! The flag is off by default and only changes the exit status (see the unit +//! tests in `src/main.rs` for the errors-versus-suspect matrix). These tests +//! drive the binary: help text, flag parsing, the error exit, and the quiet +//! contract. The suspect-only exit policy is covered separately by unit tests +//! so these process tests do not depend on the optional protected corpus. + +use std::path::Path; +use std::process::Command; + +fn senbei() -> Command { + Command::new(env!("CARGO_BIN_EXE_senbei")) +} + +fn run(args: &[&str]) -> std::process::Output { + senbei().args(args).output().expect("spawn senbei") +} + +fn assert_silent(output: &std::process::Output) { + assert!( + output.stdout.is_empty() && output.stderr.is_empty(), + "quiet>=2 must stay silent, stdout={:?} stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn write_plain(dir: &Path) -> std::path::PathBuf { + let path = dir.join("plain.bin"); + std::fs::write(&path, b"not a protected image").unwrap(); + path +} + +fn list_logs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("senbei-") && name.ends_with(".log")) + }) + .collect() +} + +#[test] +fn help_documents_strict_integrity_behavior() { + let output = run(&["--help"]); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("--strict-integrity")); + assert!(text.contains("--quiet")); + assert!(text.contains("exit non-zero")); + assert!(text.contains("suspect")); + assert!(text.contains("Off by default")); + assert!(text.contains("still written")); + assert!(text.contains("exits 0 unless")); + assert!(text.contains("a file errors")); + assert!(text.contains("Quiet levels are unchanged")); +} + +#[test] +fn short_help_and_version_ignore_the_flag_position() { + let help = run(&["-h", "--strict-integrity"]); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("--strict-integrity")); + + let version = run(&["--version"]); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stdout).starts_with("Senbei ")); +} + +#[test] +fn unknown_flag_still_exits_2() { + let output = run(&["--strict"]); + assert_eq!(output.status.code(), Some(2)); + let err = String::from_utf8_lossy(&output.stderr); + assert!(err.contains("unknown option '--strict'")); + assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity")); +} + +#[test] +fn missing_input_exits_2_with_the_flag() { + let output = run(&["--strict-integrity", "--no-pause"]); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity")); +} + +#[test] +fn errors_exit_1_by_default_and_when_strict_and_write_nothing() { + let td = tempfile::tempdir().unwrap(); + let input = write_plain(td.path()); + for extra in [None, Some("--strict-integrity")] { + let out = td + .path() + .join(if extra.is_some() { "strict" } else { "default" }); + let mut args = vec![ + input.to_str().unwrap(), + "--out", + out.to_str().unwrap(), + "--no-log", + "--no-pause", + ]; + if let Some(flag) = extra { + args.push(flag); + } + let output = run(&args); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!( + !out.join("plain.unpack.bin").exists(), + "an error must not leave an artifact" + ); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("0 unpacked")); + assert!(text.contains("1 errors")); + assert!(text.contains("0 suspect")); + } +} + +#[test] +fn double_quiet_stays_silent_for_errors_in_both_modes() { + let td = tempfile::tempdir().unwrap(); + let input = write_plain(td.path()); + let out = td.path().join("out"); + for extra in [None, Some("--strict-integrity")] { + let mut args = vec![ + input.to_str().unwrap(), + "--out", + out.to_str().unwrap(), + "--no-log", + "--no-pause", + "-q", + "-q", + ]; + if let Some(flag) = extra { + args.push(flag); + } + let output = run(&args); + assert_eq!(output.status.code(), Some(1)); + assert_silent(&output); + } +} + +#[test] +fn single_quiet_keeps_the_summary_and_the_error() { + let td = tempfile::tempdir().unwrap(); + let input = write_plain(td.path()); + let out = td.path().join("out"); + let output = run(&[ + input.to_str().unwrap(), + "--out", + out.to_str().unwrap(), + "--no-log", + "--no-pause", + "-q", + "--strict-integrity", + ]); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + // One -q only suppresses per-file lines. The banner and the summary still + // print; that is the existing quiet contract and strict mode must not + // change it. + assert!(stdout.contains("Senbei "), "{stdout}"); + assert!(stdout.contains("1 errors"), "{stdout}"); + assert!(stdout.contains("0 suspect"), "{stdout}"); +} + +#[test] +fn strict_does_not_change_what_the_log_records() { + let td = tempfile::tempdir().unwrap(); + let input = write_plain(td.path()); + let out = td.path().join("out"); + let output = run(&[ + input.to_str().unwrap(), + "--out", + out.to_str().unwrap(), + "--strict-integrity", + "--no-pause", + "-q", + "-q", + ]); + assert_eq!(output.status.code(), Some(1)); + assert_silent(&output); + let logs = list_logs(&out); + assert_eq!(logs.len(), 1); + let text = std::fs::read_to_string(&logs[0]).unwrap(); + assert!(text.contains("summary:")); + assert!(text.contains("1 errors")); + assert!(text.contains("0 suspect")); +}