From ead4c57f379c4e03cdaa96a6a82b60e7a5d075a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:16:05 +0900 Subject: [PATCH 01/26] test: require successful bounded help for eviction planning CLIs --- .../cli_help_eviction_destination_exit.rs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src-tauri/tests/cli_help_eviction_destination_exit.rs diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs new file mode 100644 index 000000000..5894dd8ea --- /dev/null +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -0,0 +1,137 @@ +//! Black-box help and invalid-argument contracts for two feature-gated operational CLIs. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const BINARIES: [(&str, &str); 2] = [ + ( + "disksage-icloud-local-eviction", + "usage: disksage-icloud-local-eviction", + ), + ( + "disksage-incomplete-download-destination-plan", + "usage: disksage-incomplete-download-destination-plan", + ), +]; + +fn build_feature_gated_binaries() -> (tempfile::TempDir, Vec) { + let target_dir = tempfile::tempdir().expect("isolated Cargo target directory must be created"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let mut command = Command::new(cargo); + command + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["build", "--locked", "--features", "cloud-cli"]); + for (binary, _) in BINARIES { + command.args(["--bin", binary]); + } + let status = command + .arg("--target-dir") + .arg(target_dir.path()) + .status() + .expect("feature-gated operational CLIs must be buildable for process contracts"); + assert!( + status.success(), + "feature-gated operational CLI build must succeed before process assertions" + ); + + let binaries = BINARIES + .iter() + .map(|(binary, _)| { + let path = target_dir + .path() + .join("debug") + .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)); + assert!( + path.is_file(), + "{binary} must exist after the explicit cloud-cli build" + ); + path + }) + .collect(); + (target_dir, binaries) +} + +fn command(binary: &Path) -> Command { + let mut command = Command::new(binary); + command.env_remove("HOME").env_remove("USERPROFILE"); + command +} + +fn assert_help_success(binary: &Path, usage: &str, flag: &str) { + let output = command(binary) + .arg(flag) + .output() + .expect("operational 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!( + stdout.contains(usage), + "help output must contain the stable usage synopsis" + ); +} + +fn assert_invalid_argument_is_bounded(binary: &Path) { + let output = command(binary) + .arg("--opaque-option=not-shown") + .output() + .expect("operational CLI must launch for invalid argument validation"); + + assert!( + !output.status.success(), + "an unknown argument must remain a non-zero failure" + ); + assert!( + output.stdout.is_empty(), + "invalid invocation must not emit successful output 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"); + assert!( + !stderr.contains("not-shown"), + "invalid diagnostics must not echo arbitrary argument payloads" + ); +} + +fn assert_help_does_not_hide_invalid_argument(binary: &Path) { + let output = command(binary) + .args(["--help", "--opaque-option=not-shown"]) + .output() + .expect("operational CLI must launch for mixed help validation"); + + assert!( + !output.status.success(), + "help must not turn an otherwise invalid invocation into success" + ); + assert!( + output.stdout.is_empty(), + "mixed invalid invocation must not emit successful help on stdout" + ); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); + assert!(!stderr.is_empty(), "mixed invalid invocation must remain visible"); + assert!( + !stderr.contains("not-shown"), + "mixed invalid diagnostics must not echo arbitrary argument payloads" + ); +} + +#[test] +fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounded() { + let (_target_dir, binaries) = build_feature_gated_binaries(); + for ((_, usage), binary) in BINARIES.iter().zip(&binaries) { + assert_help_success(binary, usage, "--help"); + assert_help_success(binary, usage, "-h"); + assert_invalid_argument_is_bounded(binary); + assert_help_does_not_hide_invalid_argument(binary); + } +} From b5a07a2bd2146a8dbf6fbff245de761287d26bc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:06:21 +0900 Subject: [PATCH 02/26] fix: make iCloud eviction help terminal and bounded --- src-tauri/src/bin/disksage-icloud-local-eviction.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index 0496fd431..37c811364 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -73,7 +73,7 @@ fn parse_args(args: &[String]) -> Result { record_dir = Some(PathBuf::from(value(args, &mut index, "--record-dir")?)) } "--help" | "-h" => return Err(usage().into()), - unknown => return Err(format!("알 수 없는 인자: {unknown}")), + _unknown => return Err("icloud-local-eviction-unknown-argument".into()), } index += 1; } @@ -166,6 +166,10 @@ fn print_json(value: &T) -> Result<(), String> { #[cfg(not(coverage))] fn run() -> Result<(), String> { let raw: Vec = std::env::args().skip(1).collect(); + if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { + println!("{}", usage()); + return Ok(()); + } let args = parse_args(&raw)?; let roots = cloud::discover_cloud_roots(&home_dir()?); let root = select_root(&roots, &args.cloud_root)?.clone(); From a20e8f6de90b98fe49037081f8785c8e7ebf7f4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:08:46 +0900 Subject: [PATCH 03/26] fix: make destination planning help terminal and bounded --- .../disksage-incomplete-download-destination-plan.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs index 388504f0d..bedeee4ec 100644 --- a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs +++ b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs @@ -153,7 +153,9 @@ fn parse_args(raw: &[String]) -> Result { private_output = Some(PathBuf::from(value(&mut index, "--private-output")?)); } "--help" | "-h" => return Err(usage()), - flag => return Err(format!("알 수 없는 인자: {flag}")), + _unknown => { + return Err("incomplete-download-destination-plan-unknown-argument".into()) + } } index += 1; } @@ -256,7 +258,12 @@ fn read_capacity_snapshot(path: &Path) -> Result #[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = parse_args(&std::env::args().skip(1).collect::>())?; + let raw = std::env::args().skip(1).collect::>(); + if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { + println!("{}", usage()); + return Ok(()); + } + let args = parse_args(&raw)?; let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "home-directory-unavailable".to_string())?; From a869c0efc98d147f1bcfe8f92e002d0eb1457f64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 16:18:25 +0900 Subject: [PATCH 04/26] test: require strict batch eviction help --- src-tauri/tests/cli_help_eviction_destination_exit.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index 5894dd8ea..2a5736a65 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -1,10 +1,10 @@ -//! Black-box help and invalid-argument contracts for two feature-gated operational CLIs. +//! Black-box help and invalid-argument contracts for feature-gated eviction CLIs. use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; -const BINARIES: [(&str, &str); 2] = [ +const BINARIES: [(&str, &str); 3] = [ ( "disksage-icloud-local-eviction", "usage: disksage-icloud-local-eviction", @@ -13,6 +13,10 @@ const BINARIES: [(&str, &str); 2] = [ "disksage-incomplete-download-destination-plan", "usage: disksage-incomplete-download-destination-plan", ), + ( + "disksage-icloud-local-eviction-batch", + "usage: disksage-icloud-local-eviction-batch", + ), ]; fn build_feature_gated_binaries() -> (tempfile::TempDir, Vec) { From 1ab412407057fb00e8983b949eccc130d019b91b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:05:00 +0900 Subject: [PATCH 05/26] fix: keep batch eviction help strictly terminal --- src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs index 2f9738ac5..41ff86498 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs @@ -44,6 +44,10 @@ fn value(args: &[String], index: &mut usize, flag: &str) -> Result Result { + if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") { + return Err(HELP_REQUESTED.into()); + } + let mut cloud_root = None; let mut manifest = None; let mut execute = false; @@ -73,7 +77,7 @@ fn parse_args(args: &[String]) -> Result { "--record-dir" => { record_dir = Some(PathBuf::from(value(args, &mut index, "--record-dir")?)) } - "--help" | "-h" => return Err(HELP_REQUESTED.into()), + "--help" | "-h" => return Err("알 수 없는 인자".into()), _unknown => return Err("알 수 없는 인자".into()), } index += 1; From 89de01a4d46fb6133a0d6aaf3713dcec6238f2de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:31:51 +0900 Subject: [PATCH 06/26] test: reject non-UTF8 eviction CLI arguments --- .../cli_help_eviction_destination_exit.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index 2a5736a65..12a49d413 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -129,6 +129,33 @@ fn assert_help_does_not_hide_invalid_argument(binary: &Path) { ); } +#[cfg(unix)] +fn assert_non_utf8_argument_is_bounded(binary: &Path) { + 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(binary) + .arg(opaque) + .output() + .expect("operational CLI must launch for non-UTF-8 argument validation"); + + assert_eq!( + output.status.code(), + Some(2), + "invalid non-UTF-8 input must use the ordinary bounded argument-error exit" + ); + assert!( + output.stdout.is_empty(), + "invalid non-UTF-8 input must not emit successful output" + ); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); + assert!(!stderr.is_empty(), "invalid non-UTF-8 input must remain visible"); + assert!( + !stderr.contains("panicked") && !stderr.contains("thread 'main'"), + "invalid host arguments must not escape through a Rust panic" + ); +} + #[test] fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounded() { let (_target_dir, binaries) = build_feature_gated_binaries(); @@ -137,5 +164,7 @@ fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounde assert_help_success(binary, usage, "-h"); assert_invalid_argument_is_bounded(binary); assert_help_does_not_hide_invalid_argument(binary); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary); } } From 64286ff7c886f15a2a1ad82cebf7aa13a54978e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:07:25 +0900 Subject: [PATCH 07/26] fix: bound non-UTF8 eviction CLI arguments --- src-tauri/src/bin/disksage-icloud-local-eviction.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index 37c811364..d7ff40f82 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -165,7 +165,14 @@ fn print_json(value: &T) -> Result<(), String> { #[cfg(not(coverage))] 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(|_| "icloud-local-eviction-invalid-utf8-argument".to_string()) + }) + .collect::, _>>()?; if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { println!("{}", usage()); return Ok(()); From d0e768b5c51ba56be33f81817efba2590d1e4c17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:11:05 +0900 Subject: [PATCH 08/26] fix: bound non-UTF8 destination-plan arguments --- .../bin/disksage-incomplete-download-destination-plan.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs index bedeee4ec..05bf9ac47 100644 --- a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs +++ b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs @@ -258,7 +258,14 @@ fn read_capacity_snapshot(path: &Path) -> Result #[cfg(not(coverage))] fn run() -> Result<(), String> { - let raw = std::env::args().skip(1).collect::>(); + let raw = std::env::args_os() + .skip(1) + .map(|argument| { + argument + .into_string() + .map_err(|_| "incomplete-download-destination-plan-invalid-utf8-argument".to_string()) + }) + .collect::, _>>()?; if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { println!("{}", usage()); return Ok(()); From 3001e7bc89783f4a80e094fce2a83abaad933816 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:14:33 +0900 Subject: [PATCH 09/26] fix: bound non-UTF8 eviction-batch arguments --- .../src/bin/disksage-icloud-local-eviction-batch.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs index 41ff86498..8318402da 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs @@ -340,7 +340,14 @@ fn print_json(value: &T) -> Result<(), String> { } 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(|_| "icloud-local-eviction-batch-invalid-utf8-argument".to_string()) + }) + .collect::, _>>()?; let args = parse_args(&raw)?; let roots = cloud::discover_cloud_roots(&home_dir()?); let root = select_root(&roots, &args.cloud_root)?.clone(); From a3694be6672ee5dab9f6e7677d26361cc9c2ccfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:14:37 +0900 Subject: [PATCH 10/26] test: pin complete eviction CLI help output --- .../cli_help_eviction_destination_exit.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index 12a49d413..1c1d61ef9 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -7,15 +7,15 @@ use std::process::Command; const BINARIES: [(&str, &str); 3] = [ ( "disksage-icloud-local-eviction", - "usage: disksage-icloud-local-eviction", + "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", ), ( "disksage-incomplete-download-destination-plan", - "usage: disksage-incomplete-download-destination-plan", + "usage: disksage-incomplete-download-destination-plan --source-root ABSOLUTE_PATH --cloud-root ABSOLUTE_PATH --destination-subdirectory RELATIVE_PATH (--live-icloud-capacity | --capacity-snapshot ABSOLUTE.json) [--max-entries 1..=200000] [--stale-after-days 1..=3650] [--capacity-reserve-mib 0..=1048576] [--private-output ABSOLUTE_NEW_FILE.json]", ), ( "disksage-icloud-local-eviction-batch", - "usage: disksage-icloud-local-eviction-batch", + "usage: disksage-icloud-local-eviction-batch --cloud-root ABSOLUTE_PATH --manifest ABSOLUTE_JSON [--execute --approved-batch-fingerprint HEX64 --confirm-batch-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", ), ]; @@ -62,7 +62,7 @@ fn command(binary: &Path) -> Command { command } -fn assert_help_success(binary: &Path, usage: &str, flag: &str) { +fn assert_help_success(binary: &Path, expected_usage: &str, flag: &str) { let output = command(binary) .arg(flag) .output() @@ -79,9 +79,10 @@ fn assert_help_success(binary: &Path, usage: &str, flag: &str) { "successful help must not be projected through stderr" ); let stdout = String::from_utf8(output.stdout).expect("help output must be valid UTF-8"); - assert!( - stdout.contains(usage), - "help output must contain the stable usage synopsis" + assert_eq!( + stdout, + format!("{expected_usage}\n"), + "help output must equal the complete stable usage contract" ); } @@ -159,9 +160,9 @@ fn assert_non_utf8_argument_is_bounded(binary: &Path) { #[test] fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounded() { let (_target_dir, binaries) = build_feature_gated_binaries(); - for ((_, usage), binary) in BINARIES.iter().zip(&binaries) { - assert_help_success(binary, usage, "--help"); - assert_help_success(binary, usage, "-h"); + for ((_, expected_usage), binary) in BINARIES.iter().zip(&binaries) { + assert_help_success(binary, expected_usage, "--help"); + assert_help_success(binary, expected_usage, "-h"); assert_invalid_argument_is_bounded(binary); assert_help_does_not_hide_invalid_argument(binary); #[cfg(unix)] From 88ba748ad1063eece3b5b31abc1b12b4863065be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:08:22 +0900 Subject: [PATCH 11/26] test: pin bounded CLI diagnostics --- .../cli_help_eviction_destination_exit.rs | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index 1c1d61ef9..4f6869439 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -4,18 +4,24 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; -const BINARIES: [(&str, &str); 3] = [ +const BINARIES: [(&str, &str, &str, &str); 3] = [ ( "disksage-icloud-local-eviction", "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", + "icloud-local-eviction-unknown-argument", + "icloud-local-eviction-invalid-utf8-argument", ), ( "disksage-incomplete-download-destination-plan", "usage: disksage-incomplete-download-destination-plan --source-root ABSOLUTE_PATH --cloud-root ABSOLUTE_PATH --destination-subdirectory RELATIVE_PATH (--live-icloud-capacity | --capacity-snapshot ABSOLUTE.json) [--max-entries 1..=200000] [--stale-after-days 1..=3650] [--capacity-reserve-mib 0..=1048576] [--private-output ABSOLUTE_NEW_FILE.json]", + "incomplete-download-destination-plan-unknown-argument", + "incomplete-download-destination-plan-invalid-utf8-argument", ), ( "disksage-icloud-local-eviction-batch", "usage: disksage-icloud-local-eviction-batch --cloud-root ABSOLUTE_PATH --manifest ABSOLUTE_JSON [--execute --approved-batch-fingerprint HEX64 --confirm-batch-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", + "알 수 없는 인자", + "icloud-local-eviction-batch-invalid-utf8-argument", ), ]; @@ -26,7 +32,7 @@ fn build_feature_gated_binaries() -> (tempfile::TempDir, Vec) { command .current_dir(env!("CARGO_MANIFEST_DIR")) .args(["build", "--locked", "--features", "cloud-cli"]); - for (binary, _) in BINARIES { + for (binary, _, _, _) in BINARIES { command.args(["--bin", binary]); } let status = command @@ -41,7 +47,7 @@ fn build_feature_gated_binaries() -> (tempfile::TempDir, Vec) { let binaries = BINARIES .iter() - .map(|(binary, _)| { + .map(|(binary, _, _, _)| { let path = target_dir .path() .join("debug") @@ -86,7 +92,7 @@ fn assert_help_success(binary: &Path, expected_usage: &str, flag: &str) { ); } -fn assert_invalid_argument_is_bounded(binary: &Path) { +fn assert_invalid_argument_is_bounded(binary: &Path, expected_diagnostic: &str) { let output = command(binary) .arg("--opaque-option=not-shown") .output() @@ -102,6 +108,10 @@ fn assert_invalid_argument_is_bounded(binary: &Path) { ); let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); assert!(!stderr.is_empty(), "invalid invocation must remain visible"); + assert!( + stderr.contains(expected_diagnostic), + "invalid invocation must emit its fixed bounded diagnostic" + ); assert!( !stderr.contains("not-shown"), "invalid diagnostics must not echo arbitrary argument payloads" @@ -131,7 +141,7 @@ fn assert_help_does_not_hide_invalid_argument(binary: &Path) { } #[cfg(unix)] -fn assert_non_utf8_argument_is_bounded(binary: &Path) { +fn assert_non_utf8_argument_is_bounded(binary: &Path, expected_diagnostic: &str) { 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]); @@ -151,6 +161,10 @@ fn assert_non_utf8_argument_is_bounded(binary: &Path) { ); let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); assert!(!stderr.is_empty(), "invalid non-UTF-8 input must remain visible"); + assert!( + stderr.contains(expected_diagnostic), + "invalid non-UTF-8 input must emit its fixed bounded diagnostic" + ); assert!( !stderr.contains("panicked") && !stderr.contains("thread 'main'"), "invalid host arguments must not escape through a Rust panic" @@ -160,12 +174,14 @@ fn assert_non_utf8_argument_is_bounded(binary: &Path) { #[test] fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounded() { let (_target_dir, binaries) = build_feature_gated_binaries(); - for ((_, expected_usage), binary) in BINARIES.iter().zip(&binaries) { + for ((_, expected_usage, expected_unknown, expected_invalid_utf8), binary) in + BINARIES.iter().zip(&binaries) + { assert_help_success(binary, expected_usage, "--help"); assert_help_success(binary, expected_usage, "-h"); - assert_invalid_argument_is_bounded(binary); + assert_invalid_argument_is_bounded(binary, expected_unknown); assert_help_does_not_hide_invalid_argument(binary); #[cfg(unix)] - assert_non_utf8_argument_is_bounded(binary); + assert_non_utf8_argument_is_bounded(binary, expected_invalid_utf8); } } From cce2f15c9c1cc946a8a84339633ee295248af053 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:23:38 -0700 Subject: [PATCH 12/26] test: require eviction CLI runtime under coverage --- ...help_eviction_coverage_runtime_contract.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs diff --git a/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs b/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs new file mode 100644 index 000000000..4a1ad7787 --- /dev/null +++ b/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs @@ -0,0 +1,80 @@ +//! Coverage instrumentation must preserve shipped runtime behavior for eviction/destination CLIs. +//! +//! These binaries are part of owned production coverage. Building with `--cfg coverage` must not +//! replace their real parser/runtime with an empty entry point, otherwise exact coverage can look +//! better while never measuring the shipped help and argument boundary. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const BINARIES: [(&str, &str); 2] = [ + ( + "disksage-icloud-local-eviction", + "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", + ), + ( + "disksage-incomplete-download-destination-plan", + "usage: disksage-incomplete-download-destination-plan --source-root ABSOLUTE_PATH --cloud-root ABSOLUTE_PATH --destination-subdirectory RELATIVE_PATH (--live-icloud-capacity | --capacity-snapshot ABSOLUTE.json) [--max-entries 1..=200000] [--stale-after-days 1..=3650] [--capacity-reserve-mib 0..=1048576] [--private-output ABSOLUTE_NEW_FILE.json]", + ), +]; + +fn build_coverage_binary(binary: &str, target_dir: &Path) -> PathBuf { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let build = Command::new(cargo) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args([ + "rustc", + "--locked", + "--features", + "cloud-cli", + "--bin", + binary, + "--target-dir", + ]) + .arg(target_dir) + .args(["--", "--cfg", "coverage"]) + .output() + .expect("Cargo should start for the coverage-instrumented operational CLI"); + assert!( + build.status.success(), + "coverage-instrumented {binary} build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + target_dir + .join("debug") + .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)) +} + +#[test] +fn coverage_instrumentation_preserves_terminal_help_runtime() { + let target = tempfile::tempdir().expect("isolated coverage target must be created"); + + for (binary, expected_usage) in BINARIES { + let executable = build_coverage_binary(binary, target.path()); + let output = Command::new(&executable) + .env_remove("HOME") + .env_remove("USERPROFILE") + .env_remove("APPDATA") + .env_remove("XDG_DATA_HOME") + .arg("--help") + .output() + .expect("coverage-instrumented operational CLI must launch"); + + assert_eq!( + output.status.code(), + Some(0), + "coverage instrumentation must preserve successful help for {binary}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "coverage-instrumented successful help must keep stderr empty for {binary}" + ); + assert_eq!( + String::from_utf8(output.stdout).expect("help output must stay valid UTF-8"), + format!("{expected_usage}\n"), + "coverage instrumentation must execute the shipped help runtime for {binary}" + ); + } +} From dfc8bb3b04f4f54069a22da34c87bed95ef29204 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:25:31 -0700 Subject: [PATCH 13/26] fix: preserve local eviction runtime under coverage --- .../src/bin/disksage-icloud-local-eviction.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index d7ff40f82..61f9ee42f 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -4,18 +4,14 @@ //! fingerprint, an attributed human approval, a second matching confirmation, and a local immutable //! record directory. -#[cfg(not(coverage))] use disksage_lib::cloud::{self, CloudRoot}; -#[cfg(not(coverage))] use disksage_lib::cloud_local_eviction::{ approve_icloud_local_eviction, execute_icloud_local_eviction, plan_icloud_local_eviction, write_immutable_record, IcloudLocalEvictionApproval, IcloudLocalEvictionPlan, IcloudLocalEvictionResult, }; -#[cfg(not(coverage))] use std::path::{Path, PathBuf}; -#[cfg(not(coverage))] #[derive(Debug, Clone, PartialEq, Eq)] struct Args { cloud_root: PathBuf, @@ -28,12 +24,10 @@ struct Args { record_dir: Option, } -#[cfg(not(coverage))] fn usage() -> &'static str { "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]" } -#[cfg(not(coverage))] fn value(args: &[String], index: &mut usize, flag: &str) -> Result { *index += 1; args.get(*index) @@ -41,7 +35,6 @@ fn value(args: &[String], index: &mut usize, flag: &str) -> Result Result { let mut cloud_root = None; let mut path = None; @@ -113,7 +106,6 @@ fn parse_args(args: &[String]) -> Result { }) } -#[cfg(not(coverage))] fn home_dir() -> Result { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) @@ -121,7 +113,6 @@ fn home_dir() -> Result { .map_err(|_| "HOME/USERPROFILE을 찾을 수 없음".into()) } -#[cfg(not(coverage))] fn select_root<'a>(roots: &'a [CloudRoot], requested: &Path) -> Result<&'a CloudRoot, String> { let matches: Vec<_> = roots .iter() @@ -134,7 +125,6 @@ fn select_root<'a>(roots: &'a [CloudRoot], requested: &Path) -> Result<&'a Cloud } } -#[cfg(not(coverage))] #[derive(Debug, serde::Serialize)] struct PlanOutput { action: &'static str, @@ -142,7 +132,6 @@ struct PlanOutput { plan: IcloudLocalEvictionPlan, } -#[cfg(not(coverage))] #[derive(Debug, serde::Serialize)] struct ExecuteOutput { action: &'static str, @@ -154,7 +143,6 @@ struct ExecuteOutput { result_record: String, } -#[cfg(not(coverage))] fn print_json(value: &T) -> Result<(), String> { println!( "{}", @@ -163,7 +151,6 @@ fn print_json(value: &T) -> Result<(), String> { Ok(()) } -#[cfg(not(coverage))] fn run() -> Result<(), String> { let raw = std::env::args_os() .skip(1) @@ -247,7 +234,6 @@ fn run() -> Result<(), String> { }) } -#[cfg(not(coverage))] fn main() { if let Err(error) = run() { eprintln!("{error}"); @@ -255,9 +241,6 @@ fn main() { } } -#[cfg(coverage)] -fn main() {} - #[cfg(test)] mod tests { use super::*; From b8cfc560dcd9533b63683d216f8686c04df205f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:26:20 -0700 Subject: [PATCH 14/26] fix: preserve destination planner runtime under coverage --- .../src/bin/disksage-incomplete-download-destination-plan.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs index 05bf9ac47..75ce49e4a 100644 --- a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs +++ b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs @@ -256,7 +256,6 @@ fn read_capacity_snapshot(path: &Path) -> Result .map_err(|_| "materialization-capacity-snapshot-json-invalid".into()) } -#[cfg(not(coverage))] fn run() -> Result<(), String> { let raw = std::env::args_os() .skip(1) @@ -336,7 +335,6 @@ fn run() -> Result<(), String> { Ok(()) } -#[cfg(not(coverage))] fn main() { if let Err(error) = run() { eprintln!("DiskSage incomplete download destination plan: {error}"); @@ -344,9 +342,6 @@ fn main() { } } -#[cfg(coverage)] -fn main() {} - #[cfg(test)] mod tests { use super::*; From 587f713f5885575e34ec2abfe346453186be0eff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:30:04 -0700 Subject: [PATCH 15/26] test: preserve native eviction CLI paths --- .../cli_help_eviction_destination_exit.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index 4f6869439..d0a0e8938 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -171,6 +171,57 @@ fn assert_non_utf8_argument_is_bounded(binary: &Path, expected_diagnostic: &str) ); } +#[cfg(unix)] +fn assert_native_path_values_are_not_forced_through_utf8(binaries: &[PathBuf]) { + use std::os::unix::ffi::OsStringExt; + + let parent = tempfile::tempdir().expect("native path parent must be created"); + let mut name = b"native-cloud-root-".to_vec(); + name.push(0xff); + let native_path = parent.path().join(OsString::from_vec(name)); + let manifest = parent.path().join("manifest.json"); + let capacity = parent.path().join("capacity.json"); + + let local = command(&binaries[0]) + .arg("--cloud-root") + .arg(&native_path) + .arg("--path") + .arg(&native_path) + .output() + .expect("local-eviction CLI must launch with native path values"); + let local_stderr = String::from_utf8(local.stderr).expect("diagnostic must remain UTF-8"); + assert_eq!(local.status.code(), Some(2)); + assert!(local_stderr.contains("HOME/USERPROFILE을 찾을 수 없음")); + assert!(!local_stderr.contains("invalid-utf8-argument")); + + let destination = command(&binaries[1]) + .arg("--source-root") + .arg(&native_path) + .arg("--cloud-root") + .arg(&native_path) + .args(["--destination-subdirectory", "Recovered", "--capacity-snapshot"]) + .arg(&capacity) + .output() + .expect("destination-plan CLI must launch with native path values"); + let destination_stderr = + String::from_utf8(destination.stderr).expect("diagnostic must remain UTF-8"); + assert_eq!(destination.status.code(), Some(2)); + assert!(destination_stderr.contains("home-directory-unavailable")); + assert!(!destination_stderr.contains("invalid-utf8-argument")); + + let batch = command(&binaries[2]) + .arg("--cloud-root") + .arg(&native_path) + .arg("--manifest") + .arg(&manifest) + .output() + .expect("batch-eviction CLI must launch with native path values"); + let batch_stderr = String::from_utf8(batch.stderr).expect("diagnostic must remain UTF-8"); + assert_eq!(batch.status.code(), Some(2)); + assert!(batch_stderr.contains("HOME을 확인할 수 없음")); + assert!(!batch_stderr.contains("invalid-utf8-argument")); +} + #[test] fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounded() { let (_target_dir, binaries) = build_feature_gated_binaries(); @@ -184,4 +235,6 @@ fn eviction_and_destination_help_are_successful_and_invalid_arguments_are_bounde #[cfg(unix)] assert_non_utf8_argument_is_bounded(binary, expected_invalid_utf8); } + #[cfg(unix)] + assert_native_path_values_are_not_forced_through_utf8(&binaries); } From 594ea142005ff3a75a9a524d111fd2362644ec80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:30:55 -0700 Subject: [PATCH 16/26] fix: preserve native local-eviction paths --- .../src/bin/disksage-icloud-local-eviction.rs | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index 61f9ee42f..c7dce8ae7 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -10,6 +10,7 @@ use disksage_lib::cloud_local_eviction::{ write_immutable_record, IcloudLocalEvictionApproval, IcloudLocalEvictionPlan, IcloudLocalEvictionResult, }; +use std::ffi::OsString; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,14 +29,20 @@ fn usage() -> &'static str { "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]" } -fn value(args: &[String], index: &mut usize, flag: &str) -> Result { +fn native_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { *index += 1; args.get(*index) .cloned() .ok_or_else(|| format!("{flag} 값이 필요함")) } -fn parse_args(args: &[String]) -> Result { +fn text_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { + native_value(args, index, flag)? + .into_string() + .map_err(|_| "icloud-local-eviction-invalid-utf8-argument".to_string()) +} + +fn parse_args_os(args: &[OsString]) -> Result { let mut cloud_root = None; let mut path = None; let mut execute = false; @@ -46,27 +53,32 @@ fn parse_args(args: &[String]) -> Result { let mut record_dir = None; let mut index = 0usize; while index < args.len() { - match args[index].as_str() { - "--cloud-root" => { - cloud_root = Some(PathBuf::from(value(args, &mut index, "--cloud-root")?)) + match args[index].to_str() { + Some("--cloud-root") => { + cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)) } - "--path" => path = Some(PathBuf::from(value(args, &mut index, "--path")?)), - "--execute" => execute = true, - "--approved-plan-fingerprint" => { + Some("--path") => path = Some(PathBuf::from(native_value(args, &mut index, "--path")?)), + Some("--execute") => execute = true, + Some("--approved-plan-fingerprint") => { approved_plan_fingerprint = - Some(value(args, &mut index, "--approved-plan-fingerprint")?) + Some(text_value(args, &mut index, "--approved-plan-fingerprint")?) } - "--confirm-plan-fingerprint" => { + Some("--confirm-plan-fingerprint") => { confirm_plan_fingerprint = - Some(value(args, &mut index, "--confirm-plan-fingerprint")?) + Some(text_value(args, &mut index, "--confirm-plan-fingerprint")?) + } + Some("--approved-by") => { + approved_by = Some(text_value(args, &mut index, "--approved-by")?) + } + Some("--rationale") => { + rationale = Some(text_value(args, &mut index, "--rationale")?) } - "--approved-by" => approved_by = Some(value(args, &mut index, "--approved-by")?), - "--rationale" => rationale = Some(value(args, &mut index, "--rationale")?), - "--record-dir" => { - record_dir = Some(PathBuf::from(value(args, &mut index, "--record-dir")?)) + Some("--record-dir") => { + record_dir = Some(PathBuf::from(native_value(args, &mut index, "--record-dir")?)) } - "--help" | "-h" => return Err(usage().into()), - _unknown => return Err("icloud-local-eviction-unknown-argument".into()), + Some("--help" | "-h") => return Err(usage().into()), + Some(_) => return Err("icloud-local-eviction-unknown-argument".into()), + None => return Err("icloud-local-eviction-invalid-utf8-argument".into()), } index += 1; } @@ -106,6 +118,12 @@ fn parse_args(args: &[String]) -> Result { }) } +#[cfg(test)] +fn parse_args(args: &[String]) -> Result { + let native = args.iter().map(OsString::from).collect::>(); + parse_args_os(&native) +} + fn home_dir() -> Result { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) @@ -152,19 +170,12 @@ fn print_json(value: &T) -> Result<(), String> { } fn run() -> Result<(), String> { - let raw = std::env::args_os() - .skip(1) - .map(|argument| { - argument - .into_string() - .map_err(|_| "icloud-local-eviction-invalid-utf8-argument".to_string()) - }) - .collect::, _>>()?; - if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { + let raw = std::env::args_os().skip(1).collect::>(); + if raw.len() == 1 && matches!(raw[0].to_str(), Some("--help" | "-h")) { println!("{}", usage()); return Ok(()); } - let args = parse_args(&raw)?; + let args = parse_args_os(&raw)?; let roots = cloud::discover_cloud_roots(&home_dir()?); let root = select_root(&roots, &args.cloud_root)?.clone(); let now_ms = cloud::system_now_ms(); From 07ce11971e06d1c089bc7f91c5cac4d8a66ad0b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:31:51 -0700 Subject: [PATCH 17/26] fix: preserve native destination-plan paths --- ...ge-incomplete-download-destination-plan.rs | 89 +++++++++++-------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs index 75ce49e4a..2181782c2 100644 --- a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs +++ b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs @@ -14,6 +14,7 @@ use disksage_lib::private_evidence::write_private_json_create_new; use disksage_lib::provider_capacity::{ collect_icloud_native_capacity, CloudCapacitySnapshot, DEFAULT_CAPACITY_RESERVE_BYTES, }; +use std::ffi::OsString; use std::io::Read; use std::path::{Component, Path, PathBuf}; @@ -61,7 +62,20 @@ fn usage() -> String { ) } -fn parse_args(raw: &[String]) -> Result { +fn native_value(raw: &[OsString], index: &mut usize, flag: &str) -> Result { + *index += 1; + raw.get(*index) + .cloned() + .ok_or_else(|| format!("{flag} 값이 필요함")) +} + +fn text_value(raw: &[OsString], index: &mut usize, flag: &str) -> Result { + native_value(raw, index, flag)? + .into_string() + .map_err(|_| "incomplete-download-destination-plan-invalid-utf8-argument".to_string()) +} + +fn parse_args_os(raw: &[OsString]) -> Result { let mut source_root = None; let mut cloud_root = None; let mut destination_subdirectory = None; @@ -73,36 +87,31 @@ fn parse_args(raw: &[String]) -> Result { let mut private_output = None; let mut index = 0usize; while index < raw.len() { - let value = |index: &mut usize, flag: &str| -> Result { - *index += 1; - raw.get(*index) - .cloned() - .ok_or_else(|| format!("{flag} 값이 필요함")) - }; - match raw[index].as_str() { - "--source-root" => { + match raw[index].to_str() { + Some("--source-root") => { if source_root.is_some() { return Err("--source-root는 한 번만 지정할 수 있음".into()); } - source_root = Some(PathBuf::from(value(&mut index, "--source-root")?)); + source_root = Some(PathBuf::from(native_value(raw, &mut index, "--source-root")?)); } - "--cloud-root" => { + Some("--cloud-root") => { if cloud_root.is_some() { return Err("--cloud-root는 한 번만 지정할 수 있음".into()); } - cloud_root = Some(PathBuf::from(value(&mut index, "--cloud-root")?)); + cloud_root = Some(PathBuf::from(native_value(raw, &mut index, "--cloud-root")?)); } - "--destination-subdirectory" => { + Some("--destination-subdirectory") => { if destination_subdirectory.is_some() { return Err("--destination-subdirectory는 한 번만 지정할 수 있음".into()); } - destination_subdirectory = Some(PathBuf::from(value( + destination_subdirectory = Some(PathBuf::from(text_value( + raw, &mut index, "--destination-subdirectory", )?)); } - "--max-entries" => { - let parsed = value(&mut index, "--max-entries")? + Some("--max-entries") => { + let parsed = text_value(raw, &mut index, "--max-entries")? .parse::() .map_err(|_| "--max-entries는 양의 정수여야 함".to_string())?; if parsed == 0 || parsed > DEFAULT_MAX_ENTRIES { @@ -112,8 +121,8 @@ fn parse_args(raw: &[String]) -> Result { } max_entries = parsed; } - "--stale-after-days" => { - let parsed = value(&mut index, "--stale-after-days")? + Some("--stale-after-days") => { + let parsed = text_value(raw, &mut index, "--stale-after-days")? .parse::() .map_err(|_| "--stale-after-days는 양의 정수여야 함".to_string())?; if !(1..=MAX_STALE_AFTER_DAYS).contains(&parsed) { @@ -123,8 +132,8 @@ fn parse_args(raw: &[String]) -> Result { } stale_after_days = parsed; } - "--capacity-reserve-mib" => { - let parsed = value(&mut index, "--capacity-reserve-mib")? + Some("--capacity-reserve-mib") => { + let parsed = text_value(raw, &mut index, "--capacity-reserve-mib")? .parse::() .map_err(|_| "--capacity-reserve-mib는 정수여야 함".to_string())?; if parsed > MAX_CAPACITY_RESERVE_MIB { @@ -134,28 +143,35 @@ fn parse_args(raw: &[String]) -> Result { } reserve_mib = parsed; } - "--live-icloud-capacity" => { + Some("--live-icloud-capacity") => { if live_icloud_capacity { return Err("--live-icloud-capacity는 한 번만 지정할 수 있음".into()); } live_icloud_capacity = true; } - "--capacity-snapshot" => { + Some("--capacity-snapshot") => { if capacity_snapshot.is_some() { return Err("--capacity-snapshot은 한 번만 지정할 수 있음".into()); } - capacity_snapshot = Some(PathBuf::from(value(&mut index, "--capacity-snapshot")?)); + capacity_snapshot = Some(PathBuf::from(native_value( + raw, + &mut index, + "--capacity-snapshot", + )?)); } - "--private-output" => { + Some("--private-output") => { if private_output.is_some() { return Err("--private-output은 한 번만 지정할 수 있음".into()); } - private_output = Some(PathBuf::from(value(&mut index, "--private-output")?)); + private_output = Some(PathBuf::from(native_value(raw, &mut index, "--private-output")?)); } - "--help" | "-h" => return Err(usage()), - _unknown => { + Some("--help" | "-h") => return Err(usage()), + Some(_) => { return Err("incomplete-download-destination-plan-unknown-argument".into()) } + None => { + return Err("incomplete-download-destination-plan-invalid-utf8-argument".into()) + } } index += 1; } @@ -199,6 +215,12 @@ fn parse_args(raw: &[String]) -> Result { }) } +#[cfg(test)] +fn parse_args(raw: &[String]) -> Result { + let native = raw.iter().map(OsString::from).collect::>(); + parse_args_os(&native) +} + fn system_now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -257,19 +279,12 @@ fn read_capacity_snapshot(path: &Path) -> Result } fn run() -> Result<(), String> { - let raw = std::env::args_os() - .skip(1) - .map(|argument| { - argument - .into_string() - .map_err(|_| "incomplete-download-destination-plan-invalid-utf8-argument".to_string()) - }) - .collect::, _>>()?; - if raw.len() == 1 && matches!(raw[0].as_str(), "--help" | "-h") { + let raw = std::env::args_os().skip(1).collect::>(); + if raw.len() == 1 && matches!(raw[0].to_str(), Some("--help" | "-h")) { println!("{}", usage()); return Ok(()); } - let args = parse_args(&raw)?; + let args = parse_args_os(&raw)?; let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "home-directory-unavailable".to_string())?; From 3212686e7deeee63eab3fd2774fd23bfe4331d31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:34:21 -0700 Subject: [PATCH 18/26] fix: preserve native batch-eviction paths --- .../disksage-icloud-local-eviction-batch.rs | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs index 8318402da..2e2efda6c 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs @@ -11,6 +11,7 @@ use disksage_lib::cloud_local_eviction_batch::{ }; use serde::Deserialize; use std::collections::BTreeMap; +use std::ffi::OsString; use std::io::Read; use std::path::{Path, PathBuf}; @@ -36,15 +37,21 @@ fn usage() -> &'static str { --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]" } -fn value(args: &[String], index: &mut usize, flag: &str) -> Result { +fn native_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { *index += 1; args.get(*index) .cloned() .ok_or_else(|| format!("{flag} 값이 필요함")) } -fn parse_args(args: &[String]) -> Result { - if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") { +fn text_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { + native_value(args, index, flag)? + .into_string() + .map_err(|_| "icloud-local-eviction-batch-invalid-utf8-argument".to_string()) +} + +fn parse_args_os(args: &[OsString]) -> Result { + if args.len() == 1 && matches!(args[0].to_str(), Some("--help" | "-h")) { return Err(HELP_REQUESTED.into()); } @@ -58,27 +65,34 @@ fn parse_args(args: &[String]) -> Result { let mut record_dir = None; let mut index = 0usize; while index < args.len() { - match args[index].as_str() { - "--cloud-root" => { - cloud_root = Some(PathBuf::from(value(args, &mut index, "--cloud-root")?)) + match args[index].to_str() { + Some("--cloud-root") => { + cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)) } - "--manifest" => manifest = Some(PathBuf::from(value(args, &mut index, "--manifest")?)), - "--execute" => execute = true, - "--approved-batch-fingerprint" => { + Some("--manifest") => { + manifest = Some(PathBuf::from(native_value(args, &mut index, "--manifest")?)) + } + Some("--execute") => execute = true, + Some("--approved-batch-fingerprint") => { approved_batch_fingerprint = - Some(value(args, &mut index, "--approved-batch-fingerprint")?) + Some(text_value(args, &mut index, "--approved-batch-fingerprint")?) } - "--confirm-batch-fingerprint" => { + Some("--confirm-batch-fingerprint") => { confirm_batch_fingerprint = - Some(value(args, &mut index, "--confirm-batch-fingerprint")?) + Some(text_value(args, &mut index, "--confirm-batch-fingerprint")?) + } + Some("--approved-by") => { + approved_by = Some(text_value(args, &mut index, "--approved-by")?) } - "--approved-by" => approved_by = Some(value(args, &mut index, "--approved-by")?), - "--rationale" => rationale = Some(value(args, &mut index, "--rationale")?), - "--record-dir" => { - record_dir = Some(PathBuf::from(value(args, &mut index, "--record-dir")?)) + Some("--rationale") => { + rationale = Some(text_value(args, &mut index, "--rationale")?) } - "--help" | "-h" => return Err("알 수 없는 인자".into()), - _unknown => return Err("알 수 없는 인자".into()), + Some("--record-dir") => { + record_dir = Some(PathBuf::from(native_value(args, &mut index, "--record-dir")?)) + } + Some("--help" | "-h") => return Err("알 수 없는 인자".into()), + Some(_) => return Err("알 수 없는 인자".into()), + None => return Err("icloud-local-eviction-batch-invalid-utf8-argument".into()), } index += 1; } @@ -121,6 +135,12 @@ fn parse_args(args: &[String]) -> Result { }) } +#[cfg(test)] +fn parse_args(args: &[String]) -> Result { + let native = args.iter().map(OsString::from).collect::>(); + parse_args_os(&native) +} + fn home_dir() -> Result { std::env::var_os("HOME") .map(PathBuf::from) @@ -340,15 +360,8 @@ fn print_json(value: &T) -> Result<(), String> { } fn run() -> Result<(), String> { - let raw = std::env::args_os() - .skip(1) - .map(|argument| { - argument - .into_string() - .map_err(|_| "icloud-local-eviction-batch-invalid-utf8-argument".to_string()) - }) - .collect::, _>>()?; - let args = parse_args(&raw)?; + let raw = std::env::args_os().skip(1).collect::>(); + let args = parse_args_os(&raw)?; let roots = cloud::discover_cloud_roots(&home_dir()?); let root = select_root(&roots, &args.cloud_root)?.clone(); validate_control_locations( From 4ea432f3981af1a78bb27887fca27c8c44642e8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:27:24 -0700 Subject: [PATCH 19/26] test: reject duplicate eviction authority options --- ...al_eviction_duplicate_authority_process.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src-tauri/tests/icloud_local_eviction_duplicate_authority_process.rs diff --git a/src-tauri/tests/icloud_local_eviction_duplicate_authority_process.rs b/src-tauri/tests/icloud_local_eviction_duplicate_authority_process.rs new file mode 100644 index 000000000..e266eab25 --- /dev/null +++ b/src-tauri/tests/icloud_local_eviction_duplicate_authority_process.rs @@ -0,0 +1,96 @@ +//! Duplicate singleton authority options must fail closed before provider or filesystem work. +//! +//! This exercises the shipped feature-gated local-eviction binary rather than only its parser. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn build_binary() -> (tempfile::TempDir, PathBuf) { + let target_dir = tempfile::tempdir().expect("isolated Cargo target directory must be created"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let status = Command::new(cargo) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args([ + "build", + "--locked", + "--features", + "cloud-cli", + "--bin", + "disksage-icloud-local-eviction", + "--target-dir", + ]) + .arg(target_dir.path()) + .status() + .expect("local-eviction CLI must be buildable for the process contract"); + assert!(status.success(), "local-eviction CLI build must succeed"); + let binary = target_dir + .path() + .join("debug") + .join(format!( + "disksage-icloud-local-eviction{}", + std::env::consts::EXE_SUFFIX + )); + assert!(binary.is_file(), "local-eviction CLI must exist after build"); + (target_dir, binary) +} + +fn assert_duplicate_rejected(binary: &Path, args: &[&OsStr], expected: &str) { + let output = Command::new(binary) + .args(args) + .env_remove("HOME") + .env_remove("USERPROFILE") + .output() + .expect("local-eviction CLI must launch for duplicate-option validation"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty(), "invalid authority input must not emit success JSON"); + let stderr = String::from_utf8(output.stderr).expect("diagnostic must remain valid UTF-8"); + assert_eq!(stderr.trim_end(), expected); +} + +#[test] +fn duplicate_eviction_authority_options_fail_closed_before_domain_work() { + let (_target_dir, binary) = build_binary(); + let fixture = tempfile::tempdir().expect("fixture root must be created"); + let cloud_root = fixture.path().join("Cloud"); + let alternate_root = fixture.path().join("OtherCloud"); + let file = cloud_root.join("file.bin"); + let alternate_file = cloud_root.join("other.bin"); + + assert_duplicate_rejected( + &binary, + &[ + OsStr::new("--cloud-root"), + cloud_root.as_os_str(), + OsStr::new("--cloud-root"), + alternate_root.as_os_str(), + OsStr::new("--path"), + file.as_os_str(), + ], + "--cloud-root는 한 번만 지정할 수 있음", + ); + assert_duplicate_rejected( + &binary, + &[ + OsStr::new("--cloud-root"), + cloud_root.as_os_str(), + OsStr::new("--path"), + file.as_os_str(), + OsStr::new("--path"), + alternate_file.as_os_str(), + ], + "--path는 한 번만 지정할 수 있음", + ); + assert_duplicate_rejected( + &binary, + &[ + OsStr::new("--cloud-root"), + cloud_root.as_os_str(), + OsStr::new("--path"), + file.as_os_str(), + OsStr::new("--execute"), + OsStr::new("--execute"), + ], + "--execute는 한 번만 지정할 수 있음", + ); +} From 0f8cf2ff4640af8085d15808da45374b949e7024 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:28:28 -0700 Subject: [PATCH 20/26] fix: reject duplicate eviction authority options --- .../src/bin/disksage-icloud-local-eviction.rs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index c7dce8ae7..41161d31a 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -55,25 +55,53 @@ fn parse_args_os(args: &[OsString]) -> Result { while index < args.len() { match args[index].to_str() { Some("--cloud-root") => { - cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)) + if cloud_root.is_some() { + return Err("--cloud-root는 한 번만 지정할 수 있음".into()); + } + cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)); + } + Some("--path") => { + if path.is_some() { + return Err("--path는 한 번만 지정할 수 있음".into()); + } + path = Some(PathBuf::from(native_value(args, &mut index, "--path")?)); + } + Some("--execute") => { + if execute { + return Err("--execute는 한 번만 지정할 수 있음".into()); + } + execute = true; } - Some("--path") => path = Some(PathBuf::from(native_value(args, &mut index, "--path")?)), - Some("--execute") => execute = true, Some("--approved-plan-fingerprint") => { + if approved_plan_fingerprint.is_some() { + return Err("--approved-plan-fingerprint는 한 번만 지정할 수 있음".into()); + } approved_plan_fingerprint = Some(text_value(args, &mut index, "--approved-plan-fingerprint")?) } Some("--confirm-plan-fingerprint") => { + if confirm_plan_fingerprint.is_some() { + return Err("--confirm-plan-fingerprint는 한 번만 지정할 수 있음".into()); + } confirm_plan_fingerprint = Some(text_value(args, &mut index, "--confirm-plan-fingerprint")?) } Some("--approved-by") => { + if approved_by.is_some() { + return Err("--approved-by는 한 번만 지정할 수 있음".into()); + } approved_by = Some(text_value(args, &mut index, "--approved-by")?) } Some("--rationale") => { + if rationale.is_some() { + return Err("--rationale은 한 번만 지정할 수 있음".into()); + } rationale = Some(text_value(args, &mut index, "--rationale")?) } Some("--record-dir") => { + if record_dir.is_some() { + return Err("--record-dir는 한 번만 지정할 수 있음".into()); + } record_dir = Some(PathBuf::from(native_value(args, &mut index, "--record-dir")?)) } Some("--help" | "-h") => return Err(usage().into()), From 804e32380a7484cb154927ef54207fdfc06f517f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:29:03 -0700 Subject: [PATCH 21/26] test: reject duplicate eviction family singletons --- ...viction_cli_duplicate_singleton_process.rs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src-tauri/tests/eviction_cli_duplicate_singleton_process.rs diff --git a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs new file mode 100644 index 000000000..bbdf4fc23 --- /dev/null +++ b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs @@ -0,0 +1,162 @@ +//! Eviction-family singleton options must not use last-one-wins semantics. +//! +//! Exercise the shipped batch-eviction and destination-plan binaries so duplicated authority and +//! resource-limit fields fail before HOME, provider discovery, capacity reads, or filesystem work. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const BINARIES: [&str; 2] = [ + "disksage-icloud-local-eviction-batch", + "disksage-incomplete-download-destination-plan", +]; + +fn build_binaries() -> (tempfile::TempDir, Vec) { + let target_dir = tempfile::tempdir().expect("isolated Cargo target directory must be created"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let mut command = Command::new(cargo); + command + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["build", "--locked", "--features", "cloud-cli"]); + for binary in BINARIES { + command.args(["--bin", binary]); + } + let status = command + .arg("--target-dir") + .arg(target_dir.path()) + .status() + .expect("eviction-family CLIs must be buildable for process contracts"); + assert!(status.success(), "eviction-family CLI build must succeed"); + let paths = BINARIES + .iter() + .map(|binary| { + target_dir + .path() + .join("debug") + .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)) + }) + .collect::>(); + for path in &paths { + assert!(path.is_file(), "expected CLI binary must exist after build"); + } + (target_dir, paths) +} + +fn assert_rejected(binary: &Path, args: &[&OsStr], expected: &str) { + let output = Command::new(binary) + .args(args) + .env_remove("HOME") + .env_remove("USERPROFILE") + .output() + .expect("CLI must launch for duplicate-option validation"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty(), "invalid input must not emit success JSON"); + let stderr = String::from_utf8(output.stderr).expect("diagnostic must remain valid UTF-8"); + assert_eq!(stderr.trim_end(), expected); +} + +#[test] +fn batch_eviction_rejects_duplicate_authority_options() { + let (_target_dir, binaries) = build_binaries(); + let fixture = tempfile::tempdir().expect("fixture directory must be created"); + let root = fixture.path().join("Cloud"); + let other_root = fixture.path().join("OtherCloud"); + let manifest = fixture.path().join("manifest.json"); + let other_manifest = fixture.path().join("other.json"); + + assert_rejected( + &binaries[0], + &[ + OsStr::new("--cloud-root"), + root.as_os_str(), + OsStr::new("--cloud-root"), + other_root.as_os_str(), + OsStr::new("--manifest"), + manifest.as_os_str(), + ], + "--cloud-root는 한 번만 지정할 수 있음", + ); + assert_rejected( + &binaries[0], + &[ + OsStr::new("--cloud-root"), + root.as_os_str(), + OsStr::new("--manifest"), + manifest.as_os_str(), + OsStr::new("--manifest"), + other_manifest.as_os_str(), + ], + "--manifest는 한 번만 지정할 수 있음", + ); + assert_rejected( + &binaries[0], + &[ + OsStr::new("--cloud-root"), + root.as_os_str(), + OsStr::new("--manifest"), + manifest.as_os_str(), + OsStr::new("--execute"), + OsStr::new("--execute"), + ], + "--execute는 한 번만 지정할 수 있음", + ); +} + +#[test] +fn destination_plan_rejects_duplicate_resource_limits() { + let (_target_dir, binaries) = build_binaries(); + let fixture = tempfile::tempdir().expect("fixture directory must be created"); + let source = fixture.path().join("source"); + let cloud = fixture.path().join("cloud"); + let capacity = fixture.path().join("capacity.json"); + let prefix = [ + OsStr::new("--source-root"), + source.as_os_str(), + OsStr::new("--cloud-root"), + cloud.as_os_str(), + OsStr::new("--destination-subdirectory"), + OsStr::new("Recovered"), + OsStr::new("--capacity-snapshot"), + capacity.as_os_str(), + ]; + + let mut max_entries = prefix.to_vec(); + max_entries.extend([ + OsStr::new("--max-entries"), + OsStr::new("1"), + OsStr::new("--max-entries"), + OsStr::new("2"), + ]); + assert_rejected( + &binaries[1], + &max_entries, + "--max-entries는 한 번만 지정할 수 있음", + ); + + let mut stale = prefix.to_vec(); + stale.extend([ + OsStr::new("--stale-after-days"), + OsStr::new("1"), + OsStr::new("--stale-after-days"), + OsStr::new("2"), + ]); + assert_rejected( + &binaries[1], + &stale, + "--stale-after-days는 한 번만 지정할 수 있음", + ); + + let mut reserve = prefix.to_vec(); + reserve.extend([ + OsStr::new("--capacity-reserve-mib"), + OsStr::new("1"), + OsStr::new("--capacity-reserve-mib"), + OsStr::new("2"), + ]); + assert_rejected( + &binaries[1], + &reserve, + "--capacity-reserve-mib는 한 번만 지정할 수 있음", + ); +} From 1b69519d75db9446975a9a646401a0dec89d5d22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:31:09 -0700 Subject: [PATCH 22/26] fix: reject duplicate destination plan limits --- ...sksage-incomplete-download-destination-plan.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs index 2181782c2..4154fcf88 100644 --- a/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs +++ b/src-tauri/src/bin/disksage-incomplete-download-destination-plan.rs @@ -80,8 +80,11 @@ fn parse_args_os(raw: &[OsString]) -> Result { let mut cloud_root = None; let mut destination_subdirectory = None; let mut max_entries = DEFAULT_MAX_ENTRIES; + let mut max_entries_seen = false; let mut stale_after_days = DEFAULT_STALE_AFTER_DAYS; + let mut stale_after_days_seen = false; let mut reserve_mib = DEFAULT_CAPACITY_RESERVE_BYTES / (1024 * 1024); + let mut reserve_mib_seen = false; let mut live_icloud_capacity = false; let mut capacity_snapshot = None; let mut private_output = None; @@ -111,6 +114,10 @@ fn parse_args_os(raw: &[OsString]) -> Result { )?)); } Some("--max-entries") => { + if max_entries_seen { + return Err("--max-entries는 한 번만 지정할 수 있음".into()); + } + max_entries_seen = true; let parsed = text_value(raw, &mut index, "--max-entries")? .parse::() .map_err(|_| "--max-entries는 양의 정수여야 함".to_string())?; @@ -122,6 +129,10 @@ fn parse_args_os(raw: &[OsString]) -> Result { max_entries = parsed; } Some("--stale-after-days") => { + if stale_after_days_seen { + return Err("--stale-after-days는 한 번만 지정할 수 있음".into()); + } + stale_after_days_seen = true; let parsed = text_value(raw, &mut index, "--stale-after-days")? .parse::() .map_err(|_| "--stale-after-days는 양의 정수여야 함".to_string())?; @@ -133,6 +144,10 @@ fn parse_args_os(raw: &[OsString]) -> Result { stale_after_days = parsed; } Some("--capacity-reserve-mib") => { + if reserve_mib_seen { + return Err("--capacity-reserve-mib는 한 번만 지정할 수 있음".into()); + } + reserve_mib_seen = true; let parsed = text_value(raw, &mut index, "--capacity-reserve-mib")? .parse::() .map_err(|_| "--capacity-reserve-mib는 정수여야 함".to_string())?; From b1ef4c256c64f1b1d79d329ccc0097b1c4b176a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:32:38 -0700 Subject: [PATCH 23/26] fix: reject duplicate batch eviction authority options --- .../disksage-icloud-local-eviction-batch.rs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs index 2e2efda6c..f2d6182dc 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs @@ -67,27 +67,53 @@ fn parse_args_os(args: &[OsString]) -> Result { while index < args.len() { match args[index].to_str() { Some("--cloud-root") => { - cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)) + if cloud_root.is_some() { + return Err("--cloud-root는 한 번만 지정할 수 있음".into()); + } + cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)); } Some("--manifest") => { - manifest = Some(PathBuf::from(native_value(args, &mut index, "--manifest")?)) + if manifest.is_some() { + return Err("--manifest는 한 번만 지정할 수 있음".into()); + } + manifest = Some(PathBuf::from(native_value(args, &mut index, "--manifest")?)); + } + Some("--execute") => { + if execute { + return Err("--execute는 한 번만 지정할 수 있음".into()); + } + execute = true; } - Some("--execute") => execute = true, Some("--approved-batch-fingerprint") => { + if approved_batch_fingerprint.is_some() { + return Err("--approved-batch-fingerprint는 한 번만 지정할 수 있음".into()); + } approved_batch_fingerprint = Some(text_value(args, &mut index, "--approved-batch-fingerprint")?) } Some("--confirm-batch-fingerprint") => { + if confirm_batch_fingerprint.is_some() { + return Err("--confirm-batch-fingerprint는 한 번만 지정할 수 있음".into()); + } confirm_batch_fingerprint = Some(text_value(args, &mut index, "--confirm-batch-fingerprint")?) } Some("--approved-by") => { + if approved_by.is_some() { + return Err("--approved-by는 한 번만 지정할 수 있음".into()); + } approved_by = Some(text_value(args, &mut index, "--approved-by")?) } Some("--rationale") => { + if rationale.is_some() { + return Err("--rationale은 한 번만 지정할 수 있음".into()); + } rationale = Some(text_value(args, &mut index, "--rationale")?) } Some("--record-dir") => { + if record_dir.is_some() { + return Err("--record-dir는 한 번만 지정할 수 있음".into()); + } record_dir = Some(PathBuf::from(native_value(args, &mut index, "--record-dir")?)) } Some("--help" | "-h") => return Err("알 수 없는 인자".into()), From 03e8954e7362850d7286a0c9f7e44301219936e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:12:34 -0700 Subject: [PATCH 24/26] test: align eviction runtime coverage contract --- ...help_eviction_coverage_runtime_contract.rs | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs b/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs index 4a1ad7787..8c13577c1 100644 --- a/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs +++ b/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs @@ -1,13 +1,18 @@ -//! Coverage instrumentation must preserve shipped runtime behavior for eviction/destination CLIs. +//! Shipped-runtime and coverage-entrypoint contract for eviction/destination CLIs. //! -//! These binaries are part of owned production coverage. Building with `--cfg coverage` must not -//! replace their real parser/runtime with an empty entry point, otherwise exact coverage can look -//! better while never measuring the shipped help and argument boundary. +//! Repository-wide coverage is intentionally collected without defining `cfg(coverage)` because +//! that synthetic cfg historically changed production semantics. This regression therefore builds +//! the real feature-gated binaries in their production configuration, executes terminal help, and +//! separately prevents either entrypoint from regaining a coverage-only no-op replacement. use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; +const LOCAL_EVICTION_SOURCE: &str = include_str!("../src/bin/disksage-icloud-local-eviction.rs"); +const DESTINATION_PLAN_SOURCE: &str = + include_str!("../src/bin/disksage-incomplete-download-destination-plan.rs"); + const BINARIES: [(&str, &str); 2] = [ ( "disksage-icloud-local-eviction", @@ -19,12 +24,12 @@ const BINARIES: [(&str, &str); 2] = [ ), ]; -fn build_coverage_binary(binary: &str, target_dir: &Path) -> PathBuf { +fn build_shipped_binary(binary: &str, target_dir: &Path) -> PathBuf { let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); let build = Command::new(cargo) .current_dir(env!("CARGO_MANIFEST_DIR")) .args([ - "rustc", + "build", "--locked", "--features", "cloud-cli", @@ -33,12 +38,11 @@ fn build_coverage_binary(binary: &str, target_dir: &Path) -> PathBuf { "--target-dir", ]) .arg(target_dir) - .args(["--", "--cfg", "coverage"]) .output() - .expect("Cargo should start for the coverage-instrumented operational CLI"); + .expect("Cargo should start for the shipped operational CLI"); assert!( build.status.success(), - "coverage-instrumented {binary} build failed: {}", + "shipped {binary} build failed: {}", String::from_utf8_lossy(&build.stderr) ); target_dir @@ -47,11 +51,31 @@ fn build_coverage_binary(binary: &str, target_dir: &Path) -> PathBuf { } #[test] -fn coverage_instrumentation_preserves_terminal_help_runtime() { - let target = tempfile::tempdir().expect("isolated coverage target must be created"); +fn eviction_coverage_contract_keeps_shipped_entrypoints_real() { + for (name, source) in [ + ("disksage-icloud-local-eviction", LOCAL_EVICTION_SOURCE), + ( + "disksage-incomplete-download-destination-plan", + DESTINATION_PLAN_SOURCE, + ), + ] { + assert!( + !source.contains("#[cfg(coverage)]\nfn main()"), + "coverage must never replace the shipped {name} entrypoint with a synthetic main" + ); + assert!( + !source.contains("#[cfg(not(coverage))]\nfn main()"), + "the shipped {name} entrypoint must remain present under instrumentation" + ); + } +} + +#[test] +fn shipped_eviction_clis_preserve_terminal_help_runtime() { + let target = tempfile::tempdir().expect("isolated build target must be created"); for (binary, expected_usage) in BINARIES { - let executable = build_coverage_binary(binary, target.path()); + let executable = build_shipped_binary(binary, target.path()); let output = Command::new(&executable) .env_remove("HOME") .env_remove("USERPROFILE") @@ -59,22 +83,22 @@ fn coverage_instrumentation_preserves_terminal_help_runtime() { .env_remove("XDG_DATA_HOME") .arg("--help") .output() - .expect("coverage-instrumented operational CLI must launch"); + .expect("shipped operational CLI must launch"); assert_eq!( output.status.code(), Some(0), - "coverage instrumentation must preserve successful help for {binary}: {}", + "shipped help must remain successful for {binary}: {}", String::from_utf8_lossy(&output.stderr) ); assert!( output.stderr.is_empty(), - "coverage-instrumented successful help must keep stderr empty for {binary}" + "successful shipped help must keep stderr empty for {binary}" ); assert_eq!( String::from_utf8(output.stdout).expect("help output must stay valid UTF-8"), format!("{expected_usage}\n"), - "coverage instrumentation must execute the shipped help runtime for {binary}" + "the shipped help runtime must remain exact for {binary}" ); } } From 34ced3f4e7907da737224c2957dbbbb2b6f9dc8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 18:31:06 -0700 Subject: [PATCH 25/26] test: reuse eviction CLI build across singleton probes --- ...viction_cli_duplicate_singleton_process.rs | 76 +++++++++++-------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs index bbdf4fc23..1906da6a3 100644 --- a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs +++ b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs @@ -6,41 +6,57 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::OnceLock; const BINARIES: [&str; 2] = [ "disksage-icloud-local-eviction-batch", "disksage-incomplete-download-destination-plan", ]; -fn build_binaries() -> (tempfile::TempDir, Vec) { - let target_dir = tempfile::tempdir().expect("isolated Cargo target directory must be created"); - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); - let mut command = Command::new(cargo); - command - .current_dir(env!("CARGO_MANIFEST_DIR")) - .args(["build", "--locked", "--features", "cloud-cli"]); - for binary in BINARIES { - command.args(["--bin", binary]); - } - let status = command - .arg("--target-dir") - .arg(target_dir.path()) - .status() - .expect("eviction-family CLIs must be buildable for process contracts"); - assert!(status.success(), "eviction-family CLI build must succeed"); - let paths = BINARIES - .iter() - .map(|binary| { - target_dir - .path() - .join("debug") - .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)) +/// Build the two shipped feature-gated CLIs once for this integration-test process. +/// +/// The test harness may execute the two parser regressions concurrently. Building the full Tauri +/// `cloud-cli` graph independently in each test creates avoidable CPU/disk contention and made the +/// otherwise deterministic process contract fail under hosted CI. `OnceLock` preserves the real +/// shipped-binary boundary while making the expensive prerequisite single-writer within the test. +fn binaries() -> &'static [PathBuf] { + static BINARY_PATHS: OnceLock> = OnceLock::new(); + BINARY_PATHS + .get_or_init(|| { + let target_dir = std::env::temp_dir().join(format!( + "disksage-eviction-cli-duplicate-singletons-{}", + std::process::id() + )); + std::fs::create_dir_all(&target_dir) + .expect("isolated Cargo target directory must be created"); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let mut command = Command::new(cargo); + command + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["build", "--locked", "--features", "cloud-cli"]); + for binary in BINARIES { + command.args(["--bin", binary]); + } + let status = command + .arg("--target-dir") + .arg(&target_dir) + .status() + .expect("eviction-family CLIs must be buildable for process contracts"); + assert!(status.success(), "eviction-family CLI build must succeed"); + let paths = BINARIES + .iter() + .map(|binary| { + target_dir + .join("debug") + .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)) + }) + .collect::>(); + for path in &paths { + assert!(path.is_file(), "expected CLI binary must exist after build"); + } + paths }) - .collect::>(); - for path in &paths { - assert!(path.is_file(), "expected CLI binary must exist after build"); - } - (target_dir, paths) + .as_slice() } fn assert_rejected(binary: &Path, args: &[&OsStr], expected: &str) { @@ -58,7 +74,7 @@ fn assert_rejected(binary: &Path, args: &[&OsStr], expected: &str) { #[test] fn batch_eviction_rejects_duplicate_authority_options() { - let (_target_dir, binaries) = build_binaries(); + let binaries = binaries(); let fixture = tempfile::tempdir().expect("fixture directory must be created"); let root = fixture.path().join("Cloud"); let other_root = fixture.path().join("OtherCloud"); @@ -105,7 +121,7 @@ fn batch_eviction_rejects_duplicate_authority_options() { #[test] fn destination_plan_rejects_duplicate_resource_limits() { - let (_target_dir, binaries) = build_binaries(); + let binaries = binaries(); let fixture = tempfile::tempdir().expect("fixture directory must be created"); let source = fixture.path().join("source"); let cloud = fixture.path().join("cloud"); From 687c0148e608fcf6ffe4bc0e13a2f9c2d88ffbde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:18:50 -0700 Subject: [PATCH 26/26] test: match destination-plan shipped diagnostics --- src-tauri/tests/eviction_cli_duplicate_singleton_process.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs index 1906da6a3..ba4c3ed12 100644 --- a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs +++ b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs @@ -147,7 +147,7 @@ fn destination_plan_rejects_duplicate_resource_limits() { assert_rejected( &binaries[1], &max_entries, - "--max-entries는 한 번만 지정할 수 있음", + "DiskSage incomplete download destination plan: --max-entries는 한 번만 지정할 수 있음", ); let mut stale = prefix.to_vec(); @@ -160,7 +160,7 @@ fn destination_plan_rejects_duplicate_resource_limits() { assert_rejected( &binaries[1], &stale, - "--stale-after-days는 한 번만 지정할 수 있음", + "DiskSage incomplete download destination plan: --stale-after-days는 한 번만 지정할 수 있음", ); let mut reserve = prefix.to_vec(); @@ -173,6 +173,6 @@ fn destination_plan_rejects_duplicate_resource_limits() { assert_rejected( &binaries[1], &reserve, - "--capacity-reserve-mib는 한 번만 지정할 수 있음", + "DiskSage incomplete download destination plan: --capacity-reserve-mib는 한 번만 지정할 수 있음", ); }