diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ad646064..3dfec92e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: run: | cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli archive_git_tree cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli --bin disksage-archive-tree + cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli --test archive_tree_help_exit - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20.19.0 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 445985b92..d905ed2f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -116,6 +116,11 @@ name = "disksage-naruon-copy-readiness-verify" path = "src/bin/disksage-naruon-copy-readiness-verify.rs" required-features = ["cloud-cli"] +[[test]] +name = "archive_tree_help_exit" +path = "tests/archive_tree_help_exit.rs" +required-features = ["archive-cli"] + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/bin/disksage-archive-tree.rs b/src-tauri/src/bin/disksage-archive-tree.rs index f08956f5c..72b2e995d 100644 --- a/src-tauri/src/bin/disksage-archive-tree.rs +++ b/src-tauri/src/bin/disksage-archive-tree.rs @@ -1,4 +1,8 @@ -//! Read-only ZIP-to-Git-tree proof. Archive entries are streamed and never extracted. +//! Read-only ZIP-to-Git-tree proof. +//! +//! Archive entries are streamed and never extracted. The command computes deterministic Git-tree +//! evidence, optionally verifies one expected tree or a content-subset relation, and never mutates +//! either archive or the local filesystem. use std::path::PathBuf; @@ -6,18 +10,25 @@ use disksage_lib::archive_git_tree::{ compare_zip_content_inclusion, inspect_zip_git_tree_with_mode, ArchiveTreeRootMode, }; +/// Parsed arguments for one archive-tree inspection or subset proof. #[derive(Debug, PartialEq, Eq)] struct Args { + /// ZIP archive whose content tree will be inspected. zip: PathBuf, + /// Optional expected 40-character Git tree identifier. expected_tree: Option, + /// Optional archive that must contain every content item from `zip`. superset_zip: Option, + /// Whether to retain a shared archive root directory in the computed tree. keep_top_level: bool, } +/// Returns the stable command synopsis used by help and bounded validation failures. fn usage() -> &'static str { "DiskSage archive proof: usage: disksage-archive-tree --zip PATH [--expected-tree HEX40 | --prove-subset-of PATH] [--keep-top-level]" } +/// Returns the required value after one known option and advances the parser index. fn value(args: &[String], index: &mut usize, flag: &str) -> Result { *index += 1; args.get(*index) @@ -25,6 +36,7 @@ fn value(args: &[String], index: &mut usize, flag: &str) -> Result Result { let mut zip = None; let mut expected_tree = None; @@ -40,7 +52,7 @@ fn parse_args(args: &[String]) -> Result { } "--keep-top-level" => keep_top_level = true, "--help" | "-h" => return Err(usage().into()), - unknown => return Err(format!("알 수 없는 인자: {unknown}")), + _ => return Err("archive-tree-unknown-argument".into()), } index += 1; } @@ -55,8 +67,20 @@ fn parse_args(args: &[String]) -> Result { }) } +/// Reads process arguments, performs the requested read-only proof, and prints JSON evidence. fn run() -> Result<(), String> { - let raw: Vec = std::env::args().skip(1).collect(); + let raw = std::env::args_os() + .skip(1) + .map(|argument| { + argument + .into_string() + .map_err(|_| "archive-tree-argument-invalid".to_string()) + }) + .collect::, _>>()?; + if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { + println!("{}", usage()); + return Ok(()); + } let args = parse_args(&raw)?; let root_mode = if args.keep_top_level { ArchiveTreeRootMode::KeepTopLevel @@ -86,6 +110,7 @@ fn run() -> Result<(), String> { Ok(()) } +/// Runs the CLI and returns exit code 2 for bounded validation or proof failures. fn main() { if let Err(error) = run() { eprintln!("{error}"); diff --git a/src-tauri/tests/archive_tree_help_exit.rs b/src-tauri/tests/archive_tree_help_exit.rs new file mode 100644 index 000000000..754b15744 --- /dev/null +++ b/src-tauri/tests/archive_tree_help_exit.rs @@ -0,0 +1,98 @@ +use std::process::Command; + +const EXPECTED_USAGE: &str = "DiskSage archive proof: usage: disksage-archive-tree --zip PATH [--expected-tree HEX40 | --prove-subset-of PATH] [--keep-top-level]"; + +/// Prove both help flags return the exact stable usage line and empty stderr. +#[test] +fn archive_tree_help_exits_successfully_without_error_output() { + for flag in ["--help", "-h"] { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-archive-tree")) + .arg(flag) + .output() + .expect("archive-tree CLI must launch for its help contract"); + + assert!( + output.status.success(), + "{flag} must be a successful terminal action, got status {:?} and stderr {:?}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "successful help must not be projected through stderr" + ); + let stdout = String::from_utf8(output.stdout).expect("help output must be valid UTF-8"); + assert_eq!( + stdout, + format!("{EXPECTED_USAGE}\n"), + "help output must equal the stable usage synopsis" + ); + } +} + +/// Prove mixed help and opaque invalid input stays a bounded failure in either order. +#[test] +fn archive_tree_help_does_not_hide_or_reflect_an_unknown_argument() { + for arguments in [ + ["--help", "--opaque-option=not-shown"], + ["--opaque-option=not-shown", "--help"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-archive-tree")) + .args(arguments) + .output() + .expect("archive-tree CLI must launch for invalid-argument validation"); + + assert!( + !output.status.success(), + "help must not turn an otherwise invalid invocation into success" + ); + assert!( + output.stdout.is_empty(), + "invalid invocation must not emit help on stdout" + ); + let stderr = + String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); + assert!( + !stderr.is_empty(), + "invalid invocation must remain visible through stderr" + ); + assert!( + !stderr.contains("not-shown"), + "mixed help diagnostics must not reflect opaque argument payloads" + ); + } +} + +/// Prove an unknown option uses the stable bounded diagnostic without reflection. +#[test] +fn archive_tree_unknown_argument_uses_bounded_diagnostic() { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-archive-tree")) + .arg("--opaque-option=not-shown") + .output() + .expect("archive-tree CLI must launch for invalid-argument validation"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); + assert_eq!(stderr.trim_end(), "archive-tree-unknown-argument"); + assert!(!stderr.contains("not-shown")); +} + +/// Prove hostile non-UTF-8 arguments fail through the stable diagnostic on Unix. +#[cfg(unix)] +#[test] +fn archive_tree_non_utf8_argument_fails_without_panic() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let opaque = OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); + let output = Command::new(env!("CARGO_BIN_EXE_disksage-archive-tree")) + .arg(opaque) + .output() + .expect("archive-tree CLI must launch for non-UTF-8 argument validation"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); + assert_eq!(stderr.trim_end(), "archive-tree-argument-invalid"); +}