Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [] }

Expand Down
31 changes: 28 additions & 3 deletions src-tauri/src/bin/disksage-archive-tree.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
//! 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;

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<String>,
/// Optional archive that must contain every content item from `zip`.
superset_zip: Option<PathBuf>,
/// 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<String, String> {
*index += 1;
args.get(*index)
.cloned()
.ok_or_else(|| format!("{flag} 값이 필요함"))
}

/// Parses bounded UTF-8 command arguments without reflecting unknown payloads.
fn parse_args(args: &[String]) -> Result<Args, String> {
let mut zip = None;
let mut expected_tree = None;
Expand All @@ -40,7 +52,7 @@ fn parse_args(args: &[String]) -> Result<Args, String> {
}
"--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;
}
Expand All @@ -55,8 +67,20 @@ fn parse_args(args: &[String]) -> Result<Args, String> {
})
}

/// Reads process arguments, performs the requested read-only proof, and prints JSON evidence.
fn run() -> Result<(), String> {
let raw: Vec<String> = 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::<Result<Vec<_>, _>>()?;
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
Expand Down Expand Up @@ -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}");
Expand Down
98 changes: 98 additions & 0 deletions src-tauri/tests/archive_tree_help_exit.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading