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..f2d6182dc 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,14 +37,24 @@ 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 { +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()); + } + let mut cloud_root = None; let mut manifest = None; let mut execute = false; @@ -54,27 +65,60 @@ 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") => { + if cloud_root.is_some() { + return Err("--cloud-root는 한 번만 지정할 수 있음".into()); + } + cloud_root = Some(PathBuf::from(native_value(args, &mut index, "--cloud-root")?)); + } + Some("--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; } - "--manifest" => manifest = Some(PathBuf::from(value(args, &mut index, "--manifest")?)), - "--execute" => execute = true, - "--approved-batch-fingerprint" => { + Some("--approved-batch-fingerprint") => { + if approved_batch_fingerprint.is_some() { + return Err("--approved-batch-fingerprint는 한 번만 지정할 수 있음".into()); + } 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") => { + if confirm_batch_fingerprint.is_some() { + return Err("--confirm-batch-fingerprint는 한 번만 지정할 수 있음".into()); + } confirm_batch_fingerprint = - Some(value(args, &mut index, "--confirm-batch-fingerprint")?) + Some(text_value(args, &mut index, "--confirm-batch-fingerprint")?) } - "--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("--approved-by") => { + if approved_by.is_some() { + return Err("--approved-by는 한 번만 지정할 수 있음".into()); + } + approved_by = Some(text_value(args, &mut index, "--approved-by")?) } - "--help" | "-h" => return Err(HELP_REQUESTED.into()), - _unknown => return Err("알 수 없는 인자".into()), + 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()), + Some(_) => return Err("알 수 없는 인자".into()), + None => return Err("icloud-local-eviction-batch-invalid-utf8-argument".into()), } index += 1; } @@ -117,6 +161,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) @@ -336,8 +386,8 @@ fn print_json(value: &T) -> Result<(), String> { } fn run() -> Result<(), String> { - let raw: Vec = std::env::args().skip(1).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( diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction.rs b/src-tauri/src/bin/disksage-icloud-local-eviction.rs index 0496fd431..41161d31a 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction.rs @@ -4,18 +4,15 @@ //! 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::ffi::OsString; use std::path::{Path, PathBuf}; -#[cfg(not(coverage))] #[derive(Debug, Clone, PartialEq, Eq)] struct Args { cloud_root: PathBuf, @@ -28,21 +25,24 @@ 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 { +fn native_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { *index += 1; args.get(*index) .cloned() .ok_or_else(|| format!("{flag} 값이 필요함")) } -#[cfg(not(coverage))] -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; @@ -53,27 +53,60 @@ 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") => { + 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")?)); } - "--path" => path = Some(PathBuf::from(value(args, &mut index, "--path")?)), - "--execute" => execute = true, - "--approved-plan-fingerprint" => { + Some("--execute") => { + if execute { + return Err("--execute는 한 번만 지정할 수 있음".into()); + } + execute = true; + } + Some("--approved-plan-fingerprint") => { + if approved_plan_fingerprint.is_some() { + return Err("--approved-plan-fingerprint는 한 번만 지정할 수 있음".into()); + } 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") => { + if confirm_plan_fingerprint.is_some() { + return Err("--confirm-plan-fingerprint는 한 번만 지정할 수 있음".into()); + } confirm_plan_fingerprint = - Some(value(args, &mut index, "--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")?) } - "--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") => { + if record_dir.is_some() { + return Err("--record-dir는 한 번만 지정할 수 있음".into()); + } + record_dir = Some(PathBuf::from(native_value(args, &mut index, "--record-dir")?)) } - "--help" | "-h" => return Err(usage().into()), - unknown => return Err(format!("알 수 없는 인자: {unknown}")), + 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; } @@ -113,7 +146,12 @@ fn parse_args(args: &[String]) -> Result { }) } -#[cfg(not(coverage))] +#[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")) @@ -121,7 +159,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 +171,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 +178,6 @@ struct PlanOutput { plan: IcloudLocalEvictionPlan, } -#[cfg(not(coverage))] #[derive(Debug, serde::Serialize)] struct ExecuteOutput { action: &'static str, @@ -154,7 +189,6 @@ struct ExecuteOutput { result_record: String, } -#[cfg(not(coverage))] fn print_json(value: &T) -> Result<(), String> { println!( "{}", @@ -163,10 +197,13 @@ fn print_json(value: &T) -> Result<(), String> { Ok(()) } -#[cfg(not(coverage))] fn run() -> Result<(), String> { - let raw: Vec = std::env::args().skip(1).collect(); - let args = parse_args(&raw)?; + 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_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(); @@ -236,7 +273,6 @@ fn run() -> Result<(), String> { }) } -#[cfg(not(coverage))] fn main() { if let Err(error) = run() { eprintln!("{error}"); @@ -244,9 +280,6 @@ fn main() { } } -#[cfg(coverage)] -fn main() {} - #[cfg(test)] mod tests { use super::*; 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..4154fcf88 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,48 +62,63 @@ 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; 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; 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") => { + 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())?; if parsed == 0 || parsed > DEFAULT_MAX_ENTRIES { @@ -112,8 +128,12 @@ fn parse_args(raw: &[String]) -> Result { } max_entries = parsed; } - "--stale-after-days" => { - let parsed = value(&mut index, "--stale-after-days")? + 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())?; if !(1..=MAX_STALE_AFTER_DAYS).contains(&parsed) { @@ -123,8 +143,12 @@ 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") => { + 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())?; if parsed > MAX_CAPACITY_RESERVE_MIB { @@ -134,26 +158,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")?)); + } + 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()) } - "--help" | "-h" => return Err(usage()), - flag => return Err(format!("알 수 없는 인자: {flag}")), } index += 1; } @@ -197,6 +230,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) @@ -254,9 +293,13 @@ fn read_capacity_snapshot(path: &Path) -> Result .map_err(|_| "materialization-capacity-snapshot-json-invalid".into()) } -#[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = parse_args(&std::env::args().skip(1).collect::>())?; + 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_os(&raw)?; let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "home-directory-unavailable".to_string())?; @@ -322,7 +365,6 @@ fn run() -> Result<(), String> { Ok(()) } -#[cfg(not(coverage))] fn main() { if let Err(error) = run() { eprintln!("DiskSage incomplete download destination plan: {error}"); @@ -330,9 +372,6 @@ fn main() { } } -#[cfg(coverage)] -fn main() {} - #[cfg(test)] mod tests { use super::*; 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..8c13577c1 --- /dev/null +++ b/src-tauri/tests/cli_help_eviction_coverage_runtime_contract.rs @@ -0,0 +1,104 @@ +//! Shipped-runtime and coverage-entrypoint contract for eviction/destination CLIs. +//! +//! 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", + "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_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([ + "build", + "--locked", + "--features", + "cloud-cli", + "--bin", + binary, + "--target-dir", + ]) + .arg(target_dir) + .output() + .expect("Cargo should start for the shipped operational CLI"); + assert!( + build.status.success(), + "shipped {binary} build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + target_dir + .join("debug") + .join(format!("{binary}{}", std::env::consts::EXE_SUFFIX)) +} + +#[test] +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_shipped_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("shipped operational CLI must launch"); + + assert_eq!( + output.status.code(), + Some(0), + "shipped help must remain successful for {binary}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "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"), + "the shipped help runtime must remain exact for {binary}" + ); + } +} 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..d0a0e8938 --- /dev/null +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -0,0 +1,240 @@ +//! 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, &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", + ), +]; + +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, expected_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_eq!( + stdout, + format!("{expected_usage}\n"), + "help output must equal the complete stable usage contract" + ); +} + +fn assert_invalid_argument_is_bounded(binary: &Path, expected_diagnostic: &str) { + 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(expected_diagnostic), + "invalid invocation must emit its fixed bounded diagnostic" + ); + 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" + ); +} + +#[cfg(unix)] +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]); + 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(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" + ); +} + +#[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(); + 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, expected_unknown); + assert_help_does_not_hide_invalid_argument(binary); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary, expected_invalid_utf8); + } + #[cfg(unix)] + assert_native_path_values_are_not_forced_through_utf8(&binaries); +} 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..ba4c3ed12 --- /dev/null +++ b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs @@ -0,0 +1,178 @@ +//! 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; +use std::sync::OnceLock; + +const BINARIES: [&str; 2] = [ + "disksage-icloud-local-eviction-batch", + "disksage-incomplete-download-destination-plan", +]; + +/// 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 + }) + .as_slice() +} + +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 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"); + 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 binaries = 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, + "DiskSage incomplete download destination plan: --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, + "DiskSage incomplete download destination plan: --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, + "DiskSage incomplete download destination plan: --capacity-reserve-mib는 한 번만 지정할 수 있음", + ); +} 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는 한 번만 지정할 수 있음", + ); +}