From 1cb27ef74a0b080cb40f87b36befee89aa29a033 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 02:42:46 +0900 Subject: [PATCH 01/24] feat: prove ZIP content inclusion without extraction --- README.md | 2 +- ...026-07-20-archive-git-tree-proof-design.md | 17 + src-tauri/src/archive_git_tree.rs | 405 +++++++++++++++++- src-tauri/src/bin/disksage-archive-tree.rs | 71 ++- 4 files changed, 464 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 963313ad1..731db66ff 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - ๐Ÿ—‚ **Ontology-based organizing** โ€” files classified into an OWL taxonomy you can edit - ๐Ÿ“Š **Disk inventory** โ€” "what is on my disk?", aggregated by category, unknowns surfaced - ๐Ÿง  **On-device LLM advisor** โ€” embedded llama.cpp model judges delete-safety, fully offline -- โ˜๏ธ **Metadata-first cloud archive** โ€” detects iCloud Drive, OneDrive, and Google Drive; inspects embedded file metadata, bounded dataset schemas, Rust-parsed ZIP indexes, and incomplete-download archive fragments without extracting payloads; verifies macOS iCloud quota through Apple's read-only native account client and revalidates authoritative OneDrive/Google account capacity through read-only OAuth with a conservative reserve; performs gated copy-plus-hash verification; and verifies macOS File Provider status first with native PKCE OAuth checksum plus exact OneDrive path or Google My Drive parent-chain fallback while retaining the source +- โ˜๏ธ **Metadata-first cloud archive** โ€” detects iCloud Drive, OneDrive, and Google Drive; inspects embedded file metadata, bounded dataset schemas, Rust-parsed ZIP indexes, incomplete-download archive fragments, and per-entry ZIP content inclusion without extracting payloads; verifies macOS iCloud quota through Apple's read-only native account client and revalidates authoritative OneDrive/Google account capacity through read-only OAuth with a conservative reserve; performs gated copy-plus-hash verification; and verifies macOS File Provider status first with native PKCE OAuth checksum plus exact OneDrive path or Google My Drive parent-chain fallback while retaining the source ## Safety first diff --git a/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md b/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md index 63ea16050..3d01e1fbc 100644 --- a/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md +++ b/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md @@ -23,6 +23,14 @@ generic multi-root archives whose top-level paths are logical content. The Rust 6. Optionally compares the resulting 40-hex tree SHA with an operator-supplied commit tree SHA and exits nonzero on mismatch. +For generic ZIP-to-ZIP review, `--prove-subset-of PATH` uses the same validated logical paths but +streams both archives into a content manifest. Each file is bound by its exact path, normalized Git +mode, declared-and-observed uncompressed byte length, and SHA-256 of the uncompressed bytes. +Compression method, archive entry order, and ZIP container metadata do not affect the proof. The +JSON report includes complete matching/missing/changed/additional counts, bounded sorted path +samples, both manifest SHA-256 values, and a role-sensitive comparison fingerprint. It exits +nonzero unless every subset entry is present and identical. + The proof contains paths, counts, byte totals, modes, and object digests. It does not retain file contents, call a network service, mutate the ZIP, or authorize deletion. @@ -32,6 +40,10 @@ contents, call a network service, mutate the ZIP, or authorize deletion. - At most 4,096 bytes per path. - At most 16 GiB declared uncompressed file bytes. - More than 1,000 case-collision groups fails closed rather than truncating evidence. +- ZIP-to-ZIP inclusion rejects any case or Unicode-normalization collision as ambiguous; it does + not claim that a colliding manifest can be safely materialized on macOS. +- Difference path samples are capped at 1,000 per category while full counts remain available; + `paths_truncated` explicitly reports any truncation. - One shared wrapper directory remains mandatory by default, matching GitHub source archive structure. `--keep-top-level` must be explicit and preserves every validated path component. - Unsupported compression or an observed-size mismatch fails closed. @@ -44,6 +56,11 @@ removal still requires a separate approval naming both compared inputs (or the Z remote repository), exact tree, reclaimable bytes, and Trash-only action. Remote reachability is checked fresh before a Git-backed approval is applied. +Likewise, `subset_content_included: true` proves content containment, not which archive is the +authoritative copy or whether its destination tenant is permitted. A smaller contained archive is +only a reversible Trash candidate after the operator explicitly selects and retains the superset +as canonical. A later cloud-source eviction still requires provider-native remote evidence. + ## Integration decision This is deterministic bounded hashing in Rust. No Noema, LLM, LLM-as-a-Judge, external model, diff --git a/src-tauri/src/archive_git_tree.rs b/src-tauri/src/archive_git_tree.rs index a5b80e957..c61ffca66 100644 --- a/src-tauri/src/archive_git_tree.rs +++ b/src-tauri/src/archive_git_tree.rs @@ -4,14 +4,17 @@ use std::fs::File; use std::io::Read; use std::path::Path; -use sha1::{Digest, Sha1}; +use sha1::{Digest as Sha1Digest, Sha1}; +use sha2::{Digest as Sha2Digest, Sha256}; use unicode_normalization::UnicodeNormalization; const REPORT_VERSION: u32 = 1; +const COMPARISON_REPORT_VERSION: u32 = 1; const MAX_ZIP_ENTRIES: usize = 100_000; const MAX_PATH_BYTES: usize = 4_096; const MAX_UNCOMPRESSED_BYTES: u64 = 16 * 1024 * 1024 * 1024; const MAX_CASE_COLLISION_GROUPS: usize = 1_000; +const MAX_COMPARISON_PATH_SAMPLES: usize = 1_000; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct ArchiveGitTreeReport { @@ -28,6 +31,38 @@ pub struct ArchiveGitTreeReport { pub case_collision_groups: Vec>, } +/// Content-addressed proof that every logical file in one ZIP is present in another ZIP. +/// +/// Counts cover the complete manifests. Path arrays are bounded evidence samples; when any sample +/// is truncated, `paths_truncated` is true. Equality requires the same validated logical path, +/// normalized Git mode, uncompressed byte length, and SHA-256 of the uncompressed bytes. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ArchiveContentInclusionReport { + pub version: u32, + pub subset_archive: String, + pub superset_archive: String, + pub root_mode: String, + pub subset_root_prefix: String, + pub superset_root_prefix: String, + pub subset_file_count: usize, + pub superset_file_count: usize, + pub subset_uncompressed_bytes: u64, + pub superset_uncompressed_bytes: u64, + pub matching_file_count: usize, + pub missing_file_count: usize, + pub changed_file_count: usize, + pub additional_file_count: usize, + pub subset_content_included: bool, + pub archives_identical: bool, + pub missing_paths: Vec, + pub changed_paths: Vec, + pub additional_paths: Vec, + pub paths_truncated: bool, + pub subset_manifest_sha256: String, + pub superset_manifest_sha256: String, + pub comparison_fingerprint_sha256: String, +} + /// Choose whether the archive's first path component is a transport wrapper or logical content. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArchiveTreeRootMode { @@ -37,6 +72,29 @@ pub enum ArchiveTreeRootMode { KeepTopLevel, } +impl ArchiveTreeRootMode { + fn label(self) -> &'static str { + match self { + Self::StripSharedRoot => "strip-shared-root", + Self::KeepTopLevel => "keep-top-level", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArchiveFileEvidence { + mode: &'static [u8], + bytes: u64, + sha256: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArchiveManifest { + report: ArchiveGitTreeReport, + root_mode: ArchiveTreeRootMode, + entries: BTreeMap, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct BlobEntry { mode: &'static [u8], @@ -139,9 +197,10 @@ fn git_blob_mode(unix_mode: Option) -> Result<&'static [u8], String> { } } -fn blob_oid(reader: &mut impl Read, size: u64) -> Result<[u8; 20], String> { - let mut hasher = Sha1::new(); - hasher.update(format!("blob {size}\0").as_bytes()); +fn blob_digests(reader: &mut impl Read, size: u64) -> Result<([u8; 20], [u8; 32]), String> { + let mut git_hasher = Sha1::new(); + git_hasher.update(format!("blob {size}\0").as_bytes()); + let mut content_hasher = Sha256::new(); let mut observed = 0u64; let mut buffer = [0u8; 64 * 1024]; loop { @@ -157,12 +216,16 @@ fn blob_oid(reader: &mut impl Read, size: u64) -> Result<[u8; 20], String> { if observed > size { return Err("archive-entry-size-mismatch".into()); } - hasher.update(&buffer[..read]); + git_hasher.update(&buffer[..read]); + content_hasher.update(&buffer[..read]); } if observed != size { return Err("archive-entry-size-mismatch".into()); } - Ok(hasher.finalize().into()) + Ok(( + git_hasher.finalize().into(), + content_hasher.finalize().into(), + )) } fn git_name_compare(left: &[u8], left_tree: bool, right: &[u8], right_tree: bool) -> Ordering { @@ -216,6 +279,50 @@ fn hex_sha1(value: &[u8; 20]) -> String { value.iter().map(|byte| format!("{byte:02x}")).collect() } +fn hex_sha256(value: &[u8; 32]) -> String { + value.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn update_len_prefixed(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn manifest_sha256(manifest: &ArchiveManifest) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"disksage.archive-content-manifest\0v1\0"); + update_len_prefixed(&mut hasher, manifest.root_mode.label().as_bytes()); + hasher.update((manifest.entries.len() as u64).to_le_bytes()); + for (path, evidence) in &manifest.entries { + update_len_prefixed(&mut hasher, path.as_bytes()); + update_len_prefixed(&mut hasher, evidence.mode); + hasher.update(evidence.bytes.to_le_bytes()); + hasher.update(evidence.sha256); + } + hasher.finalize().into() +} + +fn comparison_fingerprint( + root_mode: ArchiveTreeRootMode, + subset_manifest_sha256: &[u8; 32], + superset_manifest_sha256: &[u8; 32], +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"disksage.archive-content-inclusion\0v1\0"); + update_len_prefixed(&mut hasher, root_mode.label().as_bytes()); + hasher.update(b"subset\0"); + hasher.update(subset_manifest_sha256); + hasher.update(b"superset\0"); + hasher.update(superset_manifest_sha256); + hasher.finalize().into() +} + +fn push_bounded(paths: &mut Vec, path: &str) { + if paths.len() < MAX_COMPARISON_PATH_SAMPLES { + paths.push(path.to_string()); + } +} + /// Compute the Git tree object represented by a wrapped source ZIP without extracting it. /// /// Every entry must share one top-level directory, as GitHub source archives do. File bytes are @@ -242,6 +349,14 @@ pub fn inspect_zip_git_tree_with_mode( expected_tree: Option<&str>, root_mode: ArchiveTreeRootMode, ) -> Result { + Ok(inspect_zip_manifest_with_mode(archive_path, expected_tree, root_mode)?.report) +} + +fn inspect_zip_manifest_with_mode( + archive_path: &Path, + expected_tree: Option<&str>, + root_mode: ArchiveTreeRootMode, +) -> Result { let expected_git_tree_sha1 = validate_expected_tree(expected_tree)?; let file = File::open(archive_path).map_err(|_| "archive-open-failed".to_string())?; let mut archive = @@ -255,6 +370,7 @@ pub fn inspect_zip_git_tree_with_mode( let mut file_count = 0usize; let mut uncompressed_bytes = 0u64; let mut case_paths: BTreeMap> = BTreeMap::new(); + let mut entries = BTreeMap::new(); for index in 0..archive.len() { let mut entry = archive @@ -312,12 +428,28 @@ pub fn inspect_zip_git_tree_with_mode( let display_path = String::from_utf8(relative_bytes.clone()) .map_err(|_| "archive-entry-path-not-utf8".to_string())?; let case_key: String = display_path.nfc().flat_map(char::to_lowercase).collect(); - case_paths.entry(case_key).or_default().push(display_path); + case_paths + .entry(case_key) + .or_default() + .push(display_path.clone()); let mode = git_blob_mode(entry.unix_mode())?; let size = entry.size(); - let oid = blob_oid(&mut entry, size)?; + let (oid, sha256) = blob_digests(&mut entry, size)?; tree.insert_blob(relative, BlobEntry { mode, oid })?; + if entries + .insert( + display_path, + ArchiveFileEvidence { + mode, + bytes: size, + sha256, + }, + ) + .is_some() + { + return Err("archive-entry-duplicate-or-type-conflict".into()); + } file_count += 1; } @@ -348,18 +480,110 @@ pub fn inspect_zip_git_tree_with_mode( .as_ref() .map(|expected| expected == &git_tree_sha1); - Ok(ArchiveGitTreeReport { - version: REPORT_VERSION, - archive: archive_path.to_string_lossy().into_owned(), - root_prefix, - zip_entry_count: archive.len(), - file_count, - directory_count, - uncompressed_bytes, - git_tree_sha1, - expected_git_tree_sha1, - matches_expected, - case_collision_groups, + Ok(ArchiveManifest { + report: ArchiveGitTreeReport { + version: REPORT_VERSION, + archive: archive_path.to_string_lossy().into_owned(), + root_prefix, + zip_entry_count: archive.len(), + file_count, + directory_count, + uncompressed_bytes, + git_tree_sha1, + expected_git_tree_sha1, + matches_expected, + case_collision_groups, + }, + root_mode, + entries, + }) +} + +/// Prove that every logical file in `subset_archive_path` is present in `superset_archive_path`. +/// +/// Both archives are parsed under the same root mode. File contents are streamed directly from the +/// ZIP readers and never extracted. Ambiguous case/Unicode-normalization collisions fail closed so +/// the result can be used as evidence for later operator-approved cleanup on macOS. +pub fn compare_zip_content_inclusion( + subset_archive_path: &Path, + superset_archive_path: &Path, + root_mode: ArchiveTreeRootMode, +) -> Result { + let subset = inspect_zip_manifest_with_mode(subset_archive_path, None, root_mode)?; + let superset = inspect_zip_manifest_with_mode(superset_archive_path, None, root_mode)?; + if !subset.report.case_collision_groups.is_empty() + || !superset.report.case_collision_groups.is_empty() + { + return Err("archive-case-collision-ambiguous".into()); + } + + let mut matching_file_count = 0usize; + let mut missing_file_count = 0usize; + let mut changed_file_count = 0usize; + let mut missing_paths = Vec::new(); + let mut changed_paths = Vec::new(); + for (path, subset_evidence) in &subset.entries { + match superset.entries.get(path) { + None => { + missing_file_count += 1; + push_bounded(&mut missing_paths, path); + } + Some(superset_evidence) if superset_evidence == subset_evidence => { + matching_file_count += 1; + } + Some(_) => { + changed_file_count += 1; + push_bounded(&mut changed_paths, path); + } + } + } + + let mut additional_file_count = 0usize; + let mut additional_paths = Vec::new(); + for path in superset.entries.keys() { + if !subset.entries.contains_key(path) { + additional_file_count += 1; + push_bounded(&mut additional_paths, path); + } + } + + let subset_content_included = missing_file_count == 0 && changed_file_count == 0; + let archives_identical = subset_content_included && additional_file_count == 0; + let paths_truncated = missing_file_count > missing_paths.len() + || changed_file_count > changed_paths.len() + || additional_file_count > additional_paths.len(); + let subset_manifest_digest = manifest_sha256(&subset); + let superset_manifest_digest = manifest_sha256(&superset); + let fingerprint = comparison_fingerprint( + root_mode, + &subset_manifest_digest, + &superset_manifest_digest, + ); + + Ok(ArchiveContentInclusionReport { + version: COMPARISON_REPORT_VERSION, + subset_archive: subset.report.archive, + superset_archive: superset.report.archive, + root_mode: root_mode.label().to_string(), + subset_root_prefix: subset.report.root_prefix, + superset_root_prefix: superset.report.root_prefix, + subset_file_count: subset.report.file_count, + superset_file_count: superset.report.file_count, + subset_uncompressed_bytes: subset.report.uncompressed_bytes, + superset_uncompressed_bytes: superset.report.uncompressed_bytes, + matching_file_count, + missing_file_count, + changed_file_count, + additional_file_count, + subset_content_included, + archives_identical, + missing_paths, + changed_paths, + additional_paths, + paths_truncated, + subset_manifest_sha256: hex_sha256(&subset_manifest_digest), + superset_manifest_sha256: hex_sha256(&superset_manifest_digest), + comparison_fingerprint_sha256: hex_sha256(&fingerprint), }) } @@ -536,6 +760,147 @@ mod tests { ); } + #[test] + fn content_inclusion_proves_every_subset_entry_by_path_mode_size_and_sha256() { + let subset = generic_fixture(&[ + ( + "a.txt", + b"alpha\n", + 0o100644, + zip::CompressionMethod::Stored, + ), + ( + "nested/b.txt", + b"beta\n", + 0o100755, + zip::CompressionMethod::Deflated, + ), + ]); + let superset = generic_fixture(&[ + ( + "extra/c.txt", + b"gamma\n", + 0o100644, + zip::CompressionMethod::Deflated, + ), + ( + "nested/b.txt", + b"beta\n", + 0o100755, + zip::CompressionMethod::Stored, + ), + ( + "a.txt", + b"alpha\n", + 0o100644, + zip::CompressionMethod::Deflated, + ), + ]); + + let report = compare_zip_content_inclusion( + &subset.path().join("fixture.zip"), + &superset.path().join("fixture.zip"), + ArchiveTreeRootMode::KeepTopLevel, + ) + .unwrap(); + + assert!(report.subset_content_included); + assert!(!report.archives_identical); + assert_eq!(report.matching_file_count, 2); + assert_eq!(report.missing_file_count, 0); + assert_eq!(report.changed_file_count, 0); + assert_eq!(report.additional_file_count, 1); + assert_eq!(report.additional_paths, ["extra/c.txt"]); + assert_eq!(report.subset_manifest_sha256.len(), 64); + assert_eq!(report.superset_manifest_sha256.len(), 64); + assert_eq!(report.comparison_fingerprint_sha256.len(), 64); + assert!(!report.paths_truncated); + } + + #[test] + fn content_inclusion_reports_changed_and_missing_entries_fail_closed() { + let subset = generic_fixture(&[ + ( + "changed.txt", + b"original", + 0o100644, + zip::CompressionMethod::Stored, + ), + ( + "mode.txt", + b"same bytes", + 0o100644, + zip::CompressionMethod::Stored, + ), + ( + "missing.txt", + b"required", + 0o100644, + zip::CompressionMethod::Stored, + ), + ]); + let superset = generic_fixture(&[ + ( + "changed.txt", + b"different", + 0o100644, + zip::CompressionMethod::Stored, + ), + ( + "mode.txt", + b"same bytes", + 0o100755, + zip::CompressionMethod::Stored, + ), + ]); + + let report = compare_zip_content_inclusion( + &subset.path().join("fixture.zip"), + &superset.path().join("fixture.zip"), + ArchiveTreeRootMode::KeepTopLevel, + ) + .unwrap(); + + assert!(!report.subset_content_included); + assert_eq!(report.matching_file_count, 0); + assert_eq!(report.changed_paths, ["changed.txt", "mode.txt"]); + assert_eq!(report.missing_paths, ["missing.txt"]); + } + + #[test] + fn content_inclusion_rejects_case_or_normalization_ambiguous_archives() { + let ambiguous = generic_fixture(&[ + ( + "Cafe\u{301}.md", + b"decomposed", + 0o100644, + zip::CompressionMethod::Stored, + ), + ( + "Caf\u{e9}.md", + b"composed", + 0o100644, + zip::CompressionMethod::Stored, + ), + ]); + let superset = generic_fixture(&[( + "Caf\u{e9}.md", + b"composed", + 0o100644, + zip::CompressionMethod::Stored, + )]); + + assert_eq!( + compare_zip_content_inclusion( + &ambiguous.path().join("fixture.zip"), + &superset.path().join("fixture.zip"), + ArchiveTreeRootMode::KeepTopLevel, + ) + .unwrap_err(), + "archive-case-collision-ambiguous" + ); + } + #[test] fn parser_rejects_unsafe_paths_and_invalid_expected_hashes() { assert!(zip_path_components(b"repo/../secret", false).is_err()); diff --git a/src-tauri/src/bin/disksage-archive-tree.rs b/src-tauri/src/bin/disksage-archive-tree.rs index dc76d4a78..f08956f5c 100644 --- a/src-tauri/src/bin/disksage-archive-tree.rs +++ b/src-tauri/src/bin/disksage-archive-tree.rs @@ -2,17 +2,20 @@ use std::path::PathBuf; -use disksage_lib::archive_git_tree::{inspect_zip_git_tree_with_mode, ArchiveTreeRootMode}; +use disksage_lib::archive_git_tree::{ + compare_zip_content_inclusion, inspect_zip_git_tree_with_mode, ArchiveTreeRootMode, +}; #[derive(Debug, PartialEq, Eq)] struct Args { zip: PathBuf, expected_tree: Option, + superset_zip: Option, keep_top_level: bool, } fn usage() -> &'static str { - "DiskSage archive Git tree proof: usage: disksage-archive-tree --zip PATH [--expected-tree HEX40] [--keep-top-level]" + "DiskSage archive proof: usage: disksage-archive-tree --zip PATH [--expected-tree HEX40 | --prove-subset-of PATH] [--keep-top-level]" } fn value(args: &[String], index: &mut usize, flag: &str) -> Result { @@ -25,21 +28,29 @@ fn value(args: &[String], index: &mut usize, flag: &str) -> Result Result { let mut zip = None; let mut expected_tree = None; + let mut superset_zip = None; let mut keep_top_level = false; let mut index = 0usize; while index < args.len() { match args[index].as_str() { "--zip" => zip = Some(PathBuf::from(value(args, &mut index, "--zip")?)), "--expected-tree" => expected_tree = Some(value(args, &mut index, "--expected-tree")?), + "--prove-subset-of" => { + superset_zip = Some(PathBuf::from(value(args, &mut index, "--prove-subset-of")?)) + } "--keep-top-level" => keep_top_level = true, "--help" | "-h" => return Err(usage().into()), unknown => return Err(format!("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: {unknown}")), } index += 1; } + if expected_tree.is_some() && superset_zip.is_some() { + return Err("--expected-tree์™€ --prove-subset-of๋Š” ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Œ".into()); + } Ok(Args { zip: zip.ok_or_else(|| "--zip ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?, expected_tree, + superset_zip, keep_top_level, }) } @@ -52,14 +63,25 @@ fn run() -> Result<(), String> { } else { ArchiveTreeRootMode::StripSharedRoot }; - let report = - inspect_zip_git_tree_with_mode(&args.zip, args.expected_tree.as_deref(), root_mode)?; - println!( - "{}", - serde_json::to_string_pretty(&report).map_err(|error| error.to_string())? - ); - if report.matches_expected == Some(false) { - return Err("archive-git-tree-mismatch".into()); + if let Some(superset_zip) = args.superset_zip { + let report = compare_zip_content_inclusion(&args.zip, &superset_zip, root_mode)?; + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|error| error.to_string())? + ); + if !report.subset_content_included { + return Err("archive-content-not-included".into()); + } + } else { + let report = + inspect_zip_git_tree_with_mode(&args.zip, args.expected_tree.as_deref(), root_mode)?; + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|error| error.to_string())? + ); + if report.matches_expected == Some(false) { + return Err("archive-git-tree-mismatch".into()); + } } Ok(()) } @@ -89,6 +111,7 @@ mod tests { Args { zip: PathBuf::from("/tmp/source.zip"), expected_tree: Some("a".repeat(40)), + superset_zip: None, keep_top_level: true, } ); @@ -97,10 +120,38 @@ mod tests { Args { zip: PathBuf::from("/tmp/source.zip"), expected_tree: None, + superset_zip: None, keep_top_level: false, } ); assert!(parse_args(&[]).is_err()); assert!(parse_args(&["--unknown".into()]).is_err()); } + + #[test] + fn parser_accepts_explicit_content_subset_proof() { + let parsed = parse_args(&[ + "--zip".into(), + "/tmp/subset.zip".into(), + "--prove-subset-of".into(), + "/tmp/superset.zip".into(), + "--keep-top-level".into(), + ]) + .unwrap(); + + assert_eq!( + parsed.superset_zip, + Some(PathBuf::from("/tmp/superset.zip")) + ); + assert!(parsed.keep_top_level); + assert!(parse_args(&[ + "--zip".into(), + "/tmp/subset.zip".into(), + "--prove-subset-of".into(), + "/tmp/superset.zip".into(), + "--expected-tree".into(), + "a".repeat(40), + ]) + .is_err()); + } } From 584f2226cac700cb49959e21d4b0af7bd7f53c6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 02:57:38 +0900 Subject: [PATCH 02/24] feat: version archive inclusion evidence for naruon --- .../specs/2026-07-20-archive-git-tree-proof-design.md | 5 +++-- src-tauri/src/archive_git_tree.rs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md b/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md index 3d01e1fbc..1d4dd0351 100644 --- a/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md +++ b/docs/superpowers/specs/2026-07-20-archive-git-tree-proof-design.md @@ -28,8 +28,9 @@ streams both archives into a content manifest. Each file is bound by its exact p mode, declared-and-observed uncompressed byte length, and SHA-256 of the uncompressed bytes. Compression method, archive entry order, and ZIP container metadata do not affect the proof. The JSON report includes complete matching/missing/changed/additional counts, bounded sorted path -samples, both manifest SHA-256 values, and a role-sensitive comparison fingerprint. It exits -nonzero unless every subset entry is present and identical. +samples, both manifest SHA-256 values, a role-sensitive comparison fingerprint, and the versioned +`disksage.archive-content-inclusion` schema kind consumed by Naruon. It exits nonzero unless every +subset entry is present and identical. The proof contains paths, counts, byte totals, modes, and object digests. It does not retain file contents, call a network service, mutate the ZIP, or authorize deletion. diff --git a/src-tauri/src/archive_git_tree.rs b/src-tauri/src/archive_git_tree.rs index c61ffca66..5bc721bc2 100644 --- a/src-tauri/src/archive_git_tree.rs +++ b/src-tauri/src/archive_git_tree.rs @@ -10,6 +10,7 @@ use unicode_normalization::UnicodeNormalization; const REPORT_VERSION: u32 = 1; const COMPARISON_REPORT_VERSION: u32 = 1; +const COMPARISON_SCHEMA_KIND: &str = "disksage.archive-content-inclusion"; const MAX_ZIP_ENTRIES: usize = 100_000; const MAX_PATH_BYTES: usize = 4_096; const MAX_UNCOMPRESSED_BYTES: u64 = 16 * 1024 * 1024 * 1024; @@ -39,6 +40,7 @@ pub struct ArchiveGitTreeReport { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct ArchiveContentInclusionReport { pub version: u32, + pub schema_kind: &'static str, pub subset_archive: String, pub superset_archive: String, pub root_mode: String, @@ -562,6 +564,7 @@ pub fn compare_zip_content_inclusion( Ok(ArchiveContentInclusionReport { version: COMPARISON_REPORT_VERSION, + schema_kind: COMPARISON_SCHEMA_KIND, subset_archive: subset.report.archive, superset_archive: superset.report.archive, root_mode: root_mode.label().to_string(), @@ -805,6 +808,7 @@ mod tests { .unwrap(); assert!(report.subset_content_included); + assert_eq!(report.schema_kind, "disksage.archive-content-inclusion"); assert!(!report.archives_identical); assert_eq!(report.matching_file_count, 2); assert_eq!(report.missing_file_count, 0); From 7c5139951f78c9ae99b80e0170dd90f598609ea4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:35:59 +0900 Subject: [PATCH 03/24] feat: bound cloud offload and cleanup evidence --- README.md | 23 +- docs/cloud-offload-operator-runbook.md | 92 +++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 13 + src-tauri/src/bin/disksage-clean-plan.rs | 93 +++ src-tauri/src/bin/disksage-cloud-plan.rs | 10 +- .../src/bin/disksage-git-worktree-audit.rs | 75 +++ src-tauri/src/cloud.rs | 144 ++++- src-tauri/src/cloud_transfer.rs | 151 ++++- src-tauri/src/commands.rs | 122 +++- src-tauri/src/lib.rs | 6 +- src-tauri/src/naruon_lineage.rs | 1 + src-tauri/src/rules.rs | 242 +++++++- src-tauri/src/safety.rs | 28 +- src-tauri/src/worktrees.rs | 538 ++++++++++++++++++ src/lib/Cleanup.svelte | 51 +- src/lib/CloudArchive.svelte | 95 +++- src/lib/WorktreeAudit.svelte | 78 +++ src/lib/api.test.ts | 13 + src/lib/api.ts | 40 ++ src/routes/+page.svelte | 3 + 21 files changed, 1738 insertions(+), 81 deletions(-) create mode 100644 docs/cloud-offload-operator-runbook.md create mode 100644 src-tauri/src/bin/disksage-clean-plan.rs create mode 100644 src-tauri/src/bin/disksage-git-worktree-audit.rs create mode 100644 src-tauri/src/worktrees.rs create mode 100644 src/lib/WorktreeAudit.svelte diff --git a/README.md b/README.md index 731db66ff..81fdc262f 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,36 @@ ## Features (v1 roadmap) - ๐Ÿ—บ **Large file explorer** โ€” parallel scan with treemap visualization -- ๐Ÿงน **Known cache & temp cleanup** โ€” OS, browser, and package-manager caches +- ๐Ÿงน **Known cache & temp cleanup** โ€” OS, browser, and package-manager caches (including uv); selected developer caches are revalidated by a Rust metadata manifest (path, size, mtime, file count) immediately before trashing - ๐Ÿ›  **Dev artifact cleanup** โ€” stale `node_modules`, `target/`, `venv`, โ€ฆ +- ๐Ÿงญ **Stale Git worktree audit** โ€” bounded, read-only registration evidence; prune/remove stays behind an explicit review boundary - ๐Ÿ‘ฏ **Duplicate finder** โ€” size โ†’ partial hash โ†’ BLAKE3 full hash - ๐Ÿ—‚ **Ontology-based organizing** โ€” files classified into an OWL taxonomy you can edit - ๐Ÿ“Š **Disk inventory** โ€” "what is on my disk?", aggregated by category, unknowns surfaced - ๐Ÿง  **On-device LLM advisor** โ€” embedded llama.cpp model judges delete-safety, fully offline - โ˜๏ธ **Metadata-first cloud archive** โ€” detects iCloud Drive, OneDrive, and Google Drive; inspects embedded file metadata, bounded dataset schemas, Rust-parsed ZIP indexes, incomplete-download archive fragments, and per-entry ZIP content inclusion without extracting payloads; verifies macOS iCloud quota through Apple's read-only native account client and revalidates authoritative OneDrive/Google account capacity through read-only OAuth with a conservative reserve; performs gated copy-plus-hash verification; and verifies macOS File Provider status first with native PKCE OAuth checksum plus exact OneDrive path or Google My Drive parent-chain fallback while retaining the source +Cloud planning is bounded as well as read-only: only the largest 32 eligible files enter the initial +external metadata-probe set, the probe wall-clock budget is 10 seconds, and duplicate-content +hashing is capped at 16 MiB per plan. Deferred probes are retained as explicit evidence and review +reasons (`content-metadata-probe-deferred` / `content-hash-deferred`); they are never reported as +verified metadata or silently treated as safe to evict. + +Cache cleanup planning is bounded too: the metadata manifest has a 2-second and 100,000-record +budget per catalog entry. A partial manifest is returned with `scan_complete=false` and +`metadata-manifest-bounded`; it is display-only and cannot be submitted to the trash-delete gate. + ## Safety first -Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. +Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cache cleanup is bound to the exact candidate path, byte count, file count, and metadata fingerprint observed at review time; a changed or incomplete scan is rejected and must be refreshed. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. + +For a headless, read-only cache inventory, run `cargo run --locked --features cleanup-cli --bin disksage-clean-plan` (add `--id trivy-cache`, `--id pnpm-cache`, or `--id uv-cache` to inspect one candidate). The command prints the current metadata fingerprint; it never deletes files. + +For a headless Git worktree audit, run `cargo run --locked --features worktree-cli --bin disksage-git-worktree-audit -- --repo /path/to/repository`. It reports missing/prunable registrations and lock evidence without invoking `git worktree prune` or `git worktree remove`. The `git worktree list` probe and each raw admin-file read are bounded; a malformed registration falls back to read-only `.git/worktrees` evidence and marks `evidence_complete: false` for manual review. Each report includes a registration fingerprint; any future metadata prune must re-audit and match that fingerprint before an explicitly reviewed operation. The operator sequence for provider permissions, metadata evidence, copy, attestation, and separate source eviction is in [`docs/cloud-offload-operator-runbook.md`](docs/cloud-offload-operator-runbook.md). + +### Metadata and integration boundaries + +Archive and organization decisions keep the evidence chain in this order: embedded production metadata, an explicit date in the filename as secondary evidence, filesystem creation time, then modification time. A filename date is never treated as proof on its own; context, confidence, and lineage remain attached to the candidate. The default advisor is the offline Rust/llama.cpp path (never Ollama). Noema, an external orchestrator, the semantic-data portal, `pg-erd-cloud`, and `fast-mlsirm` are integration points only when the corresponding agent, catalog/ontology, ERD, or LLM-as-a-Judge contract is actually required; the current cache/cloud safety paths do not invoke them. ## Status diff --git a/docs/cloud-offload-operator-runbook.md b/docs/cloud-offload-operator-runbook.md new file mode 100644 index 000000000..2468133a3 --- /dev/null +++ b/docs/cloud-offload-operator-runbook.md @@ -0,0 +1,92 @@ +# DiskSage cloud offload operator runbook + +์ด ๋ฌธ์„œ๋Š” `/Users/seonghobae/Downloads` ๊ฐ™์€ ๋กœ์ปฌ ์›๋ณธ์„ iCloud Drive, OneDrive, +Google Drive์— ๋ณด๊ด€ํ•  ๋•Œ์˜ ์šด์˜ ์ˆœ์„œ๋ฅผ ์ •์˜ํ•œ๋‹ค. ๊ณ„ํšยท๋ณต์‚ฌยท์›๋ณธ ํšŒ์ˆ˜๋Š” ์„œ๋กœ ๋‹ค๋ฅธ +์ƒํƒœ์ด๋ฉฐ, ์•ž ๋‹จ๊ณ„์˜ ์„ฑ๊ณต๋งŒ์œผ๋กœ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ์Šน์ธํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 1. ๊ถŒํ•œ๊ณผ ์ฆ๊ฑฐ์˜ ๋ฒ”์œ„ + +- ๋กœ์ปฌ ์Šค์บ”๊ณผ ๋‚ด์žฅ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํŒ๋…์€ ํด๋ผ์šฐ๋“œ OAuth ์—†์ด ์ˆ˜ํ–‰ํ•œ๋‹ค. +- File Provider ๋ฃจํŠธ ํƒ์ง€์—๋Š” macOS ๊ฐœ์ธ์ •๋ณด ๋ณดํ˜ธ ๊ถŒํ•œ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ๋‹ค. +- OneDrive์™€ Google Drive์˜ ์›๊ฒฉ ์šฉ๋Ÿ‰ยท๊ณ„์ • ์†Œ์œ ๊ถŒ ํ™•์ธ์—๋Š” OAuth PKCE ์—ฐ๊ฒฐ์ด ํ•„์š”ํ•˜๋‹ค. + Desktop public client ID๋งŒ ์‚ฌ์šฉํ•˜๋ฉฐ client secret์€ ์ €์žฅํ•˜๊ฑฐ๋‚˜ ์ž…๋ ฅํ•˜์ง€ ์•Š๋Š”๋‹ค. +- iCloud๋Š” macOS ๋„ค์ดํ‹ฐ๋ธŒ quota ์ƒํƒœ๋ฅผ ์‚ฌ์šฉํ•˜์ง€๋งŒ, quota๋งŒ์œผ๋กœ ์—…๋กœ๋“œ ์™„๋ฃŒ๋ฅผ ์ฆ๋ช…ํ•˜์ง€ + ์•Š๋Š”๋‹ค. + +## 2. ํ›„๋ณด ํŒ์ • + +์ƒ์‚ฐ์ผ ์ฆ๊ฑฐ ์šฐ์„ ์ˆœ์œ„๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค. + +1. ํŒŒ์ผ ๋‚ด๋ถ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(EXIF, ffprobe, ๋ฌธ์„œ core properties, ZIP central directory ๋“ฑ) +2. ๋ช…์‹œ์ ์ธ ํŒŒ์ผ๋ช… ๋‚ ์งœ(์ €์‹ ๋ขฐ ๋ณด์กฐ ํžŒํŠธ) +3. ํŒŒ์ผ์‹œ์Šคํ…œ ์ƒ์„ฑ ์‹œ๊ฐ +4. ํŒŒ์ผ์‹œ์Šคํ…œ ์ˆ˜์ • ์‹œ๊ฐ + +`2026-04-28`์ด๋‚˜ `251210` ๊ฐ™์€ ํŒŒ์ผ๋ช… ํ† ํฐ๋งŒ์œผ๋กœ ์ƒ์‚ฐ์ผ์„ ํ™•์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋‚ด์žฅ +๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ ํŒŒ์ผ๋ช… ๋‚ ์งœ๊ฐ€ ์ถฉ๋Œํ•˜๋ฉด ํ›„๋ณด๋ฅผ ๊ฒ€ํ†  ์ƒํƒœ๋กœ ๋‘”๋‹ค. `.crdownload`, ๋ˆ„๋ฝ๋œ +multipart archive, ์ฝ์„ ์ˆ˜ ์—†๋Š” archive index๋Š” ์›์ž์  ๋ณต์‚ฌ ๊ณ„ํš์ด ์—†์œผ๋ฉด ์ฐจ๋‹จํ•œ๋‹ค. + +๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋„๊ตฌ์™€ ์ค‘๋ณต content hash๋„ ๊ณ„ํš ์ „์ฒด ์˜ˆ์‚ฐ ์•ˆ์—์„œ๋งŒ ์‹คํ–‰ํ•œ๋‹ค. ์ดˆ๊ธฐ ๊ณ„ํš์€ +๊ฐ€์žฅ ํฐ eligible ํŒŒ์ผ ์ตœ๋Œ€ 32๊ฐœ์™€ 10์ดˆ์˜ ์™ธ๋ถ€ probe ์˜ˆ์‚ฐ, 16 MiB์˜ ์ค‘๋ณต hash ์˜ˆ์‚ฐ์„ +์‚ฌ์šฉํ•œ๋‹ค. ์˜ˆ์‚ฐ์„ ๋„˜๊ธด ํ›„๋ณด์—๋Š” `metadata-probe-status` ๋˜๋Š” content-hash ์ง€์—ฐ ์ฆ๊ฑฐ์™€ +`content-metadata-probe-deferred`/`exact-duplicate-content-probe-deferred` ๊ฒ€ํ†  ์‚ฌ์œ ๊ฐ€ +๋‚จ๊ณ , ๋ณด๊ณ ์„œ์—๋Š” ํ•ด๋‹น ์ง€์—ฐ notice๊ฐ€ ์ถ”๊ฐ€๋œ๋‹ค. ์ด ํ›„๋ณด๋ฅผ ๋ณต์‚ฌํ•˜๋ ค๋ฉด ์ƒˆ ๊ณ„ํš์—์„œ ํ•„์š”ํ•œ +๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ digest๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด์•ผ ํ•œ๋‹ค. + +์บ์‹œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ manifest๋„ ํ•ญ๋ชฉ๋‹น 2์ดˆ ๋˜๋Š” 100,000๊ฐœ record์—์„œ ๋ฉˆ์ถ˜๋‹ค. ์ด ๊ฒฝ์šฐ +`scan_complete=false`์™€ `metadata-manifest-bounded`๊ฐ€ ๋‚จ์œผ๋ฉฐ, ์ฝํžŒ bytes๋Š” ๋ถ€๋ถ„๊ฐ’์ผ ์ˆ˜ +์žˆ๋‹ค. ๋ถˆ์™„์ „ manifest๋Š” GUI์™€ Rust ์ •๋ฆฌ ๊ฒŒ์ดํŠธ์—์„œ ์ž๋™ ๊ฑฐ๋ถ€๋˜๋ฏ€๋กœ, ์ƒˆ ์ฝ๊ธฐ ์ „์šฉ ๊ณ„ํš์ด +์™„๋ฃŒ๋œ ๋’ค์—๋งŒ ๋ณ„๋„ ํ•ญ๋ชฉ ์Šน์ธ์œผ๋กœ ์ง„ํ–‰ํ•œ๋‹ค. + +## 3. ๊ณ„ํš๊ณผ ๋ณต์‚ฌ + +1. DiskSage์—์„œ ์›๋ณธ ๋ฃจํŠธ๋ฅผ ์Šค์บ”ํ•˜๊ณ  ํด๋ผ์šฐ๋“œ ๋ฃจํŠธ๋ฅผ ๋‹ค์‹œ ํƒ์ง€ํ•œ๋‹ค. +2. ํ›„๋ณด์˜ `metadata_fingerprint`, `review_fingerprint`, bytes, ์›๋ณธ ์ƒ๋Œ€ ๊ฒฝ๋กœ, + production-time source/confidence, context๋ฅผ ๊ฒ€ํ† ํ•œ๋‹ค. +3. ๊ณต๊ธ‰์ž ์šฉ๋Ÿ‰๊ณผ ๋™๊ธฐํ™” ์ƒํƒœ๋ฅผ ๊ฒ€์ฆํ•œ ๋’ค ๊ณ„ํš์„ ๋‹ค์‹œ ์ƒ์„ฑํ•œ๋‹ค. ์ด์ „ preview๋‚˜ + ๋กœ์ปฌ provider ํด๋” ์กด์žฌ๋งŒ์œผ๋กœ๋Š” ๋ณต์‚ฌ ์Šน์ธ์„ ์žฌ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. +4. ๋ฏผ๊ฐ ๋งฅ๋ฝยท์ €์‹ ๋ขฐ ์ƒ์‚ฐ์ผยท์ปจํ…Œ์ด๋„ˆ ๋‚ด์šฉ์„ ๊ฐ€์ง„ ํ›„๋ณด๋Š” ํ•ด๋‹น fingerprint์— ๊ฒฐ๋ฐ•๋œ + ๋ช…์‹œ์  approve/hold ๊ฒฐ์ •์ด ์žˆ์–ด์•ผ ํ•œ๋‹ค. +5. ๋ณต์‚ฌ๋Š” `create-only`์™€ ์ฝ˜ํ…์ธ  hash ๊ฒ€์ฆ์„ ๊ฑฐ์น˜๋ฉฐ, ์›๋ณธ์€ ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค. copy-only + receipt์˜ `lineage.capacity`์—๋Š” ๊ทธ ๋ณต์‚ฌ๋ฅผ ํ—ˆ์šฉํ•œ ์šฉ๋Ÿ‰ snapshot, evidence fingerprint, + requested/reserve ๊ณ„์‚ฐ, `can_fit` ๊ฒฐ๊ณผ๊ฐ€ ํ•จ๊ป˜ ๊ฒฐ๋ฐ•๋œ๋‹ค. immutable receipt์™€ provider + evidence๊ฐ€ ์ƒ์„ฑ๋˜์–ด์•ผ ๋ณต์‚ฌ ๋‹จ๊ณ„๊ฐ€ ์™„๋ฃŒ๋œ ๊ฒƒ์œผ๋กœ ๋ณธ๋‹ค. ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋™์ผ ๋ชฉ์ ์ง€๋ฅผ + ์ฑ„ํƒํ•˜๋Š” ๊ฒฝ๋กœ๋Š” ์ƒˆ ๋ฐ”์ดํŠธ๋ฅผ ์“ฐ์ง€ ์•Š์œผ๋ฏ€๋กœ capacity lineage๊ฐ€ ์—†์„ ์ˆ˜ ์žˆ๋‹ค. + +## 4. ์›๋ณธ ํšŒ์ˆ˜ + +์›๋ณธ ํšŒ์ˆ˜๋Š” ๋ณต์‚ฌ์™€ ๋ณ„๋„์˜ ์Šน์ธ์ด๋‹ค. provider-native/API evidence๊ฐ€ receipt์˜ +destination, bytes, digest, ์œ„์น˜์™€ ์ผ์น˜ํ•˜๊ณ  `sync_complete`์ธ ๊ฒฝ์šฐ์—๋งŒ eviction +permit์ด ์ƒ์„ฑ๋œ๋‹ค. permit ์—†์ด ์›๋ณธ์„ Trash๋กœ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค. ํšŒ์ˆ˜ ์ „์—๋Š” source +metadata์™€ content digest๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•˜๊ณ , ์‹คํŒจํ•˜๋ฉด staging์„ ๋ณต๊ตฌํ•œ๋‹ค. + +## 5. ์Šน์ธ ๋ฌธ๊ตฌ์˜ ๋ฒ”์œ„ + +`์Šน์ธ`, `๋„ค` ๊ฐ™์€ ์ผ๋ฐ˜ ๋™์˜๋Š” ํ˜„์žฌ ํ›„๋ณด์— ๊ฒฐ๋ฐ•๋˜์ง€ ์•Š๋Š”๋‹ค. ์‹คํ–‰ ์ง์ „์— DiskSage๊ฐ€ +์ƒˆ ๊ณ„ํš์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ํ•ญ๋ชฉ์„ ์ œ์‹œํ•ด์•ผ ํ•œ๋‹ค. + +- ์ •ํ™•ํ•œ source/destination ๊ฒฝ๋กœ +- bytes์™€ source modified ์‹œ๊ฐ +- metadata/review fingerprint +- ๊ณต๊ธ‰์žยท๊ณ„์ • ๋ฒ”์œ„ยท์šฉ๋Ÿ‰ evidence fingerprint +- copy-only์ธ์ง€, provider attestation์ธ์ง€, source eviction์ธ์ง€ + +์‚ฌ์šฉ์ž๋Š” copy-only์™€ source eviction์„ ๊ฐ๊ฐ ์Šน์ธํ•œ๋‹ค. ์–ด๋А ํ•œ ๋‹จ๊ณ„์˜ ์„ฑ๊ณต์„ ๋‹ค์Œ +๋‹จ๊ณ„์˜ ์Šน์ธ์œผ๋กœ ๊ฐ„์ฃผํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 6. stale Git worktree ๊ฐ์‚ฌ + +`disksage-git-worktree-audit`๋Š” `git worktree list --porcelain`์„ 5์ดˆ ์•ˆ์— ๋๋‚ด์ง€ +๋ชปํ•˜๋ฉด `.git/worktrees` ๊ด€๋ฆฌ์ž ๋“ฑ๋ก์„ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ํ™•์ธํ•œ๋‹ค. ๊ด€๋ฆฌ์ž ํŒŒ์ผ์€ ํฌ๊ธฐ์™€ +์ฝ๊ธฐ ์‹œ๊ฐ„์„ ์ œํ•œํ•˜๋ฉฐ, ๋น„์–ด ์žˆ๊ฑฐ๋‚˜ ์ฝ๊ธฐ timeout์ธ `gitdir`๋Š” ์‹ค์ œ worktree ๊ฒฝ๋กœ๋กœ +์ถ”์ •ํ•˜์ง€ ์•Š๊ณ  `` ์ฆ๊ฑฐ๋กœ ๋‚จ๊ธด๋‹ค. ์ด fallback ๋ณด๊ณ ์„œ๋Š” +`evidence_complete: false`์ด๋ฏ€๋กœ `registration_fingerprint`๋ฅผ ๋ณด๊ด€ํ•˜๊ณ  ์ˆ˜๋™ ๊ฒ€ํ† ํ•  +๋•Œ๊นŒ์ง€ `git worktree prune/remove`๋‚˜ ํŒŒ์ผ ์‚ญ์ œ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 7. ์กฐ๊ฑด๋ถ€ ํ†ตํ•ฉ ๊ฒฝ๊ณ„ + +๊ธฐ๋ณธ ํŒ๋‹จยทhashยทcapacity ๊ณ„์‚ฐ์€ Rust์™€ ์˜คํ”„๋ผ์ธ llama.cpp ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค(Ollama +์‚ฌ์šฉ ์•ˆ ํ•จ). Noema/contextual-orchestrator๋Š” ์‹ค์ œ agent/external-LLM ๊ณ„์•ฝ์ด ์ƒ๊ธธ +๋•Œ๋งŒ ์—ฐ๊ฒฐํ•œ๋‹ค. semantic-data-portal๊ณผ pg-erd-cloud๋Š” ์˜์† catalog/DB ๊ฒฝ๊ณ„๊ฐ€ ํ•„์š”ํ•  +๋•Œ๋งŒ ์—ฐ๊ฒฐํ•˜๊ณ , fast-mlsirm์€ binary/polytomous LLM-as-a-Judge ๊ณ„์•ฝ์ด ์ƒ๊ธธ ๋•Œ๋งŒ +ํŒ์ •๊ธฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d4367c00..3f8aad853 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1016,6 +1016,7 @@ dependencies = [ "getrandom 0.3.4", "jwalk", "keyring", + "libc", "llama-cpp-2", "memchr", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cedde1eae..315d6cef6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,16 @@ name = "disksage-archive-tree" path = "src/bin/disksage-archive-tree.rs" required-features = ["archive-cli"] +[[bin]] +name = "disksage-clean-plan" +path = "src/bin/disksage-clean-plan.rs" +required-features = ["cleanup-cli"] + +[[bin]] +name = "disksage-git-worktree-audit" +path = "src/bin/disksage-git-worktree-audit.rs" +required-features = ["worktree-cli"] + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -36,6 +46,7 @@ serde_json = "1" jwalk = "0.8" trash = "5.2.6" blake3 = "1.8.5" +libc = "0.2.186" base64 = "0.22.1" oxttl = "0.2.3" oxrdf = "0.3.3" @@ -63,6 +74,8 @@ tempfile = "3.27.0" [features] archive-cli = [] +cleanup-cli = [] +worktree-cli = [] cloud-cli = [] llm-engine = ["dep:llama-cpp-2"] diff --git a/src-tauri/src/bin/disksage-clean-plan.rs b/src-tauri/src/bin/disksage-clean-plan.rs new file mode 100644 index 000000000..8c4075795 --- /dev/null +++ b/src-tauri/src/bin/disksage-clean-plan.rs @@ -0,0 +1,93 @@ +//! Read-only cache cleanup plan. It exposes the same metadata-bound candidates as the GUI. + +use disksage_lib::rules::{cache_candidates, BaseDirs}; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Args { + id: Option, +} + +fn parse_args(args: &[String]) -> Result { + let mut parsed = Args::default(); + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "--id" => { + index += 1; + let id = args + .get(index) + .cloned() + .ok_or_else(|| "--id ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?; + if id.is_empty() { + return Err("--id ๊ฐ’์ด ๋น„์–ด ์žˆ์Œ".into()); + } + parsed.id = Some(id); + } + "--help" | "-h" => { + return Err("usage: disksage-clean-plan [--id CACHE_ID]".into()); + } + unknown => return Err(format!("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: {unknown}")), + } + index += 1; + } + Ok(parsed) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn run(args: &[String]) -> Result<(), String> { + let parsed = parse_args(args)?; + let bases = BaseDirs::from_env().ok_or("ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ")?; + let mut candidates = cache_candidates(&bases); + if let Some(id) = parsed.id { + candidates.retain(|candidate| candidate.id == id); + } + let mut notices = vec![ + "dry-run-only", + "metadata-fingerprint-only", + "trash-delete-requires-explicit-review", + ]; + if candidates.iter().any(|candidate| !candidate.scan_complete) { + notices.push("metadata-manifest-bounded"); + } + let payload = serde_json::json!({ + "generated_at_ms": now_ms(), + "candidates": candidates, + "notices": notices, + }); + println!( + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ); + Ok(()) +} + +fn main() { + if let Err(error) = run(&std::env::args().skip(1).collect::>()) { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_accepts_optional_id() { + assert_eq!(parse_args(&[]).unwrap(), Args::default()); + assert_eq!( + parse_args(&["--id".into(), "trivy-cache".into()]).unwrap(), + Args { + id: Some("trivy-cache".into()) + } + ); + assert!(parse_args(&["--id".into()]).is_err()); + assert!(parse_args(&["--nope".into()]).is_err()); + } +} diff --git a/src-tauri/src/bin/disksage-cloud-plan.rs b/src-tauri/src/bin/disksage-cloud-plan.rs index fd1109abc..a3d6a4ab4 100644 --- a/src-tauri/src/bin/disksage-cloud-plan.rs +++ b/src-tauri/src/bin/disksage-cloud-plan.rs @@ -918,7 +918,9 @@ fn run() -> Result<(), String> { } else { None }; - if !adopt_existing { + let capacity = if adopt_existing { + None + } else { let assessment = verified_capacity_for_bytes( &selected, args.oauth_connections.as_deref(), @@ -933,7 +935,8 @@ fn run() -> Result<(), String> { assessment.blockers.join(",") }); } - } + Some(assessment) + }; let (receipt, receipt_path) = if adopt_existing { cloud_transfer::adopt_existing_cloud_copy_with_review( candidate, @@ -943,12 +946,13 @@ fn run() -> Result<(), String> { review_decision.as_ref(), )? } else { - cloud_transfer::prepare_cloud_copy_with_review( + cloud_transfer::prepare_cloud_copy_with_review_and_capacity( candidate, &selected, receipt_dir, cloud::system_now_ms(), review_decision.as_ref(), + capacity.as_ref(), )? }; println!( diff --git a/src-tauri/src/bin/disksage-git-worktree-audit.rs b/src-tauri/src/bin/disksage-git-worktree-audit.rs new file mode 100644 index 000000000..81aba3d7d --- /dev/null +++ b/src-tauri/src/bin/disksage-git-worktree-audit.rs @@ -0,0 +1,75 @@ +//! Read-only Git worktree audit. No prune/remove operation is exposed. + +use std::path::PathBuf; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Args { + repository: Option, +} + +fn parse_args(args: &[String]) -> Result { + let mut parsed = Args::default(); + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "--repo" => { + index += 1; + parsed.repository = Some(PathBuf::from( + args.get(index) + .ok_or_else(|| "--repo ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?, + )); + } + "--help" | "-h" => { + return Err("usage: disksage-git-worktree-audit [--repo PATH]".into()); + } + unknown => return Err(format!("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: {unknown}")), + } + index += 1; + } + Ok(parsed) +} + +fn main() { + let raw: Vec = std::env::args().skip(1).collect(); + let args = match parse_args(&raw) { + Ok(args) => args, + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + }; + let repository = args + .repository + .unwrap_or_else(|| std::env::current_dir().expect("ํ˜„์žฌ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + let report = + match disksage_lib::worktrees::audit(&repository, disksage_lib::worktrees::system_now_ms()) + { + Ok(report) => report, + Err(error) => { + eprintln!("DiskSage Git worktree ๊ฐ์‚ฌ ์‹คํŒจ: {error}"); + std::process::exit(2); + } + }; + println!( + "{}", + serde_json::to_string_pretty(&report).expect("worktree report serialization failed") + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_accepts_optional_repository() { + assert_eq!(parse_args(&[]).unwrap(), Args::default()); + assert_eq!( + parse_args(&["--repo".into(), "/repo".into()]).unwrap(), + Args { + repository: Some(PathBuf::from("/repo")) + } + ); + assert!(parse_args(&["--repo".into()]).is_err()); + assert!(parse_args(&["--unknown".into()]).is_err()); + } +} diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 6d117ea41..19c9f8842 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -14,6 +14,8 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; #[cfg(not(coverage))] use std::io::{Read, Seek, SeekFrom}; +#[cfg(all(not(coverage), unix))] +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; #[cfg(not(coverage))] use std::process::{Command, Stdio}; @@ -28,6 +30,14 @@ const METADATA_PROBE_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(not(coverage))] const METADATA_PROBE_OUTPUT_LIMIT: usize = 1024 * 1024; #[cfg(not(coverage))] +const METADATA_PROBE_PLAN_BUDGET: Duration = Duration::from_secs(10); +#[cfg(not(coverage))] +const MAX_METADATA_PROBE_FILES: usize = 32; +#[cfg(not(coverage))] +// Hashing is only a duplicate hint during a read-only plan. Keep the initial pass small so a +// provider placeholder or a nearly-full disk cannot turn an inventory request into a long read. +const MAX_CONTENT_HASH_BYTES_PER_PLAN: u64 = 16 * 1024 * 1024; +#[cfg(not(coverage))] const MAX_ZIP_METADATA_ENTRIES: usize = 10_000; #[cfg(not(coverage))] const MAX_ZIP_CONTEXT_NAMES: usize = 16; @@ -1074,6 +1084,18 @@ fn run_metadata_command_with_limits( timeout: Duration, output_limit: usize, ) -> Result, MetadataProbeFailure> { + // ExifTool/ffprobe/pdfinfo may spawn helpers that inherit stdout. Put each probe in its + // own process group so a timeout can close the pipe instead of waiting forever for a child + // that the direct `Child` handle does not represent. + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } command.stdout(Stdio::piped()).stderr(Stdio::null()); let mut child = command.spawn().map_err(|_| MetadataProbeFailure::Spawn)?; let mut stdout = child.stdout.take().ok_or(MetadataProbeFailure::Read)?; @@ -1104,15 +1126,26 @@ fn run_metadata_command_with_limits( std::thread::sleep(Duration::from_millis(25)); } Ok(None) => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } let _ = child.kill(); let _ = child.wait(); - let _ = output_reader.join(); + // Do not join a reader whose pipe may still be held by an escaped helper. The + // process group kill normally lets it finish immediately; detaching is the final + // bound that keeps the planner responsive even when a provider tool misbehaves. + drop(output_reader); return Err(MetadataProbeFailure::Timeout); } Err(_) => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } let _ = child.kill(); let _ = child.wait(); - let _ = output_reader.join(); + drop(output_reader); return Err(MetadataProbeFailure::Wait); } } @@ -3021,8 +3054,13 @@ fn push_candidate_evidence( /// Hash only non-blocked candidates that share a byte length. Exact duplicates remain movable, /// but require an operator to select the canonical lineage instead of silently copying every path. #[cfg(not(coverage))] -fn mark_exact_duplicate_candidates(candidates: &mut [CloudCandidate]) -> ExactDuplicateSummary { +fn mark_exact_duplicate_candidates_with_budget( + candidates: &mut [CloudCandidate], + max_hash_bytes: Option, +) -> (ExactDuplicateSummary, bool) { let mut summary = ExactDuplicateSummary::default(); + let mut hashed_bytes = 0_u64; + let mut deferred = false; let mut by_size: BTreeMap> = BTreeMap::new(); for (index, candidate) in candidates.iter().enumerate() { if candidate.blocked_reason.is_none() { @@ -3034,6 +3072,24 @@ fn mark_exact_duplicate_candidates(candidates: &mut [CloudCandidate]) -> ExactDu let mut by_digest: BTreeMap<(String, String), Vec> = BTreeMap::new(); for &index in same_size { let candidate = &candidates[index]; + if max_hash_bytes.is_some_and(|limit| { + candidate.bytes > limit.saturating_sub(hashed_bytes) + }) { + let candidate = &mut candidates[index]; + candidate + .review_reasons + .push("exact-duplicate-content-probe-deferred".into()); + push_candidate_evidence( + candidate, + "content-hash-status", + "deferred:content-hash-budget", + "local:content-hash:planner-budget", + "high", + ); + deferred = true; + continue; + } + hashed_bytes = hashed_bytes.saturating_add(candidate.bytes); match hash_duplicate_candidate(Path::new(&candidate.src), candidate.bytes) { Ok(digests) => by_digest .entry((digests.sha256, digests.blake3)) @@ -3105,7 +3161,7 @@ fn mark_exact_duplicate_candidates(candidates: &mut [CloudCandidate]) -> ExactDu candidate.requires_review = !candidate.review_reasons.is_empty(); candidate.review_fingerprint = candidate_review_fingerprint(candidate); } - summary + (summary, deferred) } fn hash_review_value(hasher: &mut blake3::Hasher, value: &[u8]) { @@ -3186,6 +3242,38 @@ pub fn plan_cloud_archive( now_ms: u64, options: CloudPlanOptions, ) -> CloudPlanReport { + #[cfg(not(coverage))] + let mut metadata_probe_candidates: Vec<&FileFact> = files + .iter() + .filter(|file| { + if file.bytes < options.min_size_bytes || file.modified_ms == 0 { + return false; + } + let age_days = now_ms.saturating_sub(file.modified_ms) / DAY_MS; + if age_days < options.min_age_days || archive_kind(&file.path).is_none() { + return false; + } + let Ok(relative) = file.path.strip_prefix(source_root) else { + return false; + }; + !relative.as_os_str().is_empty() + }) + .collect(); + #[cfg(not(coverage))] + metadata_probe_candidates + .sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path))); + #[cfg(not(coverage))] + metadata_probe_candidates.truncate(options.limit.min(MAX_METADATA_PROBE_FILES)); + #[cfg(not(coverage))] + let metadata_probe_paths: BTreeSet = metadata_probe_candidates + .into_iter() + .map(|file| file.path.clone()) + .collect(); + #[cfg(not(coverage))] + let metadata_probe_deadline = Instant::now() + METADATA_PROBE_PLAN_BUDGET; + #[cfg(not(coverage))] + let mut metadata_probe_deferred = false; + let mut candidates = Vec::new(); for file in files { if file.bytes < options.min_size_bytes || file.modified_ms == 0 { @@ -3207,12 +3295,28 @@ pub fn plan_cloud_archive( let filename_ms = filename_date_ms(&file.path); let filename_publication_month = filename_publication_month(&file.path); let mut lineage_metadata = file.content_metadata.clone(); + #[cfg(not(coverage))] + let mut metadata_probe_deferred_for_file = false; // Coverage builds exercise the deterministic planning core. Content probing is an // external-process adapter (ExifTool/ffprobe/pdfinfo/unzip) covered by normal tests and // integration smoke runs, so it is kept outside the in-process line-coverage boundary. #[cfg(not(coverage))] if lineage_metadata == ContentMetadata::default() && file.path.is_file() { - lineage_metadata = probe_content_metadata(&file.path); + if metadata_probe_paths.contains(&file.path) + && Instant::now() < metadata_probe_deadline + { + lineage_metadata = probe_content_metadata(&file.path); + } else { + add_evidence( + &mut lineage_metadata, + "metadata-probe-status", + "deferred:plan-budget-or-result-limit", + "local:metadata-probe:planner-budget", + "high", + ); + metadata_probe_deferred_for_file = true; + metadata_probe_deferred = true; + } } let embedded_production_time_ms = lineage_metadata.production_time_ms; if let Some(value) = filename_ms { @@ -3284,6 +3388,10 @@ pub fn plan_cloud_archive( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|| ".".into()); let mut review_reasons = review_reasons(&file.path, kind); + #[cfg(not(coverage))] + if metadata_probe_deferred_for_file { + review_reasons.push("content-metadata-probe-deferred".into()); + } review_reasons.extend(embedded_metadata_review_reasons( &file.path, &lineage_metadata, @@ -3404,7 +3512,10 @@ pub fn plan_cloud_archive( candidates.push(candidate); } #[cfg(not(coverage))] - let exact_duplicates = mark_exact_duplicate_candidates(&mut candidates); + let (exact_duplicates, content_hash_deferred) = mark_exact_duplicate_candidates_with_budget( + &mut candidates, + Some(MAX_CONTENT_HASH_BYTES_PER_PLAN), + ); #[cfg(coverage)] let exact_duplicates = ExactDuplicateSummary::default(); candidates.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.src.cmp(&b.src))); @@ -3415,6 +3526,20 @@ pub fn plan_cloud_archive( .filter(|c| c.blocked_reason.is_none()) .map(|c| c.bytes) .sum(); + let mut notices = vec![ + "dry-run-only".into(), + "cloud-quota-unverified".into(), + "cloud-sync-unverified".into(), + "content-hash-pending".into(), + ]; + #[cfg(not(coverage))] + if metadata_probe_deferred { + notices.push("content-metadata-probe-deferred".into()); + } + #[cfg(not(coverage))] + if content_hash_deferred { + notices.push("content-hash-deferred".into()); + } CloudPlanReport { cloud_root: cloud_root.clone(), generated_at_ms: now_ms, @@ -3423,12 +3548,7 @@ pub fn plan_cloud_archive( potentially_reclaimable_bytes, exact_duplicates, capacity: None, - notices: vec![ - "dry-run-only".into(), - "cloud-quota-unverified".into(), - "cloud-sync-unverified".into(), - "content-hash-pending".into(), - ], + notices, } } diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index e75f7f753..f14cb2ca5 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -11,6 +11,7 @@ use crate::cloud::{ use crate::cloud_review::{validate_decision, CloudReviewDecision, CloudReviewDisposition}; use crate::dataset_metadata::DatasetProfile; use crate::provider_evidence::{validate_sync_evidence_record, ProviderSyncEvidenceRecord}; +use crate::provider_capacity::CloudCapacityAssessment; use std::path::Path; #[cfg(not(coverage))] @@ -106,6 +107,9 @@ pub struct CloudLineageSnapshot { pub duration_ms: Option, pub dataset_profile: Option, pub metadata_evidence: Vec, + /// Capacity evidence used by the copy gate. Older receipts omit this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capacity: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -172,6 +176,15 @@ fn embedded_high_confidence(candidate: &CloudCandidate) -> bool { && candidate.production_time_source.starts_with("embedded:") } +pub fn candidate_requires_fresh_plan(candidate: &CloudCandidate) -> bool { + candidate.review_reasons.iter().any(|reason| { + matches!( + reason.as_str(), + "content-metadata-probe-deferred" | "exact-duplicate-content-probe-deferred" + ) + }) +} + /// Validate that a dry-run candidate is still eligible to enter the copy-only phase. /// /// The function collects every reason so the UI can explain why a candidate remains blocked. @@ -225,6 +238,12 @@ fn candidate_blockers_for_action( if allow_existing_destination && !existing_destination_candidate { blockers.push("existing-destination-plan-required".into()); } + // A bounded planner intentionally did not observe the embedded metadata or the duplicate + // content digest for these candidates. An operator rationale cannot turn an unobserved probe + // into evidence; refresh the plan so the missing proof is collected before copying. + if candidate_requires_fresh_plan(candidate) { + blockers.push("deferred-probe-requires-fresh-plan".into()); + } // Embedded, high-confidence production time remains the only evidence that can pass without // an operator decision. A low-confidence explicit filename date, filesystem creation time, or // modification time may enter the copy-only phase only when an approval is bound to the exact @@ -336,6 +355,15 @@ fn lineage_snapshot( candidate: &CloudCandidate, review_decision: Option<&CloudReviewDecision>, copy_verification_method: CloudCopyVerificationMethod, +) -> CloudLineageSnapshot { + lineage_snapshot_with_capacity(candidate, review_decision, copy_verification_method, None) +} + +fn lineage_snapshot_with_capacity( + candidate: &CloudCandidate, + review_decision: Option<&CloudReviewDecision>, + copy_verification_method: CloudCopyVerificationMethod, + capacity: Option<&CloudCapacityAssessment>, ) -> CloudLineageSnapshot { CloudLineageSnapshot { candidate_fingerprint: candidate.metadata_fingerprint.clone(), @@ -368,6 +396,7 @@ fn lineage_snapshot( duration_ms: candidate.duration_ms, dataset_profile: candidate.dataset_profile.clone(), metadata_evidence: candidate.metadata_evidence.clone(), + capacity: capacity.cloned(), } } @@ -904,7 +933,31 @@ fn build_verified_receipt( verified_at_ms: u64, copy_verification_method: CloudCopyVerificationMethod, ) -> Result { - let lineage = lineage_snapshot(candidate, review_decision, copy_verification_method); + build_verified_receipt_with_capacity( + candidate, + review_decision, + hashes, + verified_at_ms, + copy_verification_method, + None, + ) +} + +#[cfg(not(coverage))] +fn build_verified_receipt_with_capacity( + candidate: &CloudCandidate, + review_decision: Option<&CloudReviewDecision>, + hashes: ContentDigests, + verified_at_ms: u64, + copy_verification_method: CloudCopyVerificationMethod, + capacity: Option<&CloudCapacityAssessment>, +) -> Result { + let lineage = lineage_snapshot_with_capacity( + candidate, + review_decision, + copy_verification_method, + capacity, + ); let lineage_fingerprint = lineage_fingerprint(&lineage)?; let mut receipt = CloudCopyReceipt { version: RECEIPT_VERSION, @@ -965,18 +1018,41 @@ pub fn prepare_cloud_copy_with_review( receipt_dir: &Path, copied_at_ms: u64, review_decision: Option<&CloudReviewDecision>, +) -> Result<(CloudCopyReceipt, PathBuf), String> { + prepare_cloud_copy_with_review_and_capacity( + candidate, + cloud_root, + receipt_dir, + copied_at_ms, + review_decision, + None, + ) +} + +/// Copy a candidate after validating an optional operator review decision and the fresh provider +/// capacity assessment used by the copy gate. Capacity evidence is persisted in the receipt +/// lineage so an auditor can distinguish a verified copy from an unverified quota assumption. +#[cfg(not(coverage))] +pub fn prepare_cloud_copy_with_review_and_capacity( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + receipt_dir: &Path, + copied_at_ms: u64, + review_decision: Option<&CloudReviewDecision>, + capacity: Option<&CloudCapacityAssessment>, ) -> Result<(CloudCopyReceipt, PathBuf), String> { let blockers = candidate_blockers_with_review(candidate, cloud_root, review_decision); if !blockers.is_empty() { return Err(blockers.join(",")); } let (_, hashes) = copy_and_verify(candidate, cloud_root)?; - let receipt = build_verified_receipt( + let receipt = build_verified_receipt_with_capacity( candidate, review_decision, hashes, copied_at_ms, CloudCopyVerificationMethod::CopiedByDiskSage, + capacity, )?; match write_immutable_receipt(&receipt, receipt_dir) { Ok(path) => Ok((receipt, path)), @@ -1109,6 +1185,53 @@ mod tests { candidate } + #[test] + fn capacity_evidence_is_bound_to_lineage_fingerprint() { + let candidate = candidate(); + let without_capacity = lineage_snapshot( + &candidate, + None, + CloudCopyVerificationMethod::CopiedByDiskSage, + ); + let capacity = crate::provider_capacity::CloudCapacityAssessment { + snapshot: crate::provider_capacity::CloudCapacitySnapshot { + schema_version: 1, + provider: CloudProvider::Icloud, + evidence_kind: crate::provider_capacity::CapacityEvidenceKind::ProviderNativeStatus, + observed_at_ms: 10, + total_bytes: None, + used_bytes: None, + remaining_bytes: Some(100), + trashed_bytes: None, + max_upload_size_bytes: None, + state: crate::provider_capacity::CloudCapacityState::Available, + evidence_fingerprint: Some("f".repeat(64)), + unavailable_reason: None, + }, + requested_bytes: candidate.bytes, + largest_candidate_bytes: candidate.bytes, + reserve_bytes: 10, + required_bytes: Some(candidate.bytes + 10), + can_fit: Some(true), + blockers: Vec::new(), + notices: Vec::new(), + }; + let with_capacity = lineage_snapshot_with_capacity( + &candidate, + None, + CloudCopyVerificationMethod::CopiedByDiskSage, + Some(&capacity), + ); + + assert_ne!( + lineage_fingerprint(&without_capacity).unwrap(), + lineage_fingerprint(&with_capacity).unwrap() + ); + assert!(with_capacity.capacity.is_some()); + let encoded = serde_json::to_value(&with_capacity).unwrap(); + assert!(encoded.get("capacity").is_some()); + } + fn refresh_review_fingerprint(candidate: &mut CloudCandidate) { candidate.review_fingerprint = candidate_review_fingerprint(candidate); } @@ -1417,6 +1540,30 @@ mod tests { assert!(held_blockers.contains(&"embedded-high-confidence-date-required".to_string())); } + #[test] + fn deferred_probe_requires_a_fresh_plan_even_after_operator_approval() { + for reason in [ + "content-metadata-probe-deferred", + "exact-duplicate-content-probe-deferred", + ] { + let mut deferred = candidate(); + deferred.requires_review = true; + deferred.review_reasons = vec![reason.into()]; + deferred.review_fingerprint = crate::cloud::candidate_review_fingerprint(&deferred); + let approval = crate::cloud_review::create_decision( + &deferred, + CloudReviewDisposition::Approved, + 10, + ) + .unwrap(); + let blockers = candidate_blockers_with_review(&deferred, &root(), Some(&approval)); + assert!( + blockers.contains(&"deferred-probe-requires-fresh-plan".to_string()), + "{reason} must remain non-overridable" + ); + } + } + #[test] fn provider_sync_evidence_is_required_before_eviction_permit() { let valid_receipt = receipt(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cfaa26b0a..300386092 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,11 +13,13 @@ use crate::scanner::ScanResult; // clean_paths_inner/execute_moves_inner/undo_last_moves_inner(์ˆœ์ˆ˜ ํ•จ์ˆ˜)๊ฐ€ ์“ฐ๋Š” ๊ฒƒ์€ ๋ฌด์กฐ๊ฑด import; ๋ž˜ํผ ์ „์šฉ์€ cfg(not(coverage)) use crate::organize; +use crate::rules; use crate::safety; +use crate::worktrees; #[cfg(not(coverage))] use crate::{ cloud, cloud_review, cloud_transfer, dev_artifacts, dupes, provider_api_client, - provider_capacity, provider_evidence, provider_oauth, provider_sync, rules, + provider_capacity, provider_evidence, provider_oauth, provider_sync, }; #[derive(Default)] @@ -137,6 +139,48 @@ pub fn clean_paths_inner( .collect() } +/// ์บ์‹œ ํ›„๋ณด๋Š” ๋ชฉ๋ก์„ ์ฝ์€ ์‹œ์ ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ง€๋ฌธ๊ณผ ์ผ์น˜ํ•  ๋•Œ๋งŒ ํœด์ง€ํ†ต์œผ๋กœ ๋ณด๋‚ธ๋‹ค. +/// ํ›„๋ณด๊ฐ€ ๋ฐ”๋€Œ์—ˆ๊ฑฐ๋‚˜ ์ฝ๊ธฐ ์˜ค๋ฅ˜๊ฐ€ ์„ž์˜€์œผ๋ฉด ์–ด๋–ค ํ•ญ๋ชฉ๋„ ์ด๋™ํ•˜์ง€ ์•Š๊ณ  ์žฌ์Šค์บ”์„ ์š”๊ตฌํ•œ๋‹ค. +pub fn clean_cache_candidates_inner( + requests: &[rules::CacheCleanupRequest], + bases: &rules::BaseDirs, + journal_path: &Path, + now_ms: u64, +) -> Vec { + let current = rules::cache_candidates(bases); + requests + .iter() + .flat_map(|request| { + let Some(candidate) = current.iter().find(|c| c.id == request.id) else { + return vec![CleanResult { + path: request.path.clone(), + ok: false, + error: "์บ์‹œ ๊ทœ์น™์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์Šค์บ”ํ•˜์„ธ์š”".into(), + }]; + }; + + let matches = candidate.exists + && candidate.scan_complete + && candidate.skipped == 0 + && candidate.path == request.path + && candidate.bytes == request.bytes + && candidate.files == request.files + && candidate.skipped == request.skipped + && candidate.fingerprint == request.fingerprint; + if !matches { + return vec![CleanResult { + path: request.path.clone(), + ok: false, + error: "์บ์‹œ ํ›„๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ฑฐ๋‚˜ ๋ถˆ์™„์ „ํ•˜๊ฒŒ ์ฝํ˜”์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌ ์ „์— ๋‹ค์‹œ ์Šค์บ”ํ•˜์„ธ์š”".into(), + }]; + } + + let targets = rules::clean_targets(Path::new(&candidate.path)); + clean_paths_inner(&targets, journal_path, now_ms) + }) + .collect() +} + /// ์ €๋„์˜ move ๊ฒฝ๋กœ ํ•„๋“œ "src -> dst"๋ฅผ ๋ถ„๋ฆฌ (์ˆœ์ˆ˜ ํ•จ์ˆ˜ โ€” ํ…Œ์ŠคํŠธ ๋Œ€์ƒ). ๊ตฌ๋ถ„์ž ์—†์œผ๋ฉด None. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { path_field @@ -377,6 +421,12 @@ pub fn list_dev_artifacts( Ok(dev_artifacts::find_artifacts(Path::new(&root), min_age_days, now_ms())) } +#[cfg(not(coverage))] +#[tauri::command] +pub fn list_stale_worktrees(repository: String) -> Result { + worktrees::audit(Path::new(&repository), worktrees::system_now_ms()) +} + #[cfg(not(coverage))] #[tauri::command] pub fn clean_paths(paths: Vec, app: AppHandle) -> Result, String> { @@ -385,6 +435,17 @@ pub fn clean_paths(paths: Vec, app: AppHandle) -> Result, + app: AppHandle, +) -> Result, String> { + let bases = rules::BaseDirs::from_env().ok_or("ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ")?; + let jp = journal_file_path(&app)?; + Ok(clean_cache_candidates_inner(&requests, &bases, &jp, now_ms())) +} + #[cfg(not(coverage))] #[tauri::command] pub fn recent_operations(limit: usize, app: AppHandle) -> Result, String> { @@ -714,7 +775,7 @@ fn require_capacity_for_copy( selected: &cloud::CloudRoot, candidate: &cloud::CloudCandidate, app: &AppHandle, -) -> Result<(), String> { +) -> Result { let snapshot = authenticated_capacity_snapshot(selected, app, cloud::system_now_ms())?; let assessment = provider_capacity::assess_capacity( snapshot, @@ -723,7 +784,7 @@ fn require_capacity_for_copy( provider_capacity::DEFAULT_CAPACITY_RESERVE_BYTES, ); if assessment.can_fit == Some(true) { - Ok(()) + Ok(assessment) } else { Err(if assessment.blockers.is_empty() { "cloud-capacity-verification-required".into() @@ -821,6 +882,9 @@ pub fn review_cloud_candidate( if candidate.review_fingerprint != review_fingerprint { return Err("fresh-plan-review-fingerprint-mismatch".into()); } + if cloud_transfer::candidate_requires_fresh_plan(candidate) { + return Err("deferred-probe-requires-fresh-plan".into()); + } let decision = cloud_review::create_attributed_decision( candidate, disposition, @@ -887,9 +951,11 @@ fn create_cloud_candidate_receipt( } else { None }; - if !adopt_existing { - require_capacity_for_copy(&selected, candidate, app)?; - } + let capacity = if adopt_existing { + None + } else { + Some(require_capacity_for_copy(&selected, candidate, app)?) + }; let (receipt, receipt_path) = if adopt_existing { cloud_transfer::adopt_existing_cloud_copy_with_review( candidate, @@ -899,12 +965,13 @@ fn create_cloud_candidate_receipt( review_decision.as_ref(), )? } else { - cloud_transfer::prepare_cloud_copy_with_review( + cloud_transfer::prepare_cloud_copy_with_review_and_capacity( candidate, &selected, &receipt_dir, cloud::system_now_ms(), review_decision.as_ref(), + capacity.as_ref(), )? }; Ok(CloudCopyOutput { @@ -1618,6 +1685,47 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . } } + #[test] + fn cache_cleanup_rejects_a_stale_metadata_fingerprint() { + let tmp = tempfile::tempdir().unwrap(); + let bases = rules::BaseDirs { + temp: tmp.path().join("tmp"), + local_data: tmp.path().join("local"), + home: tmp.path().join("home"), + }; + let trivy = rules::cache_candidates(&bases) + .into_iter() + .find(|c| c.id == "trivy-cache") + .unwrap() + .path; + let trivy_path = PathBuf::from(&trivy); + fs::create_dir_all(&trivy_path).unwrap(); + fs::write(trivy_path.join("db.bin"), b"old").unwrap(); + let observed = rules::cache_candidates(&bases) + .into_iter() + .find(|c| c.id == "trivy-cache") + .unwrap(); + + // ๋ชฉ๋ก์„ ์ฝ์€ ๋’ค ์ƒˆ ํŒŒ์ผ์ด ์ƒ๊ธฐ๋ฉด, ๊ฐ™์€ ํฌ๊ธฐ๋ผ๋„ ๊ฒฝ๋กœ๊ฐ€ manifest์— ๋“ค์–ด๊ฐ€๋ฏ€๋กœ ๊ฑฐ๋ถ€ํ•œ๋‹ค. + fs::write(trivy_path.join("new.bin"), b"new").unwrap(); + let request = rules::CacheCleanupRequest { + id: observed.id, + path: observed.path, + bytes: observed.bytes, + files: observed.files, + skipped: observed.skipped, + scan_complete: observed.scan_complete, + fingerprint: observed.fingerprint, + }; + let results = clean_cache_candidates_inner(&[request], &bases, &tmp.path().join("journal.jsonl"), 1); + + assert_eq!(results.len(), 1); + assert!(!results[0].ok); + assert!(results[0].error.contains("๋‹ค์‹œ ์Šค์บ”")); + assert!(trivy_path.join("db.bin").exists()); + assert!(trivy_path.join("new.bin").exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bb6cbb2e..2ba82f67e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,7 +12,9 @@ mod settings; #[cfg_attr(coverage, allow(dead_code))] mod safety; #[cfg_attr(coverage, allow(dead_code))] -mod rules; +pub mod rules; +#[cfg_attr(coverage, allow(dead_code))] +pub mod worktrees; #[cfg_attr(coverage, allow(dead_code))] mod dev_artifacts; #[cfg_attr(coverage, allow(dead_code))] @@ -60,7 +62,9 @@ pub fn run() { commands::top_files, commands::list_cache_candidates, commands::list_dev_artifacts, + commands::list_stale_worktrees, commands::clean_paths, + commands::clean_cache_candidates, commands::recent_operations, commands::expand_clean_targets, commands::find_duplicate_files, diff --git a/src-tauri/src/naruon_lineage.rs b/src-tauri/src/naruon_lineage.rs index 0a5f2abea..3ed401cff 100644 --- a/src-tauri/src/naruon_lineage.rs +++ b/src-tauri/src/naruon_lineage.rs @@ -302,6 +302,7 @@ mod tests { source: "exiftool:CreateDate".into(), confidence: "high".into(), }], + capacity: None, }), } } diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 2fa6cebbf..a2860aad0 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -1,8 +1,10 @@ use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; - -use crate::scanner; +use std::time::{Duration, Instant}; +// Cache inventory is metadata-only, but a package cache can contain millions of entries. Keep +// the UI and cleanup planner responsive and fail closed when the bounded manifest is incomplete. +const CACHE_MANIFEST_BUDGET: Duration = Duration::from_secs(2); +const CACHE_MANIFEST_MAX_RECORDS: usize = 100_000; pub struct BaseDirs { pub temp: PathBuf, pub local_data: PathBuf, @@ -31,9 +33,25 @@ pub struct CacheCandidate { pub label: String, pub path: String, pub bytes: u64, + pub files: u64, + pub skipped: u64, + pub scan_complete: bool, + /// Deterministic metadata manifest, not a content hash. + pub fingerprint: String, pub exists: bool, } +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CacheCleanupRequest { + pub id: String, + pub path: String, + pub bytes: u64, + pub files: u64, + pub skipped: u64, + pub scan_complete: bool, + pub fingerprint: String, +} + /// ์ •์  ์บ์‹œ ์นดํƒˆ๋กœ๊ทธ (์ŠคํŽ™ ยง4 rules). ํ•ญ๋ชฉ = (id, ๋ผ๋ฒจ, ๋ฒ ์ด์Šค ๊ธฐ์ค€ ์ƒ๋Œ€๊ฒฝ๋กœ). /// ponytail: ๋ธŒ๋ผ์šฐ์ € ์บ์‹œ๋Š” ํ”„๋กœํ•„ ๊ธ€๋กญ์ด ํ•„์š”ํ•ด M2 ๋ฒ”์œ„ ๋ฐ– โ€” ์นดํƒˆ๋กœ๊ทธ์— ์ถ”๊ฐ€๋งŒ ํ•˜๋ฉด ํ™•์žฅ๋จ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { @@ -51,6 +69,25 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { #[cfg(not(any(windows, target_os = "macos")))] let pip = bases.local_data.join("pip"); // linux: ~/.cache/pip + #[cfg(windows)] + let trivy = bases.local_data.join("trivy"); + #[cfg(target_os = "macos")] + let trivy = bases.home.join("Library").join("Caches").join("trivy"); + #[cfg(all(not(windows), not(target_os = "macos")))] + let trivy = bases.local_data.join("trivy"); + + #[cfg(windows)] + let pnpm = bases.local_data.join("pnpm-cache"); + #[cfg(target_os = "macos")] + let pnpm = bases.home.join("Library").join("Caches").join("pnpm"); + #[cfg(all(not(windows), not(target_os = "macos")))] + let pnpm = bases.local_data.join("pnpm"); + + #[cfg(windows)] + let uv = bases.local_data.join("uv").join("cache"); + #[cfg(not(windows))] + let uv = bases.home.join(".cache").join("uv"); + // Windows ์ „์šฉ ์ง„๋‹จ/ํŠธ๋ ˆ์ด์Šค ์บ์‹œ๋Š” ์•„๋ž˜ extend๋กœ ์ถ”๊ฐ€ โ€” ๋‹ค๋ฅธ ํ”Œ๋žซํผ์„  ๊ทธ ๋ผ์ธ์ด cfg-absent๋ผ // mut๊ฐ€ ๋ฏธ์‚ฌ์šฉ์ด๋ฏ€๋กœ allow(unused_mut). (npm/pip์™€ ๊ฐ™์€ cfg ๊ทœ์œจ) #[allow(unused_mut)] @@ -60,6 +97,11 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { ("pip-cache", "pip ์บ์‹œ", pip), ("cargo-registry-cache", "cargo ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ์บ์‹œ", bases.home.join(".cargo").join("registry").join("cache")), + // ํ‘œ์ค€ ๊ฐœ๋ฐœ ๋„๊ตฌ์˜ ์žฌ์ƒ์„ฑ ๊ฐ€๋Šฅํ•œ ์บ์‹œ๋งŒ ๋…ธ์ถœํ•œ๋‹ค. Codexยท๋ธŒ๋ผ์šฐ์ €ยทํ”„๋กœ์ ํŠธ ๋ฐ์ดํ„ฐ๋Š” + // ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ์ž‘์—… ์‚ฐ์ถœ๋ฌผ์ผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ž๋™ ์ •๋ฆฌ ์นดํƒˆ๋กœ๊ทธ์—์„œ ์ œ์™ธํ•œ๋‹ค. + ("trivy-cache", "Trivy ์ทจ์•ฝ์  DB ์บ์‹œ", trivy), + ("pnpm-cache", "pnpm ํŒจํ‚ค์ง€ ์บ์‹œ", pnpm), + ("uv-cache", "uv ํŒจํ‚ค์ง€ ์บ์‹œ", uv), ]; // Windows ์ง„๋‹จ ์บ์‹œ โ€” ์กฐ์šฉํžˆ ์ˆ˜์‹ญ GB๋กœ ์ž๋ผ๋Š” ๊ฒƒ๋“ค. RDP ์ž๋™ ์ถ”์ (RdClientAutoTrace)์˜ .etl ๋กœ๊ทธ๊ฐ€ @@ -83,26 +125,145 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { .into_iter() .map(|(id, label, path)| { let exists = path.is_dir(); - let bytes = if exists { - // ponytail: ๊ทœ์น™๋ณ„ ๋ธ”๋กœํ‚น ์Šค์บ”(์ทจ์†Œ ๋ถˆ๊ฐ€) โ€” os-temp๊ฐ€ ๊ฑฐ๋Œ€ํ•˜๋ฉด ๋А๋ฆด ์ˆ˜ ์žˆ์Œ. - // UX๊ฐ€ ๋ฌธ์ œ ๋˜๋ฉด candidates์— ์ทจ์†Œ ํ† ํฐ๊ณผ ์ง„ํ–‰ ์ด๋ฒคํŠธ๋ฅผ ์ถ”๊ฐ€. - // interval 1: ์ง„ํ–‰ ์ฝœ๋ฐฑ(no-op)์ด ์ž‘์€ ํ…Œ์ŠคํŠธ ํ”ฝ์Šค์ฒ˜์—์„œ๋„ ์‹คํ–‰๋˜์–ด ์ปค๋ฒ„๋ฆฌ์ง€์—์„œ - // 0์œผ๋กœ ๋‚จ์ง€ ์•Š์Œ โ€” ์ฝœ๋ฐฑ์ด ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ํ˜ธ์ถœ ๋นˆ๋„๋Š” ๋™์ž‘์— ๋ฌด๊ด€ - scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes + let manifest = if exists { + cache_manifest(&path) } else { - 0 + CacheManifest::missing() }; CacheCandidate { id: id.into(), label: label.into(), path: path.to_string_lossy().into_owned(), - bytes, + bytes: manifest.bytes, + files: manifest.files, + skipped: manifest.skipped, + scan_complete: manifest.scan_complete, + fingerprint: manifest.fingerprint, exists, } }) .collect() } +#[derive(Default)] +struct CacheManifest { + bytes: u64, + files: u64, + skipped: u64, + scan_complete: bool, + records: Vec, + fingerprint: String, +} + +impl CacheManifest { + fn missing() -> Self { + let mut manifest = Self::default(); + manifest.scan_complete = true; + manifest.fingerprint = fingerprint(&["missing".to_string()]); + manifest + } +} + +/// ์บ์‹œ ๋””๋ ‰ํ† ๋ฆฌ์˜ ๊ฒฐ์ •์  ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ง€๋ฌธ์„ ๋งŒ๋“ ๋‹ค. ํŒŒ์ผ ๋‚ด์šฉ์€ ์ฝ์ง€ ์•Š์œผ๋ฉฐ ์ƒ๋Œ€๊ฒฝ๋กœยท์ข…๋ฅ˜ยทํฌ๊ธฐยทmtime๋งŒ +/// ํฌํ•จํ•œ๋‹ค. ์ฝ๊ธฐ ์˜ค๋ฅ˜๊ฐ€ ์žˆ์œผ๋ฉด skipped๋ฅผ ์˜ฌ๋ ค ๋ถˆ์™„์ „ํ•œ ์Šค์บ”์„ ์ •๋ฆฌ ์Šน์ธ์œผ๋กœ ์˜ค์ธํ•˜์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค. +fn cache_manifest(root: &Path) -> CacheManifest { + let mut manifest = CacheManifest { + scan_complete: true, + ..CacheManifest::default() + }; + let deadline = Instant::now() + CACHE_MANIFEST_BUDGET; + collect_manifest(root, root, &mut manifest, deadline); + if !manifest.scan_complete { + manifest.records.push("!incomplete\0bounded-metadata-manifest".into()); + } + manifest.records.sort_unstable(); + manifest.fingerprint = fingerprint(&manifest.records); + manifest +} + +fn collect_manifest(root: &Path, dir: &Path, manifest: &mut CacheManifest, deadline: Instant) { + if Instant::now() >= deadline || manifest.records.len() >= CACHE_MANIFEST_MAX_RECORDS { + manifest.scan_complete = false; + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + manifest.skipped = manifest.skipped.saturating_add(1); + return; + }; + + for entry in entries { + if Instant::now() >= deadline || manifest.records.len() >= CACHE_MANIFEST_MAX_RECORDS { + manifest.scan_complete = false; + return; + } + let Ok(entry) = entry else { + manifest.skipped = manifest.skipped.saturating_add(1); + continue; + }; + let path = entry.path(); + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + let Ok(file_type) = entry.file_type() else { + manifest.skipped = manifest.skipped.saturating_add(1); + continue; + }; + + if file_type.is_symlink() { + let target = std::fs::read_link(&path) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| "".into()); + manifest.records.push(format!("S\0{relative}\0{target}")); + continue; + } + if file_type.is_dir() { + manifest.records.push(format!("D\0{relative}")); + collect_manifest(root, &path, manifest, deadline); + continue; + } + if !file_type.is_file() { + manifest.records.push(format!("O\0{relative}")); + continue; + } + + let Ok(metadata) = entry.metadata() else { + manifest.skipped = manifest.skipped.saturating_add(1); + continue; + }; + let modified = match metadata.modified() { + Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => format!("{}:{}", duration.as_secs(), duration.subsec_nanos()), + Err(_) => { + manifest.skipped = manifest.skipped.saturating_add(1); + "".into() + } + }, + Err(_) => { + manifest.skipped = manifest.skipped.saturating_add(1); + "".into() + } + }; + manifest.bytes = manifest.bytes.saturating_add(metadata.len()); + manifest.files = manifest.files.saturating_add(1); + manifest + .records + .push(format!("F\0{relative}\0{}\0{modified}", metadata.len())); + } +} + +fn fingerprint(records: &[String]) -> String { + let mut hasher = blake3::Hasher::new(); + for record in records { + // Length-prefix each record so a filename containing a newline cannot collide + // with a different sequence of manifest records. + hasher.update(&(record.len() as u64).to_le_bytes()); + hasher.update(record.as_bytes()); + } + hasher.finalize().to_hex().to_string() +} + /// dir์ด ํ˜„์žฌ ์นดํƒˆ๋กœ๊ทธ๊ฐ€ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ฒฝ๋กœ์ธ์ง€ (expand_clean_targets์˜ ์Šค์ฝ”ํ”„ ๊ฒ€์ฆ์šฉ โ€” ํฌ๊ธฐ ๊ณ„์‚ฐ ์—†์Œ) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { catalog(bases).iter().any(|(_, _, p)| p == dir) @@ -156,8 +317,63 @@ mod tests { let temp_c = cands.iter().find(|c| c.id == "os-temp").unwrap(); assert!(!temp_c.exists); assert_eq!(temp_c.bytes, 0); - // ์นดํƒˆ๋กœ๊ทธ์— ์ตœ์†Œ 4๊ฐœ ๊ทœ์น™ - assert!(cands.len() >= 4); + // ํ‘œ์ค€ ๊ฐœ๋ฐœ ์บ์‹œ๊นŒ์ง€ ์นดํƒˆ๋กœ๊ทธ์— ํฌํ•จ๋˜๋ฉฐ, ํ›„๋ณด์—๋Š” ํŒŒ์ผ ์ˆ˜์™€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ง€๋ฌธ์ด ์žˆ๋‹ค. + assert!(cands.len() >= 7); + for id in ["trivy-cache", "pnpm-cache", "uv-cache"] { + let c = cands.iter().find(|c| c.id == id).unwrap(); + assert!(!c.exists); + assert_eq!(c.files, 0); + assert_eq!(c.skipped, 0); + assert_eq!(c.fingerprint.len(), 64); + } + } + + #[test] + fn cache_fingerprint_changes_when_metadata_manifest_changes() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let trivy = catalog(&bases) + .into_iter() + .find(|(id, _, _)| *id == "trivy-cache") + .unwrap() + .2; + fs::create_dir_all(&trivy).unwrap(); + fs::write(trivy.join("db.bin"), vec![0u8; 4]).unwrap(); + + let first = cache_candidates(&bases) + .into_iter() + .find(|c| c.id == "trivy-cache") + .unwrap(); + assert_eq!(first.files, 1); + assert_eq!(first.bytes, 4); + assert_eq!(first.skipped, 0); + + fs::write(trivy.join("new.bin"), vec![0u8; 4]).unwrap(); + let second = cache_candidates(&bases) + .into_iter() + .find(|c| c.id == "trivy-cache") + .unwrap(); + assert_ne!(first.fingerprint, second.fingerprint); + assert_eq!(second.files, 2); + } + + #[test] + fn expired_manifest_budget_is_marked_incomplete() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("fixture.bin"), b"x").unwrap(); + let mut manifest = CacheManifest { + scan_complete: true, + ..CacheManifest::default() + }; + collect_manifest( + tmp.path(), + tmp.path(), + &mut manifest, + Instant::now() - Duration::from_secs(1), + ); + assert!(!manifest.scan_complete); + assert_eq!(manifest.files, 0); + assert_eq!(manifest.bytes, 0); } #[cfg(windows)] diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 244cd2fff..13be6fb9b 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -29,6 +29,14 @@ fn is_home_root(path: &Path, home: Option<&str>) -> bool { /// ์‹œ์Šคํ…œยท๋ฃจํŠธ ๊ฒฝ๋กœ ํ•˜๋“œ ๊ฑฐ๋ถ€ ๋ชฉ๋ก (์ŠคํŽ™ ยง7-3). /// ์•ˆ์ „ ๊ณ„์ธต์˜ ์ตœํ›„ ๋ฐฉ์–ด์„  โ€” ํ˜ธ์ถœ์ž๊ฐ€ ๋ฌด์—‡์„ ๋„˜๊ธฐ๋“  ์—ฌ๊ธฐ์„œ ๊ฑธ๋Ÿฌ์ง„๋‹ค. pub fn is_protected(path: &Path) -> bool { + // ParentDir components are rejected before any prefix exception (including macOS temp + // folders), so callers that preflight without canonicalization cannot scan through `..`. + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return true; + } // ๋“œ๋ผ์ด๋ธŒ/ํŒŒ์ผ์‹œ์Šคํ…œ ๋ฃจํŠธ ์ž์ฒด if path.parent().is_none() { return true; @@ -87,9 +95,18 @@ pub fn is_protected(path: &Path) -> bool { "/System", "/Library", "/Applications", "/private", "/Volumes", "/cores", "/Network", ]); let s = path.to_string_lossy(); - if denied_prefixes - .iter() - .any(|d| s == *d || s.starts_with(&format!("{d}/"))) + // `/var/folders` and `/var/tmp` resolve to `/private/var/...` on macOS. They are + // user/session-scoped temporary areas and are valid trash-only cleanup targets; keeping + // the broad `/private` guard without this narrow exception would classify every + // tempfile-backed operation as protected after canonicalization. + #[cfg(target_os = "macos")] + let macos_ephemeral = s.starts_with("/private/var/folders/") || s.starts_with("/private/var/tmp/"); + #[cfg(not(target_os = "macos"))] + let macos_ephemeral = false; + if !macos_ephemeral + && denied_prefixes + .iter() + .any(|d| s == *d || s.starts_with(&format!("{d}/"))) { return true; } @@ -467,6 +484,11 @@ mod tests { ] { assert!(is_protected(Path::new(p)), "{p} must be protected on macOS"); } + // tempfile::tempdir() commonly resolves through /var -> /private/var. The ephemeral + // descendants remain valid trash-only fixtures even though /private itself is guarded. + assert!(!is_protected(Path::new("/private/var/folders/user/temp"))); + assert!(!is_protected(Path::new("/private/var/tmp/disksage-fixture"))); + assert!(is_protected(Path::new("/private/var/folders/../System"))); } #[test] diff --git a/src-tauri/src/worktrees.rs b/src-tauri/src/worktrees.rs new file mode 100644 index 000000000..3ca056703 --- /dev/null +++ b/src-tauri/src/worktrees.rs @@ -0,0 +1,538 @@ +//! Read-only stale Git worktree audit. +//! +//! The audit never runs `git worktree remove`, `git worktree prune`, or a filesystem delete. +//! It reports the exact local Git evidence needed for a later, explicitly reviewed cleanup. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +const MAX_GIT_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +const MAX_GIT_ADMIN_FILE_BYTES: u64 = 4 * 1024; +const GIT_WORKTREE_LIST_TIMEOUT: Duration = Duration::from_secs(5); +const GIT_ADMIN_FILE_READ_TIMEOUT: Duration = Duration::from_millis(250); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct RawWorktree { + path: PathBuf, + head: String, + branch: Option, + detached: bool, + locked_reason: Option, + prunable_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct WorktreeCandidate { + pub path: String, + pub head: String, + pub branch: Option, + pub is_primary: bool, + pub detached: bool, + pub exists: bool, + pub locked_reason: Option, + pub prunable_reason: Option, + pub metadata_prune_eligible: bool, + pub review_reasons: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct WorktreeAudit { + pub repository: String, + pub generated_at_ms: u64, + /// Digest of the exact repository registration and candidate evidence in this report. + /// A future metadata-prune operation must re-audit and compare this value first. + pub registration_fingerprint: String, + pub evidence_complete: bool, + pub worktrees: Vec, + pub stale_count: usize, + pub metadata_prune_eligible_count: usize, + pub notices: Vec, +} + +fn feed_fingerprint(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn registration_fingerprint(repository: &Path, worktrees: &[WorktreeCandidate]) -> String { + let mut hasher = blake3::Hasher::new(); + feed_fingerprint(&mut hasher, repository.to_string_lossy().as_bytes()); + for worktree in worktrees { + feed_fingerprint(&mut hasher, worktree.path.as_bytes()); + feed_fingerprint(&mut hasher, worktree.head.as_bytes()); + feed_fingerprint( + &mut hasher, + worktree + .branch + .as_deref() + .unwrap_or("") + .as_bytes(), + ); + for flag in [ + worktree.is_primary, + worktree.detached, + worktree.exists, + worktree.metadata_prune_eligible, + ] { + hasher.update(&[u8::from(flag)]); + } + feed_fingerprint( + &mut hasher, + worktree + .locked_reason + .as_deref() + .unwrap_or("") + .as_bytes(), + ); + feed_fingerprint( + &mut hasher, + worktree + .prunable_reason + .as_deref() + .unwrap_or("") + .as_bytes(), + ); + for reason in &worktree.review_reasons { + feed_fingerprint(&mut hasher, reason.as_bytes()); + } + hasher.update(&[0xff]); + } + hasher.finalize().to_hex().to_string() +} + +/// Parse Git's porcelain worktree records without interpreting arbitrary paths as commands. +fn parse_worktree_porcelain(input: &str) -> Vec { + input + .split("\n\n") + .filter_map(|block| { + let mut record = RawWorktree::default(); + for line in block.lines() { + if let Some(value) = line.strip_prefix("worktree ") { + record.path = PathBuf::from(value); + } else if let Some(value) = line.strip_prefix("HEAD ") { + record.head = value.to_string(); + } else if let Some(value) = line.strip_prefix("branch ") { + record.branch = Some(value.to_string()); + } else if line == "detached" { + record.detached = true; + } else if line == "locked" { + record.locked_reason = Some(String::new()); + } else if let Some(value) = line.strip_prefix("locked ") { + record.locked_reason = Some(value.to_string()); + } else if line == "prunable" { + record.prunable_reason = Some(String::new()); + } else if let Some(value) = line.strip_prefix("prunable ") { + record.prunable_reason = Some(value.to_string()); + } + } + (!record.path.as_os_str().is_empty()).then_some(record) + }) + .collect() +} + +/// Run Git's worktree listing with a hard timeout. A malformed registration can otherwise make +/// `git worktree list` wait indefinitely while trying to resolve a missing worktree gitdir. +fn run_git_worktree_list(repository: &Path) -> Result { + let repository_string = repository.to_string_lossy().into_owned(); + let mut child = Command::new("git") + .args([ + "-C", + repository_string.as_str(), + "worktree", + "list", + "--porcelain", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("git ์‹คํ–‰ ์‹คํŒจ: {error}"))?; + let deadline = Instant::now() + GIT_WORKTREE_LIST_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("git-worktree-list-timeout".into()); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("git-worktree-list-wait-failed".into()); + } + } + } + let output = child + .wait_with_output() + .map_err(|error| format!("git ์ถœ๋ ฅ ์ˆ˜์ง‘ ์‹คํŒจ: {error}"))?; + if !output.status.success() { + return Err(format!( + "git worktree list ์‹คํŒจ(exit={})", + output.status.code().unwrap_or(-1) + )); + } + if output.stdout.len() > MAX_GIT_OUTPUT_BYTES { + return Err(format!( + "git worktree ์ถœ๋ ฅ์ด ์ œํ•œ์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค({MAX_GIT_OUTPUT_BYTES} bytes)" + )); + } + String::from_utf8(output.stdout).map_err(|_| "git worktree ์ถœ๋ ฅ์ด UTF-8์ด ์•„๋‹™๋‹ˆ๋‹ค".into()) +} + +fn read_bounded_text(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| format!("worktree-admin-file-missing:{}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("worktree-admin-file-unsafe:{}", path.display())); + } + if metadata.len() > MAX_GIT_ADMIN_FILE_BYTES { + return Err(format!("worktree-admin-file-too-large:{}", path.display())); + } + let expected_len = metadata.len(); + let path = path.to_path_buf(); + let display_path = path.display().to_string(); + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("disksage-git-admin-read".into()) + .spawn(move || { + let result = (|| { + let file = std::fs::File::open(&path).map_err(|_| { + format!("worktree-admin-file-open-failed:{}", path.display()) + })?; + let mut bytes = Vec::new(); + file.take(MAX_GIT_ADMIN_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| format!("worktree-admin-file-read-failed:{}", path.display()))?; + if bytes.len() as u64 != expected_len + || bytes.len() as u64 > MAX_GIT_ADMIN_FILE_BYTES + { + return Err(format!("worktree-admin-file-changed:{}", path.display())); + } + String::from_utf8(bytes) + .map_err(|_| format!("worktree-admin-file-not-utf8:{}", path.display())) + })(); + let _ = sender.send(result); + }) + .map_err(|_| format!("worktree-admin-file-reader-spawn-failed:{display_path}"))?; + receiver + .recv_timeout(GIT_ADMIN_FILE_READ_TIMEOUT) + .map_err(|_| format!("worktree-admin-file-read-timeout:{display_path}"))? +} + +fn resolve_relative_git_path(base: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value.trim()); + if path.is_absolute() { + path + } else { + base.join(path) + } +} + +fn parse_head_content(content: &str) -> (String, Option, bool) { + let head = content.lines().next().unwrap_or_default().trim().to_string(); + if let Some(branch) = head.strip_prefix("ref: ") { + let branch = branch.trim().to_string(); + (head, Some(branch), false) + } else { + (head, None, true) + } +} + +fn primary_git_dir(repository: &Path, common_dir: &Path) -> PathBuf { + let dot_git = repository.join(".git"); + if dot_git.is_dir() { + return dot_git; + } + if let Ok(content) = read_bounded_text(&dot_git) { + if let Some(value) = content.trim().strip_prefix("gitdir: ") { + return resolve_relative_git_path(repository, value); + } + } + common_dir.to_path_buf() +} + +fn raw_from_git_admin(repository: &Path) -> Result, String> { + let common_output = Command::new("git") + .args(["-C", &repository.to_string_lossy(), "rev-parse", "--git-common-dir"]) + .output() + .map_err(|_| "git-common-dir-command-failed".to_string())?; + if !common_output.status.success() { + return Err("git-common-dir-command-failed".into()); + } + let common_value = String::from_utf8(common_output.stdout) + .map_err(|_| "git-common-dir-output-not-utf8".to_string())?; + let common_dir = resolve_relative_git_path(&repository, common_value.trim()); + let primary_dir = primary_git_dir(&repository, &common_dir); + let primary_head = read_bounded_text(&primary_dir.join("HEAD")) + .unwrap_or_default(); + let (primary_head, primary_branch, primary_detached) = parse_head_content(&primary_head); + let mut records = vec![RawWorktree { + path: repository.to_path_buf(), + head: primary_head, + branch: primary_branch, + detached: primary_detached, + locked_reason: None, + prunable_reason: None, + }]; + + let admin_dir = common_dir.join("worktrees"); + let admin_metadata = std::fs::symlink_metadata(&admin_dir) + .map_err(|_| "git-worktree-admin-directory-missing".to_string())?; + if admin_metadata.file_type().is_symlink() || !admin_metadata.is_dir() { + return Err("git-worktree-admin-directory-unsafe".into()); + } + let mut entries = std::fs::read_dir(&admin_dir) + .map_err(|_| "git-worktree-admin-directory-unreadable".to_string())? + .filter_map(Result::ok) + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let entry_path = entry.path(); + let entry_metadata = match std::fs::symlink_metadata(&entry_path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => metadata, + _ => continue, + }; + let _ = entry_metadata; + let entry_name = entry.file_name().to_string_lossy().into_owned(); + let gitdir_path = entry_path.join("gitdir"); + let mut record = RawWorktree { + path: entry_path.clone(), + head: String::new(), + branch: None, + detached: true, + locked_reason: None, + prunable_reason: None, + }; + match read_bounded_text(&gitdir_path) { + Ok(value) if !value.trim().is_empty() => { + let gitdir_target = resolve_relative_git_path(&entry_path, value.trim()); + record.path = gitdir_target + .file_name() + .and_then(|name| (name == ".git").then_some(gitdir_target.parent())) + .flatten() + .map(Path::to_path_buf) + .unwrap_or(gitdir_target.clone()); + if !gitdir_target.exists() { + record.prunable_reason = Some("gitdir-target-missing".into()); + } + } + Ok(_) => { + record.path = PathBuf::from(format!("")); + record.prunable_reason = Some("gitdir-file-empty".into()); + } + Err(error) => { + record.path = PathBuf::from(format!("")); + record.prunable_reason = Some(error); + } + } + if let Ok(head) = read_bounded_text(&entry_path.join("HEAD")) { + let (head, branch, detached) = parse_head_content(&head); + record.head = head; + record.branch = branch; + record.detached = detached; + } else { + record.prunable_reason.get_or_insert_with(|| "worktree-head-missing".into()); + } + if let Ok(reason) = read_bounded_text(&entry_path.join("locked")) { + record.locked_reason = Some(reason.trim().to_string()); + } + if let Ok(reason) = read_bounded_text(&entry_path.join("prunable")) { + record.prunable_reason = Some(if reason.trim().is_empty() { + "git-prunable-marker".into() + } else { + reason.trim().to_string() + }); + } + if record.path.as_os_str().is_empty() { + record.path = PathBuf::from(format!("")); + } + records.push(record); + } + Ok(records) +} + +fn build_audit( + repository: &Path, + generated_at_ms: u64, + raw: Vec, + mut notices: Vec, + evidence_complete: bool, +) -> WorktreeAudit { + let mut stale_count = 0usize; + let mut metadata_prune_eligible_count = 0usize; + let worktrees: Vec = raw + .into_iter() + .enumerate() + .map(|(index, record)| { + let exists = record.path.is_dir(); + let stale = record.prunable_reason.is_some() || !exists; + let metadata_prune_eligible = stale && record.locked_reason.is_none(); + let mut review_reasons = Vec::new(); + if stale { + stale_count += 1; + if record.prunable_reason.is_some() { + review_reasons.push("git-registration-prunable".to_string()); + } + if !exists { + review_reasons.push("worktree-path-missing".to_string()); + } + } else { + review_reasons.push("worktree-registration-present".to_string()); + } + if record.locked_reason.is_some() { + review_reasons.push("worktree-locked".to_string()); + } + if metadata_prune_eligible { + metadata_prune_eligible_count += 1; + } + WorktreeCandidate { + path: record.path.to_string_lossy().into_owned(), + head: record.head, + branch: record.branch, + is_primary: index == 0, + detached: record.detached, + exists, + locked_reason: record.locked_reason, + prunable_reason: record.prunable_reason, + metadata_prune_eligible, + review_reasons, + } + }) + .collect(); + notices.extend([ + "git-worktree-remove-not-invoked".into(), + "git-worktree-prune-not-invoked".into(), + "metadata-prune-requires-explicit-review".into(), + "registration-fingerprint-required-for-prune".into(), + ]); + WorktreeAudit { + repository: repository.to_string_lossy().into_owned(), + generated_at_ms, + registration_fingerprint: registration_fingerprint(repository, &worktrees), + evidence_complete, + worktrees, + stale_count, + metadata_prune_eligible_count, + notices, + } +} + +pub fn system_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +/// Build a bounded, read-only audit for one repository. +pub fn audit(repository: &Path, generated_at_ms: u64) -> Result { + if !repository.is_dir() { + return Err(format!( + "์ €์žฅ์†Œ ๊ฒฝ๋กœ๊ฐ€ ๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค: {}", + repository.display() + )); + } + let repository = repository + .canonicalize() + .map_err(|error| format!("์ €์žฅ์†Œ ๊ฒฝ๋กœ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {error}"))?; + match run_git_worktree_list(&repository) { + Ok(output) => Ok(build_audit( + &repository, + generated_at_ms, + parse_worktree_porcelain(&output), + vec!["read-only-git-worktree-list".into()], + true, + )), + Err(error) if error == "git-worktree-list-timeout" => { + let raw = raw_from_git_admin(&repository)?; + Ok(build_audit( + &repository, + generated_at_ms, + raw, + vec![ + "read-only-git-admin-fallback".into(), + "git-worktree-list-timeout".into(), + ], + false, + )) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_prunable_and_locked_records() { + let records = parse_worktree_porcelain( + "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /gone\nHEAD def\ndetached\nprunable gitdir file points to non-existent location\n\nworktree /locked\nHEAD ghi\nlocked maintainer\n", + ); + assert_eq!(records.len(), 3); + assert_eq!(records[0].branch.as_deref(), Some("refs/heads/main")); + assert!(records[1].detached); + assert!(records[1].prunable_reason.is_some()); + assert_eq!(records[2].locked_reason.as_deref(), Some("maintainer")); + } + + #[test] + fn empty_blocks_are_ignored() { + assert!(parse_worktree_porcelain("\n\n").is_empty()); + } + + #[test] + fn parses_symbolic_and_detached_head_contents() { + let (head, branch, detached) = parse_head_content("ref: refs/heads/main\n"); + assert_eq!(head, "ref: refs/heads/main"); + assert_eq!(branch.as_deref(), Some("refs/heads/main")); + assert!(!detached); + + let (head, branch, detached) = parse_head_content("abc123\n"); + assert_eq!(head, "abc123"); + assert_eq!(branch, None); + assert!(detached); + } + + fn candidate(path: &str, head: &str) -> WorktreeCandidate { + WorktreeCandidate { + path: path.into(), + head: head.into(), + branch: Some("refs/heads/topic".into()), + is_primary: false, + detached: false, + exists: false, + locked_reason: None, + prunable_reason: Some("missing gitdir".into()), + metadata_prune_eligible: true, + review_reasons: vec!["git-registration-prunable".into()], + } + } + + #[test] + fn registration_fingerprint_binds_repository_and_worktree_state() { + let worktrees = vec![candidate("/gone", "abc")]; + let first = registration_fingerprint(Path::new("/repo"), &worktrees); + assert_eq!( + first, + registration_fingerprint(Path::new("/repo"), &worktrees) + ); + + let mut changed = worktrees.clone(); + changed[0].head = "def".into(); + assert_ne!( + first, + registration_fingerprint(Path::new("/repo"), &changed) + ); + assert_ne!( + first, + registration_fingerprint(Path::new("/other"), &worktrees) + ); + } +} diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index d2e59a0ac..b1f1c14f6 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -43,21 +43,28 @@ } let totalSelected = $derived( - caches.filter((c) => selectedRules.has(c.id)).reduce((s, c) => s + c.bytes, 0) + + caches + .filter((c) => selectedRules.has(c.id) && c.skipped === 0 && c.scan_complete) + .reduce((s, c) => s + c.bytes, 0) + artifacts.filter((a) => selected.has(a.path)).reduce((s, a) => s + a.bytes, 0), ); let selectionCount = $derived( - caches.filter((c) => selectedRules.has(c.id) && c.exists).length + + caches.filter((c) => selectedRules.has(c.id) && c.exists && c.skipped === 0 && c.scan_complete).length + artifacts.filter((a) => selected.has(a.path)).length, ); async function executeClean() { // ๊ฒ€ํ† ยทํ™•์ธ (์ŠคํŽ™ ยง7-6): ๋ช…์‹œ์  ์Šน์ธ ์—†์ด๋Š” ์•„๋ฌด๊ฒƒ๋„ ์‹คํ–‰๋˜์ง€ ์•Š๋Š”๋‹ค - const ruleDirs = caches.filter((c) => selectedRules.has(c.id) && c.exists); + const ruleDirs = caches.filter( + (c) => selectedRules.has(c.id) && c.exists && c.skipped === 0 && c.scan_complete, + ); const artifactPaths = artifacts.filter((a) => selected.has(a.path)).map((a) => a.path); const summary = [ - ...ruleDirs.map((c) => `${c.label} (${fmtBytes(c.bytes)}) โ€” ๋‚ด์šฉ๋ฌผ ๋น„์šฐ๊ธฐ`), + ...ruleDirs.map( + (c) => + `${c.label} (${fmtBytes(c.bytes)}, ${c.files}๊ฐœ) โ€” ๋‚ด์šฉ๋ฌผ ๋น„์šฐ๊ธฐ ยท ์ง€๋ฌธ ${c.fingerprint.slice(0, 12)}`, + ), ...artifactPaths, ]; if (summary.length === 0) return; @@ -72,11 +79,23 @@ busy = true; try { - const paths: string[] = [...artifactPaths]; - for (const c of ruleDirs) { - paths.push(...(await api.expandCleanTargets(c.path))); - } - results = await api.cleanPaths(paths); + // ์บ์‹œ๋Š” ๋ชฉ๋ก ์‹œ์ ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ง€๋ฌธ์„ Rust์—์„œ ๋‹ค์‹œ ๊ฒ€์ฆํ•œ๋‹ค. ๋ชฉ๋ก์ด ๋ฐ”๋€Œ๋ฉด + // ํ•ด๋‹น ํ›„๋ณด๋งŒ ๊ฑฐ๋ถ€ํ•˜๊ณ , ์ค‘๋ณต/๊ฐœ๋ฐœ ์•„ํ‹ฐํŒฉํŠธ์˜ ๊ธฐ์กด ๊ฒฝ๋กœ ์ •๋ฆฌ๋Š” ๋ณ„๋„ API๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. + const cacheResults = ruleDirs.length + ? await api.cleanCacheCandidates( + ruleDirs.map(({ id, path, bytes, files, skipped, scan_complete, fingerprint }) => ({ + id, + path, + bytes, + files, + skipped, + scan_complete, + fingerprint, + })), + ) + : []; + const artifactResults = artifactPaths.length ? await api.cleanPaths(artifactPaths) : []; + results = [...cacheResults, ...artifactResults]; selected = new Set(); selectedRules = new Set(); await load(); @@ -98,15 +117,23 @@
    {#each caches as c (c.id)}
  • -
  • diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 1e63b6303..bee76edf9 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -77,6 +77,7 @@ && candidate.production_time_source.startsWith("embedded:"); const capacityEvidenceAvailable = api.cloudCapacityAllowsCopy(report?.capacity); return candidate.blocked_reason === null + && !requiresFreshProbe(candidate) && (!candidate.requires_review || exactApproval) && (embeddedHighConfidence || exactApproval) && capacityEvidenceAvailable; @@ -88,6 +89,7 @@ const embeddedHighConfidence = candidate.production_time_confidence === "high" && candidate.production_time_source.startsWith("embedded:"); return candidate.blocked_reason === "destination-exists" + && !requiresFreshProbe(candidate) && (!candidate.requires_review || exactApproval) && (embeddedHighConfidence || exactApproval); } @@ -103,10 +105,23 @@ return decision?.review_fingerprint === candidate.review_fingerprint ? decision : null; } + function requiresFreshProbe(candidate: api.CloudCandidate): boolean { + return candidate.review_reasons.some((reason) => + reason === "content-metadata-probe-deferred" + || reason === "exact-duplicate-content-probe-deferred" + ); + } + function reviewReasonLabel(reason: string): string { if (reason === "embedded-date-differs-from-filename-publication-month") { return "๋‚ด์žฅ ์ƒ์‚ฐ์ผ๊ณผ ํŒŒ์ผ๋ช… ๋ฐœํ–‰์›”์ด ๋‹ค๋ฆ„"; } + if (reason === "content-metadata-probe-deferred") { + return "๊ณ„ํš ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์˜ˆ์‚ฐ ์ดˆ๊ณผ๋กœ ๋‚ด์žฅ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋ฏธํ™•์ธ"; + } + if (reason === "exact-duplicate-content-probe-deferred") { + return "์ค‘๋ณต content hash ์˜ˆ์‚ฐ ์ดˆ๊ณผ๋กœ ๋™์ผ์„ฑ ๋ฏธํ™•์ธ"; + } return reason; } @@ -114,7 +129,7 @@ candidate: api.CloudCandidate, disposition: api.CloudReviewDisposition, ) { - if (!scannedRoot || !selectedRoot || !candidate.requires_review) return; + if (!scannedRoot || !selectedRoot || !candidate.requires_review || requiresFreshProbe(candidate)) return; const rationale = (reviewRationales[candidate.metadata_fingerprint] ?? "").trim(); if (!rationale) return; reviewingFingerprint = candidate.metadata_fingerprint; @@ -225,6 +240,22 @@ return connectionCapacityRoot === selectedRoot ? connectionCapacity : null; } + async function refreshCapacityBoundPlan() { + if (!report || !scannedRoot || !selectedRoot) return; + const planned = await api.planCloudArchive( + scannedRoot, + selectedRoot, + Math.max(1, Math.floor(minSizeMib)), + Math.max(0, Math.floor(minAgeDays)), + 200, + ); + report = planned; + if (planned.capacity) { + connectionCapacity = planned.capacity.snapshot; + connectionCapacityRoot = selectedRoot; + } + } + function capacityUnavailableLabel(reason: string | null): string { const labels: Record = { "provider-oauth-connection-missing": "์ €์žฅ๋œ ์—ฐ๊ฒฐ ์„ค์ •์ด ์—†์Šต๋‹ˆ๋‹ค.", @@ -250,6 +281,7 @@ try { connectionCapacity = await api.verifyCloudProviderCapacity(root.path); connectionCapacityRoot = root.path; + await refreshCapacityBoundPlan(); } catch (e) { loadError = String(e); } finally { @@ -271,6 +303,7 @@ oauthClientId = ""; connectionCapacity = await api.verifyCloudProviderCapacity(root.path); connectionCapacityRoot = root.path; + await refreshCapacityBoundPlan(); } catch (e) { loadError = String(e); } finally { @@ -447,6 +480,12 @@ {report.candidates.length}๊ฐœ ํ›„๋ณด ยท ์ด {fmtBytes(report.candidate_bytes)} ยท ์ถฉ๋Œ ์ œ์™ธ ์ž ์žฌ ํšŒ์ˆ˜ {fmtBytes(report.potentially_reclaimable_bytes)} + {#if report.notices.includes("content-metadata-probe-deferred") || report.notices.includes("content-hash-deferred")} +

    + ๊ณ„ํš ์˜ˆ์‚ฐ ๋•Œ๋ฌธ์— ์ผ๋ถ€ ํ›„๋ณด์˜ ๋‚ด์žฅ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋˜๋Š” ์ค‘๋ณต content hash๋ฅผ ์•„์ง ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. + ํ•ด๋‹น ํ›„๋ณด๋Š” ๊ฒ€ํ†  ์‚ฌ์œ ์™€ evidence๋ฅผ ํ™•์ธํ•˜๊ณ , ๋ณต์‚ฌ ์ „ ์ƒˆ ๊ณ„ํš์—์„œ ๋‹ค์‹œ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +

    + {/if} {#if report.capacity} {#if report.capacity.can_fit === true}

    @@ -582,7 +621,9 @@ {#if candidate.requires_review}

    - {#if matchingReviewDecision(candidate)?.disposition === "approved"} + {#if requiresFreshProbe(candidate)} + ๊ณ„ํš ์˜ˆ์‚ฐ ๋•Œ๋ฌธ์— ์ฆ๊ฑฐ๊ฐ€ ๋ฏธํ™•์ธ์ž…๋‹ˆ๋‹ค. ์ƒˆ ๊ณ„ํš์—์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐยทcontent hash๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + {:else if matchingReviewDecision(candidate)?.disposition === "approved"} ํ˜„์žฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ฆ๊ฑฐ ๊ฒ€ํ†  ์Šน์ธ๋จ {:else if matchingReviewDecision(candidate)?.disposition === "held"} ํ˜„์žฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ฆ๊ฑฐ ๋ณด๋ฅ˜๋จ @@ -597,30 +638,32 @@ ๊ทผ๊ฑฐ: {matchingReviewDecision(candidate)?.rationale ?? "legacy decision"} {/if} - - - + {#if !requiresFreshProbe(candidate)} + + + + {/if}
    {/if} {#if copyEligible(candidate)} diff --git a/src/lib/WorktreeAudit.svelte b/src/lib/WorktreeAudit.svelte new file mode 100644 index 000000000..6314a39f6 --- /dev/null +++ b/src/lib/WorktreeAudit.svelte @@ -0,0 +1,78 @@ + + +
    +

    + Git worktree ๊ฐ์‚ฌ + +

    + +

    ์ฝ๊ธฐ ์ „์šฉ ๊ฐ์‚ฌ์ž…๋‹ˆ๋‹ค. `git worktree prune/remove`์™€ ํŒŒ์ผ ์‚ญ์ œ๋Š” ํ˜ธ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

    + {#if error}

    {error}

    {/if} + {#if report} +

    + ๋“ฑ๋ก {report.worktrees.length}๊ฐœ ยท stale/prunable {report.stale_count}๊ฐœ ยท + metadata prune ๊ฒ€ํ†  ํ›„๋ณด {report.metadata_prune_eligible_count}๊ฐœ +

    +

    + registration fingerprint: {report.registration_fingerprint.slice(0, 16)}โ€ฆ +

    +

    + ์ฆ๊ฑฐ ์ƒํƒœ: {report.evidence_complete ? "์™„์ „" : "๋ถˆ์™„์ „ โ€” ์ˆ˜๋™ ๊ฒ€ํ†  ํ•„์š”"} +

    + {#if !report.evidence_complete} +

    Git ๋ชฉ๋ก timeout์œผ๋กœ ๊ด€๋ฆฌ์ž ๋“ฑ๋ก์„ ์ฝ๊ธฐ ์ „์šฉ fallback์œผ๋กœ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค. prune/remove๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

    + {/if} + {#if report.stale_count > 0} +
      + {#each report.worktrees.filter((worktree) => worktree.metadata_prune_eligible) as worktree (worktree.path)} +
    • + {worktree.path} + {worktree.branch ?? (worktree.detached ? "detached" : "branch ๋ฏธํ™•์ธ")} + {worktree.prunable_reason ?? "๊ฒฝ๋กœ ๋ถ€์žฌ"} +
    • + {/each} +
    + {:else} +

    stale ๋“ฑ๋ก ์—†์Œ

    + {/if} + {/if} +
    + + diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 9859806ee..51a1438a5 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -30,7 +30,20 @@ describe("api wrappers", () => { [() => api.listCacheCandidates(), "list_cache_candidates"], [() => api.listDevArtifacts("/repo"), "list_dev_artifacts", { root: "/repo", minAgeDays: 30 }], [() => api.listDevArtifacts("/repo", 7), "list_dev_artifacts", { root: "/repo", minAgeDays: 7 }], + [() => api.listStaleWorktrees("/repo"), "list_stale_worktrees", { repository: "/repo" }], [() => api.cleanPaths(["/tmp/a"]), "clean_paths", { paths: ["/tmp/a"] }], + [ + () => + api.cleanCacheCandidates([ + { id: "trivy-cache", path: "/cache/trivy", bytes: 4, files: 1, skipped: 0, scan_complete: true, fingerprint: "a".repeat(64) }, + ]), + "clean_cache_candidates", + { + requests: [ + { id: "trivy-cache", path: "/cache/trivy", bytes: 4, files: 1, skipped: 0, scan_complete: true, fingerprint: "a".repeat(64) }, + ], + }, + ], [() => api.expandCleanTargets("/tmp"), "expand_clean_targets", { dir: "/tmp" }], [() => api.recentOperations(), "recent_operations", { limit: 20 }], [() => api.recentOperations(3), "recent_operations", { limit: 3 }], diff --git a/src/lib/api.ts b/src/lib/api.ts index 7b121641c..c72d37332 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -30,8 +30,21 @@ export interface CacheCandidate { label: string; path: string; bytes: number; + files: number; + skipped: number; + scan_complete: boolean; + fingerprint: string; exists: boolean; } +export interface CacheCleanupRequest { + id: string; + path: string; + bytes: number; + files: number; + skipped: number; + scan_complete: boolean; + fingerprint: string; +} export interface DevArtifact { path: string; kind: string; @@ -39,6 +52,28 @@ export interface DevArtifact { bytes: number; age_days: number; } +export interface WorktreeCandidate { + path: string; + head: string; + branch: string | null; + is_primary: boolean; + detached: boolean; + exists: boolean; + locked_reason: string | null; + prunable_reason: string | null; + metadata_prune_eligible: boolean; + review_reasons: string[]; +} +export interface WorktreeAudit { + repository: string; + generated_at_ms: number; + registration_fingerprint: string; + evidence_complete: boolean; + worktrees: WorktreeCandidate[]; + stale_count: number; + metadata_prune_eligible_count: number; + notices: string[]; +} export interface CleanResult { path: string; ok: boolean; @@ -60,7 +95,11 @@ export interface DupeGroup { export const listCacheCandidates = () => invoke("list_cache_candidates"); export const listDevArtifacts = (root: string, minAgeDays = 30) => invoke("list_dev_artifacts", { root, minAgeDays }); +export const listStaleWorktrees = (repository: string) => + invoke("list_stale_worktrees", { repository }); export const cleanPaths = (paths: string[]) => invoke("clean_paths", { paths }); +export const cleanCacheCandidates = (requests: CacheCleanupRequest[]) => + invoke("clean_cache_candidates", { requests }); export const expandCleanTargets = (dir: string) => invoke("expand_clean_targets", { dir }); export const recentOperations = (limit = 20) => @@ -386,6 +425,7 @@ export interface CloudLineageSnapshot { duration_ms: number | null; dataset_profile: DatasetProfile | null; metadata_evidence: MetadataEvidence[]; + capacity?: CloudCapacityAssessment; } export interface CloudCopyOutput { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4a4254473..b6b2b310d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,6 +5,7 @@ import TopFiles from "$lib/TopFiles.svelte"; import Treemap from "$lib/Treemap.svelte"; import Cleanup from "$lib/Cleanup.svelte"; + import WorktreeAudit from "$lib/WorktreeAudit.svelte"; import Duplicates from "$lib/Duplicates.svelte"; import Inventory from "$lib/Inventory.svelte"; import Organize from "$lib/Organize.svelte"; @@ -120,6 +121,8 @@ 0 ? crumbs[0] : null} /> + 0 ? crumbs[0] : null} /> + 0 ? crumbs[0] : null} /> 0 ? crumbs[0] : null} /> From 7d6a6314e7a038dd55279f792a5a1b02be407d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:20:23 +0900 Subject: [PATCH 04/24] feat: revalidate developer artifact cleanup --- README.md | 6 +- src-tauri/src/commands.rs | 463 +++++++++++++++++++++++++-------- src-tauri/src/dev_artifacts.rs | 162 +++++++++++- src-tauri/src/lib.rs | 57 ++-- src/lib/Cleanup.svelte | 34 ++- src/lib/api.ts | 6 + 6 files changed, 572 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index 81fdc262f..463ab0c6d 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,14 @@ verified metadata or silently treated as safe to evict. Cache cleanup planning is bounded too: the metadata manifest has a 2-second and 100,000-record budget per catalog entry. A partial manifest is returned with `scan_complete=false` and `metadata-manifest-bounded`; it is display-only and cannot be submitted to the trash-delete gate. +Developer-artifact cleanup uses the same fail-closed rule: each `node_modules`, `target`, `venv`, +or `__pycache__` candidate carries a bounded metadata fingerprint, byte/file counts, and scan +status. The Rust command re-scans the selected root immediately before trashing; a changed, +recreated, or incomplete candidate is rejected and must be refreshed. ## Safety first -Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cache cleanup is bound to the exact candidate path, byte count, file count, and metadata fingerprint observed at review time; a changed or incomplete scan is rejected and must be refreshed. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. +Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cache and developer-artifact cleanup are bound to the exact candidate path, byte/file counts, age, and metadata fingerprint observed at review time; a changed or incomplete scan is rejected and must be refreshed. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. For a headless, read-only cache inventory, run `cargo run --locked --features cleanup-cli --bin disksage-clean-plan` (add `--id trivy-cache`, `--id pnpm-cache`, or `--id uv-cache` to inspect one candidate). The command prints the current metadata fingerprint; it never deletes files. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 300386092..55e2cf6b5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -54,7 +54,10 @@ pub struct NodeView { /// ์Šค์บ” ๊ฒฐ๊ณผ + ์‹ค์‹œ๊ฐ„ read_dir๋กœ ํ•œ ๋ ˆ๋ฒจ์„ ์กฐํšŒ (์ˆœ์ˆ˜ ํ•จ์ˆ˜ โ€” ํ…Œ์ŠคํŠธ ๋Œ€์ƒ) pub fn node_view(res: &ScanResult, path: &Path) -> Result { // '..'๋Š” lexical starts_with๋ฅผ ์šฐํšŒํ•ด ๋ฃจํŠธ ๋ฐ–์„ ์—ด๋žŒํ•  ์ˆ˜ ์žˆ์Œ โ€” ์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„๋กœ ๊ฑฐ๋ถ€ - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err("path outside scanned root".into()); } if !path.starts_with(&res.root) { @@ -96,11 +99,7 @@ pub struct CleanResult { } /// ์ •๋ฆฌ ์‹คํ–‰์˜ ์ˆœ์ˆ˜ ์ฝ”์–ด โ€” ๊ฒฐ๊ณผ๋Š” ํ•ญ๋ชฉ๋ณ„, ํ•˜๋‚˜๊ฐ€ ์‹คํŒจํ•ด๋„ ๋‚˜๋จธ์ง€๋Š” ์ง„ํ–‰ (์ŠคํŽ™ ยง8) -pub fn clean_paths_inner( - paths: &[PathBuf], - journal_path: &Path, - now_ms: u64, -) -> Vec { +pub fn clean_paths_inner(paths: &[PathBuf], journal_path: &Path, now_ms: u64) -> Vec { paths .iter() .map(|p| { @@ -171,7 +170,9 @@ pub fn clean_cache_candidates_inner( return vec![CleanResult { path: request.path.clone(), ok: false, - error: "์บ์‹œ ํ›„๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ฑฐ๋‚˜ ๋ถˆ์™„์ „ํ•˜๊ฒŒ ์ฝํ˜”์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌ ์ „์— ๋‹ค์‹œ ์Šค์บ”ํ•˜์„ธ์š”".into(), + error: + "์บ์‹œ ํ›„๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ฑฐ๋‚˜ ๋ถˆ์™„์ „ํ•˜๊ฒŒ ์ฝํ˜”์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌ ์ „์— ๋‹ค์‹œ ์Šค์บ”ํ•˜์„ธ์š”" + .into(), }]; } @@ -181,6 +182,49 @@ pub fn clean_cache_candidates_inner( .collect() } +/// ๊ฐœ๋ฐœ ์•„ํ‹ฐํŒฉํŠธ๋Š” ๋ชฉ๋ก ์‹œ์ ์˜ bounded metadata manifest์™€ ์ผ์น˜ํ•  ๋•Œ๋งŒ ํœด์ง€ํ†ต์œผ๋กœ ๋ณด๋‚ธ๋‹ค. +/// ์„ ํƒ ํ›„ ์žฌ์ƒ์„ฑยท๋ณ€๊ฒฝ๋œ target/node_modules๋Š” ๊ฒฝ๋กœ๊ฐ€ ๊ฐ™์•„๋„ ์žฌ์Šค์บ”์„ ์š”๊ตฌํ•œ๋‹ค. +pub fn clean_dev_artifacts_inner( + requests: &[dev_artifacts::DevArtifact], + root: &Path, + min_age_days: u64, + journal_path: &Path, + now_ms: u64, +) -> Vec { + let current = dev_artifacts::find_artifacts(root, min_age_days, now_ms); + requests + .iter() + .flat_map(|request| { + let matches = current.iter().find(|candidate| { + candidate.path == request.path + && candidate.kind == request.kind + && candidate.project == request.project + && candidate.bytes == request.bytes + && candidate.files == request.files + && candidate.skipped == request.skipped + && candidate.scan_complete + && request.scan_complete + && request.skipped == 0 + && candidate.fingerprint == request.fingerprint + && candidate.age_days == request.age_days + }); + if matches.is_none() { + return vec![CleanResult { + path: request.path.clone(), + ok: false, + error: "๊ฐœ๋ฐœ ์•„ํ‹ฐํŒฉํŠธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๊ฑฐ๋‚˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์Šค์บ”์ด ๋ถˆ์™„์ „ํ•ฉ๋‹ˆ๋‹ค. ์ •๋ฆฌ ์ „์— ๋‹ค์‹œ ์Šค์บ”ํ•˜์„ธ์š”".into(), + }]; + } + + clean_paths_inner( + &[PathBuf::from(&request.path)], + journal_path, + now_ms, + ) + }) + .collect() +} + /// ์ €๋„์˜ move ๊ฒฝ๋กœ ํ•„๋“œ "src -> dst"๋ฅผ ๋ถ„๋ฆฌ (์ˆœ์ˆ˜ ํ•จ์ˆ˜ โ€” ํ…Œ์ŠคํŠธ ๋Œ€์ƒ). ๊ตฌ๋ถ„์ž ์—†์œผ๋ฉด None. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { path_field @@ -189,12 +233,26 @@ pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { } /// MovePlan์„ safety::move_file๋กœ ์‹คํ–‰ํ•˜๋Š” ์ˆœ์ˆ˜ ์ฝ”์–ด โ€” ํ•ญ๋ชฉ๋ณ„ ๊ฒฐ๊ณผ, ํ•˜๋‚˜ ์‹คํŒจํ•ด๋„ ๋‚˜๋จธ์ง€๋Š” ์ง„ํ–‰ (M2์™€ ๋™์ผ ์›์น™) -pub fn execute_moves_inner(plans: &[organize::MovePlan], journal_path: &Path, now_ms: u64) -> Vec { +pub fn execute_moves_inner( + plans: &[organize::MovePlan], + journal_path: &Path, + now_ms: u64, +) -> Vec { plans .iter() - .map(|p| match safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) { - Ok(()) => CleanResult { path: p.src.clone(), ok: true, error: String::new() }, - Err(e) => CleanResult { path: p.src.clone(), ok: false, error: e.to_string() }, + .map(|p| { + match safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) { + Ok(()) => CleanResult { + path: p.src.clone(), + ok: true, + error: String::new(), + }, + Err(e) => CleanResult { + path: p.src.clone(), + ok: false, + error: e.to_string(), + }, + } }) .collect() } @@ -210,9 +268,19 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) .filter_map(|e| parse_move_entry(&e.path)) - .map(|(src, dst)| match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { - Ok(()) => CleanResult { path: src, ok: true, error: String::new() }, - Err(e) => CleanResult { path: src, ok: false, error: e.to_string() }, + .map(|(src, dst)| { + match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { + Ok(()) => CleanResult { + path: src, + ok: true, + error: String::new(), + }, + Err(e) => CleanResult { + path: src, + ok: false, + error: e.to_string(), + }, + } }) .collect() } @@ -246,7 +314,9 @@ pub fn load_ontology_from(ttl: &str) -> Result String { use tauri::Manager; if let Ok(dir) = app.path().app_config_dir() { - if let Ok(s) = std::fs::read_to_string(dir.join("userrules.json")) { return s; } + if let Ok(s) = std::fs::read_to_string(dir.join("userrules.json")) { + return s; + } } "[]".to_string() } @@ -266,7 +336,10 @@ fn bundled_ontology_ttl(app: &AppHandle) -> Result { } let res = app .path() - .resolve("resources/ontology/default.ttl", tauri::path::BaseDirectory::Resource) + .resolve( + "resources/ontology/default.ttl", + tauri::path::BaseDirectory::Resource, + ) .map_err(|e| e.to_string())?; std::fs::read_to_string(&res).map_err(|e| e.to_string()) } @@ -279,7 +352,10 @@ pub fn get_ontology(app: AppHandle) -> Result #[cfg(not(coverage))] #[tauri::command(async)] -pub fn disk_inventory(root: String, app: AppHandle) -> Result { +pub fn disk_inventory( + root: String, + app: AppHandle, +) -> Result { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let files = crate::dupes::collect_files(std::path::Path::new(&root)); Ok(crate::inventory::build_inventory(&files, &onto)) @@ -315,7 +391,10 @@ pub fn get_settings(app: AppHandle) -> Result /// online_mode ์„ค์ • ํ›„ ์˜์†. ๋ฐ˜ํ™˜์€ ์ €์žฅ๋œ ์„ค์ •. #[cfg(not(coverage))] #[tauri::command] -pub fn set_settings(online_mode: bool, app: AppHandle) -> Result { +pub fn set_settings( + online_mode: bool, + app: AppHandle, +) -> Result { let s = crate::settings::Settings { online_mode }; let path = settings_file_path(&app)?; std::fs::write(&path, crate::settings::serialize_settings(&s)).map_err(|e| e.to_string())?; @@ -418,7 +497,11 @@ pub fn list_dev_artifacts( root: String, min_age_days: u64, ) -> Result, String> { - Ok(dev_artifacts::find_artifacts(Path::new(&root), min_age_days, now_ms())) + Ok(dev_artifacts::find_artifacts( + Path::new(&root), + min_age_days, + now_ms(), + )) } #[cfg(not(coverage))] @@ -435,6 +518,24 @@ pub fn clean_paths(paths: Vec, app: AppHandle) -> Result, + app: AppHandle, +) -> Result, String> { + let jp = journal_file_path(&app)?; + Ok(clean_dev_artifacts_inner( + &artifacts, + Path::new(&root), + min_age_days, + &jp, + now_ms(), + )) +} + #[cfg(not(coverage))] #[tauri::command] pub fn clean_cache_candidates( @@ -443,12 +544,20 @@ pub fn clean_cache_candidates( ) -> Result, String> { let bases = rules::BaseDirs::from_env().ok_or("ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ")?; let jp = journal_file_path(&app)?; - Ok(clean_cache_candidates_inner(&requests, &bases, &jp, now_ms())) + Ok(clean_cache_candidates_inner( + &requests, + &bases, + &jp, + now_ms(), + )) } #[cfg(not(coverage))] #[tauri::command] -pub fn recent_operations(limit: usize, app: AppHandle) -> Result, String> { +pub fn recent_operations( + limit: usize, + app: AppHandle, +) -> Result, String> { Ok(safety::journal_recent(&journal_file_path(&app)?, limit)) } @@ -456,7 +565,9 @@ pub fn recent_operations(limit: usize, app: AppHandle) -> Result Vec { // ์นดํƒˆ๋กœ๊ทธ ๊ฒฝ๋กœ๋กœ๋งŒ ์Šค์ฝ”ํ”„ โ€” ์ž„์˜ ๋””๋ ‰ํ† ๋ฆฌ ์—ด๋žŒ IPC๊ฐ€ ๋˜์ง€ ์•Š๋„๋ก - let Some(bases) = rules::BaseDirs::from_env() else { return Vec::new() }; + let Some(bases) = rules::BaseDirs::from_env() else { + return Vec::new(); + }; let d = Path::new(&dir); if !rules::is_catalog_path(&bases, d) { return Vec::new(); @@ -577,12 +688,7 @@ pub async fn connect_cloud_provider( let connection_path = oauth_connections_path(&app)?; let connected_at_ms = cloud::system_now_ms(); tauri::async_runtime::spawn_blocking(move || { - provider_oauth::finish_authorization( - pending, - &selected, - &connection_path, - connected_at_ms, - ) + provider_oauth::finish_authorization(pending, &selected, &connection_path, connected_at_ms) }) .await .map_err(|_| "provider-oauth-task-failed".to_string())? @@ -592,10 +698,7 @@ pub async fn connect_cloud_provider( /// connection descriptor. This does not alter any cloud file. #[cfg(not(coverage))] #[tauri::command(async)] -pub async fn disconnect_cloud_provider( - cloud_root: String, - app: AppHandle, -) -> Result<(), String> { +pub async fn disconnect_cloud_provider(cloud_root: String, app: AppHandle) -> Result<(), String> { let selected = selected_cloud_root(&app, &cloud_root)?; if selected.provider == cloud::CloudProvider::Icloud { return Err("icloud-oauth-not-supported".into()); @@ -686,7 +789,10 @@ fn cloud_plan_for_inputs( .cloned() .ok_or_else(|| "ํƒ์ง€๋œ ํด๋ผ์šฐ๋“œ ๋ฃจํŠธ๊ฐ€ ์•„๋‹˜".to_string())?; cloud::validate_cloud_root_readable(&selected)?; - let excluded: Vec = discovered.iter().map(|root| PathBuf::from(&root.path)).collect(); + let excluded: Vec = discovered + .iter() + .map(|root| PathBuf::from(&root.path)) + .collect(); if excluded.iter().any(|cloud| root_path.starts_with(cloud)) { return Err("์ด๋ฏธ ํด๋ผ์šฐ๋“œ ์•ˆ์— ์žˆ๋Š” ๊ฒฝ๋กœ๋Š” ์˜คํ”„๋กœ๋“œ ์›๋ณธ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Œ".into()); } @@ -755,18 +861,20 @@ fn attach_capacity_assessment( report .notices .retain(|notice| notice != "cloud-quota-unverified"); - report.notices.push(match assessment.can_fit { - Some(true) - if assessment.snapshot.evidence_kind - == provider_capacity::CapacityEvidenceKind::ProviderNativeStatus => - { - "cloud-quota-provider-native-verified" + report.notices.push( + match assessment.can_fit { + Some(true) + if assessment.snapshot.evidence_kind + == provider_capacity::CapacityEvidenceKind::ProviderNativeStatus => + { + "cloud-quota-provider-native-verified" + } + Some(true) => "cloud-quota-provider-api-verified", + Some(false) => "cloud-quota-insufficient-or-blocked", + None => "cloud-quota-unavailable", } - Some(true) => "cloud-quota-provider-api-verified", - Some(false) => "cloud-quota-insufficient-or-blocked", - None => "cloud-quota-unavailable", - } - .into()); + .into(), + ); report.capacity = Some(assessment); } @@ -831,7 +939,11 @@ fn local_human_reviewer() -> String { .collect(); format!( "human:local:{}", - if bounded.is_empty() { "unknown" } else { &bounded } + if bounded.is_empty() { + "unknown" + } else { + &bounded + } ) } @@ -851,9 +963,7 @@ pub fn review_cloud_candidate( state: State, ) -> Result { for fingerprint in [&metadata_fingerprint, &review_fingerprint] { - if fingerprint.len() != 64 - || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) - { + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("cloud-review-fingerprint-invalid".into()); } } @@ -861,14 +971,8 @@ pub fn review_cloud_candidate( .cloud_review .lock() .map_err(|_| "cloud-review-lock-poisoned".to_string())?; - let (_, report) = cloud_plan_for_inputs( - &root, - &cloud_root, - min_size_mib, - min_age_days, - limit, - &app, - )?; + let (_, report) = + cloud_plan_for_inputs(&root, &cloud_root, min_size_mib, min_age_days, limit, &app)?; let matches: Vec<_> = report .candidates .iter() @@ -916,18 +1020,14 @@ fn create_cloud_candidate_receipt( adopt_existing: bool, ) -> Result { if metadata_fingerprint.len() != 64 - || !metadata_fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !metadata_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) { return Err("metadata-fingerprint-invalid".into()); } - let (selected, report) = cloud_plan_for_inputs( - root, - cloud_root, - min_size_mib, - min_age_days, - limit, - app, - )?; + let (selected, report) = + cloud_plan_for_inputs(root, cloud_root, min_size_mib, min_age_days, limit, app)?; let matches: Vec<_> = report .candidates .iter() @@ -1180,7 +1280,11 @@ pub async fn attest_cloud_copy( #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn plan_organize(root: String, app: AppHandle, state: State) -> Result, String> { +pub fn plan_organize( + root: String, + app: AppHandle, + state: State, +) -> Result, String> { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let rules = crate::userrules::parse_rules(&user_rules_json(&app))?; // malformed โ†’ Err surfaced let files = dupes::collect_files(Path::new(&root)); @@ -1204,11 +1308,25 @@ pub fn plan_organize(root: String, app: AppHandle, state: State) -> Re let meta = file_meta_at(p, 0, 0); crate::llm::pick_class(engine, &meta, cands) }; - return Ok(organize::plan_moves_with(&files, &onto, &home, now_ms(), &rules, &pick)); + return Ok(organize::plan_moves_with( + &files, + &onto, + &home, + now_ms(), + &rules, + &pick, + )); } } } - Ok(organize::plan_moves_with(&files, &onto, &home, now_ms(), &rules, &|_, _| None)) + Ok(organize::plan_moves_with( + &files, + &onto, + &home, + now_ms(), + &rules, + &|_, _| None, + )) } /// ํ™œ์„ฑ ์‚ฌ์šฉ์ž ๊ทœ์น™ ์กฐํšŒ(UI ํ‘œ์‹œ์šฉ). ์†์ƒ ํŒŒ์ผ์€ Err. @@ -1221,7 +1339,10 @@ pub fn user_rules(app: AppHandle) -> Result, String> /// MovePlan์„ safety::move_file๋กœ ์‹คํ–‰ โ€” ํ•ญ๋ชฉ๋ณ„ ๊ฒฐ๊ณผ, ํ•˜๋‚˜ ์‹คํŒจํ•ด๋„ ๋‚˜๋จธ์ง€๋Š” ์ง„ํ–‰ (M2์™€ ๋™์ผ ์›์น™) #[cfg(not(coverage))] #[tauri::command(async)] -pub fn execute_moves(plans: Vec, app: AppHandle) -> Result, String> { +pub fn execute_moves( + plans: Vec, + app: AppHandle, +) -> Result, String> { let jp = journal_file_path(&app)?; Ok(execute_moves_inner(&plans, &jp, now_ms())) } @@ -1242,23 +1363,37 @@ pub struct ModelStatus { /// ๋ชจ๋ธ ํŒŒ์ผ ๊ฒฝ๋กœ: /models/.gguf pub fn model_file_path(app_data_dir: &Path) -> PathBuf { - app_data_dir.join("models").join(format!("{}.gguf", crate::llm::DEFAULT.name)) + app_data_dir + .join("models") + .join(format!("{}.gguf", crate::llm::DEFAULT.name)) } /// ๋ชจ๋ธ ์กด์žฌ ์—ฌ๋ถ€ + ์ด๋ฆ„. ์—†์œผ๋ฉด ์•ฑ์€ ๊ทœ์น™ ๊ธฐ๋ฐ˜์œผ๋กœ ๋™์ž‘(๋ฐฐ์ง€ ๋ฏธํŒ์ •). pub fn model_status_for(model_path: &Path) -> ModelStatus { - ModelStatus { present: model_path.exists(), name: crate::llm::DEFAULT.name.to_string() } + ModelStatus { + present: model_path.exists(), + name: crate::llm::DEFAULT.name.to_string(), + } } /// ๊ฒฝ๋กœ + (์ด๋ฏธ ์ฝ์€) sizeยทage๋กœ FileMeta ๊ตฌ์„ฑ. name/parent๋Š” ๊ฒฝ๋กœ์—์„œ, ์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด(ํŒจ๋‹‰ ์—†์Œ). pub fn file_meta_at(path: &Path, size: u64, mtime_days: u64) -> crate::llm::FileMeta { - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); let parent = path .parent() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - crate::llm::FileMeta { path: path.to_string_lossy().into_owned(), name, size, mtime_days, parent } + crate::llm::FileMeta { + path: path.to_string_lossy().into_owned(), + name, + size, + mtime_days, + parent, + } } /// ํ•ญ๋ชฉ๋งˆ๋‹ค ์บ์‹œ(path|size|mtime_ms) ํ™•์ธ ํ›„ ๋ฏธ์Šค๋ฉด ์ถ”๋ก . ํŒ์ •๋งŒ ์บ์‹œ(์ด์œ ๋Š” ๋ฏธ์Šค ์‹œ์—๋งŒ). @@ -1271,7 +1406,11 @@ pub fn verdicts_with( for (meta, mtime_ms) in items { let key = crate::llm::VerdictCache::key(&meta.path, meta.size, *mtime_ms); if let Some(v) = cache.get(&key) { - out.push(crate::llm::FileVerdict { path: meta.path.clone(), verdict: v, reason: String::new() }); + out.push(crate::llm::FileVerdict { + path: meta.path.clone(), + verdict: v, + reason: String::new(), + }); } else { let fv = crate::llm::verdict_for(engine, meta); cache.put(key, fv.verdict); @@ -1287,16 +1426,21 @@ pub fn verdicts_with( #[cfg(not(coverage))] fn meta_items(paths: &[String]) -> Vec<(crate::llm::FileMeta, u64)> { - paths.iter().filter_map(|p| { - let path = std::path::Path::new(p); - let md = std::fs::metadata(path).ok()?; - let mtime_ms = md.modified().ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let age_days = now_ms().saturating_sub(mtime_ms) / 86_400_000; // ์‹ค์ œ ํŒŒ์ผ ๋‚˜์ด(ํ”„๋กฌํ”„ํŠธ์šฉ); ์บ์‹œ ํ‚ค๋Š” ์›์‹œ mtime_ms ์‚ฌ์šฉ - Some((file_meta_at(path, md.len(), age_days), mtime_ms)) - }).collect() + paths + .iter() + .filter_map(|p| { + let path = std::path::Path::new(p); + let md = std::fs::metadata(path).ok()?; + let mtime_ms = md + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let age_days = now_ms().saturating_sub(mtime_ms) / 86_400_000; // ์‹ค์ œ ํŒŒ์ผ ๋‚˜์ด(ํ”„๋กฌํ”„ํŠธ์šฉ); ์บ์‹œ ํ‚ค๋Š” ์›์‹œ mtime_ms ์‚ฌ์šฉ + Some((file_meta_at(path, md.len(), age_days), mtime_ms)) + }) + .collect() } #[cfg(not(coverage))] @@ -1323,7 +1467,11 @@ pub fn download_model(app: AppHandle) -> Result<(), String> { #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn file_verdicts(paths: Vec, app: AppHandle, state: State) -> Result, String> { +pub fn file_verdicts( + paths: Vec, + app: AppHandle, + state: State, +) -> Result, String> { let items = meta_items(&paths); #[cfg(feature = "llm-engine")] @@ -1358,7 +1506,11 @@ pub fn file_verdicts(paths: Vec, app: AppHandle, state: State) #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn summarize_unknown_bucket(paths: Vec, app: AppHandle, state: State) -> Result, String> { +pub fn summarize_unknown_bucket( + paths: Vec, + app: AppHandle, + state: State, +) -> Result, String> { if paths.is_empty() { return Ok(None); } @@ -1399,8 +1551,14 @@ pub fn reason_unknown_extensions( // opt-in ์›น: online_mode์ผ ๋•Œ๋งŒ DdgLookup, ์•„๋‹ˆ๋ฉด None โ†’ build_insights์˜ ์›น ๋ถ„๊ธฐ ์ ˆ๋Œ€ ๋ฏธ์‹คํ–‰(default offline) let settings = get_settings(app.clone())?; let ddg = crate::web::DdgLookup; - let web_fn = |ext: &str| -> Option { crate::web::WebLookup::file_type(&ddg, ext).ok().flatten() }; - let web: Option<&dyn Fn(&str) -> Option> = if settings.online_mode { Some(&web_fn) } else { None }; + let web_fn = |ext: &str| -> Option { + crate::web::WebLookup::file_type(&ddg, ext).ok().flatten() + }; + let web: Option<&dyn Fn(&str) -> Option> = if settings.online_mode { + Some(&web_fn) + } else { + None + }; // ์˜คํ”„๋ผ์ธ LLM(feature+๋ชจ๋ธ+์—”์ง„ ์žˆ์œผ๋ฉด ์‹ค์ œ; ๊ทธ ๋ธ”๋ก์—์„œ ๋ฐ˜ํ™˜). ์—†์œผ๋ฉด ์•„๋ž˜ fallback๋กœ ๋‚™ํ•˜. #[cfg(feature = "llm-engine")] @@ -1410,8 +1568,11 @@ pub fn reason_unknown_extensions( if model_status_for(&model_file_path(&dir)).present { // ์˜จํ†จ๋กœ์ง€ ๋กœ๋“œ๋Š” LLM ๊ฒฝ๋กœ์—์„œ๋งŒ ํ•„์š” โ€” ์—ฌ๊ธฐ๋กœ ์ด๋™ํ•ด ๊ธฐ๋ณธ/์›น์ „์šฉ ๋นŒ๋“œ๊ฐ€ malformed ontology.ttl๋กœ ์‹คํŒจํ•˜์ง€ ์•Š๊ฒŒ ํ•จ let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; - let candidates: Vec = onto.classes.iter() - .map(|c| c.id.rsplit(['#', '/']).next().unwrap_or(&c.id).to_string()).collect(); + let candidates: Vec = onto + .classes + .iter() + .map(|c| c.id.rsplit(['#', '/']).next().unwrap_or(&c.id).to_string()) + .collect(); let cand_refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect(); let mut guard = state.engine.lock().unwrap(); @@ -1443,7 +1604,10 @@ mod tests { // --- M5 LLM ์ปค๋งจ๋“œ ์ˆœ์ˆ˜ ํ—ฌํผ --- use crate::llm::{InferenceEngine, Verdict, VerdictCache}; - struct CountingFake { out: String, calls: std::cell::Cell } + struct CountingFake { + out: String, + calls: std::cell::Cell, + } impl InferenceEngine for CountingFake { fn infer(&self, _p: &str) -> Result { self.calls.set(self.calls.get() + 1); @@ -1484,19 +1648,29 @@ mod tests { #[test] fn verdicts_with_caches_and_avoids_reinference() { - let engine = CountingFake { out: r#"{"verdict":"safe","reason":"r"}"#.into(), calls: std::cell::Cell::new(0) }; + let engine = CountingFake { + out: r#"{"verdict":"safe","reason":"r"}"#.into(), + calls: std::cell::Cell::new(0), + }; let mut cache = VerdictCache::new(); let meta = file_meta_at(std::path::Path::new("/x/a.bin"), 100, 1); let items = vec![(meta.clone(), 1700u64), (meta, 1700u64)]; // ๊ฐ™์€ path|size|mtime โ†’ ๋‘ ๋ฒˆ์งธ๋Š” ์บ์‹œ ํžˆํŠธ let out = verdicts_with(&engine, &mut cache, &items); assert_eq!(out.len(), 2); assert!(out.iter().all(|fv| fv.verdict == Verdict::Safe)); - assert_eq!(engine.calls.get(), 1, "๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์€ ์บ์‹œ ํžˆํŠธ๋ผ ์ถ”๋ก  1ํšŒ๋งŒ"); + assert_eq!( + engine.calls.get(), + 1, + "๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์€ ์บ์‹œ ํžˆํŠธ๋ผ ์ถ”๋ก  1ํšŒ๋งŒ" + ); } #[test] fn verdicts_with_distinct_items_infer_each() { - let engine = CountingFake { out: r#"{"verdict":"keep"}"#.into(), calls: std::cell::Cell::new(0) }; + let engine = CountingFake { + out: r#"{"verdict":"keep"}"#.into(), + calls: std::cell::Cell::new(0), + }; let mut cache = VerdictCache::new(); let a = (file_meta_at(std::path::Path::new("/x/a"), 1, 1), 10u64); let b = (file_meta_at(std::path::Path::new("/x/b"), 2, 2), 20u64); @@ -1646,9 +1820,14 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . let ok_file = tmp.path().join("disksage-clean-fixture-file.bin"); fs::write(&ok_file, vec![0u8; 16]).unwrap(); let missing = tmp.path().join("ghost"); - let protected = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows" } else { "/usr" }); + let protected = + std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows" } else { "/usr" }); - let results = clean_paths_inner(&[ok_dir.clone(), ok_file.clone(), missing, protected], &jp, 7); + let results = clean_paths_inner( + &[ok_dir.clone(), ok_file.clone(), missing, protected], + &jp, + 7, + ); assert_eq!(results.len(), 4); assert!(results[0].ok); @@ -1668,7 +1847,10 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . .iter() .find(|e| e.outcome == "ok" && e.path.contains("disksage-clean-fixture-file")) .unwrap(); - assert_eq!(ok_file_entry.bytes, 16, "๋‹จ์ผ ํŒŒ์ผ์€ metadata ํฌ๊ธฐ๋กœ ์ €๋„๋ง"); + assert_eq!( + ok_file_entry.bytes, 16, + "๋‹จ์ผ ํŒŒ์ผ์€ metadata ํฌ๊ธฐ๋กœ ์ €๋„๋ง" + ); // ํ…Œ์ŠคํŠธ ํ”ฝ์Šค์ฒ˜ ํœด์ง€ํ†ต ์ •๋ฆฌ (win/linux) #[cfg(any(windows, target_os = "linux"))] @@ -1678,7 +1860,8 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . .into_iter() .filter(|i| { let n = i.name.to_string_lossy(); - n.contains("disksage-clean-fixture-dir") || n.contains("disksage-clean-fixture-file") + n.contains("disksage-clean-fixture-dir") + || n.contains("disksage-clean-fixture-file") }) .collect(); trash::os_limited::purge_all(items).unwrap(); @@ -1717,7 +1900,8 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . scan_complete: observed.scan_complete, fingerprint: observed.fingerprint, }; - let results = clean_cache_candidates_inner(&[request], &bases, &tmp.path().join("journal.jsonl"), 1); + let results = + clean_cache_candidates_inner(&[request], &bases, &tmp.path().join("journal.jsonl"), 1); assert_eq!(results.len(), 1); assert!(!results[0].ok); @@ -1726,6 +1910,42 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . assert!(trivy_path.join("new.bin").exists()); } + #[test] + fn dev_artifact_cleanup_rejects_a_stale_metadata_fingerprint() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("webapp"); + let artifact = project.join("node_modules"); + fs::create_dir_all(&artifact).unwrap(); + fs::write(project.join("package.json"), b"{}").unwrap(); + fs::write(artifact.join("payload.bin"), b"old").unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let observed = crate::dev_artifacts::find_artifacts(tmp.path(), 0, now); + assert_eq!(observed.len(), 1); + + // The path still exists, but its metadata manifest no longer matches the selection. + fs::write( + artifact.join("payload.bin"), + b"recreated-with-different-size", + ) + .unwrap(); + let results = clean_dev_artifacts_inner( + &observed, + tmp.path(), + 0, + &tmp.path().join("journal.jsonl"), + now, + ); + + assert_eq!(results.len(), 1); + assert!(!results[0].ok); + assert!(results[0].error.contains("๋‹ค์‹œ ์Šค์บ”")); + assert!(artifact.join("payload.bin").exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); @@ -1735,8 +1955,16 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . let dst_ok = tmp.path().join("sub").join("a.bin"); // ํ•˜๋‚˜๋Š” ์„ฑ๊ณต(๊ฐ™์€ ๋ณผ๋ฅจ rename), ํ•˜๋‚˜๋Š” ์‹คํŒจ(์กด์žฌํ•˜์ง€ ์•Š๋Š” src) let plans = vec![ - organize::MovePlan { src: src_ok.to_string_lossy().into(), dst: dst_ok.to_string_lossy().into(), class_id: "x".into() }, - organize::MovePlan { src: tmp.path().join("ghost").to_string_lossy().into(), dst: tmp.path().join("g2").to_string_lossy().into(), class_id: "x".into() }, + organize::MovePlan { + src: src_ok.to_string_lossy().into(), + dst: dst_ok.to_string_lossy().into(), + class_id: "x".into(), + }, + organize::MovePlan { + src: tmp.path().join("ghost").to_string_lossy().into(), + dst: tmp.path().join("g2").to_string_lossy().into(), + class_id: "x".into(), + }, ]; let results = execute_moves_inner(&plans, &jp, 1); assert_eq!(results.len(), 2); @@ -1754,7 +1982,11 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . std::fs::write(&a, vec![2u8; 8]).unwrap(); let a_moved = tmp.path().join("dest").join("a.bin"); // ๋จผ์ € ์ด๋™ ์‹คํ–‰(์ €๋„์— move/ok ๊ธฐ๋ก) - let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into() }]; + let plans = vec![organize::MovePlan { + src: a.to_string_lossy().into(), + dst: a_moved.to_string_lossy().into(), + class_id: "x".into(), + }]; execute_moves_inner(&plans, &jp, 5); assert!(!a.exists()); assert!(a_moved.exists()); @@ -1775,10 +2007,22 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . let s = tmp.path().join(name); std::fs::write(&s, b"z").unwrap(); let d = tmp.path().join("d").join(name); - execute_moves_inner(&[organize::MovePlan { src: s.to_string_lossy().into(), dst: d.to_string_lossy().into(), class_id: "x".into() }], &jp, 1); + execute_moves_inner( + &[organize::MovePlan { + src: s.to_string_lossy().into(), + dst: d.to_string_lossy().into(), + class_id: "x".into(), + }], + &jp, + 1, + ); } let undone = undo_last_moves_inner(1, &jp, 9); - assert_eq!(undone.len(), 1, "filter-before-take: pending ๋ผ์ธ์ด ์‹ค์ œ ์„ฑ๊ณต์„ ๋ฐ€์–ด๋‚ด์ง€ ์•Š์Œ"); + assert_eq!( + undone.len(), + 1, + "filter-before-take: pending ๋ผ์ธ์ด ์‹ค์ œ ์„ฑ๊ณต์„ ๋ฐ€์–ด๋‚ด์ง€ ์•Š์Œ" + ); } #[test] @@ -1788,14 +2032,21 @@ dm:Image a owl:Class ; rdfs:label "์ด๋ฏธ์ง€"@ko . let a = tmp.path().join("a.bin"); std::fs::write(&a, vec![3u8; 4]).unwrap(); let a_moved = tmp.path().join("dest").join("a.bin"); - let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into() }]; + let plans = vec![organize::MovePlan { + src: a.to_string_lossy().into(), + dst: a_moved.to_string_lossy().into(), + class_id: "x".into(), + }]; execute_moves_inner(&plans, &jp, 1); assert!(a_moved.exists()); // ์›๋ž˜ ์ž๋ฆฌ์— ์ƒˆ ํŒŒ์ผ์ด ๋‹ค์‹œ ์ƒ๊ฒจ ๋˜๋Œ๋ฆฌ๊ธฐ ๋ชฉ์ ์ง€๊ฐ€ ๋ง‰ํž˜ โ†’ move_file์ด ์‹คํŒจํ•ด์•ผ ํ•จ std::fs::write(&a, b"blocker").unwrap(); let undone = undo_last_moves_inner(1, &jp, 2); assert_eq!(undone.len(), 1); - assert!(!undone[0].ok, "๋ชฉ์ ์ง€ ์žฌ์ ์œ  ์‹œ ๋˜๋Œ๋ฆฌ๊ธฐ ์‹คํŒจ๋ฅผ ๋ณด๊ณ ํ•ด์•ผ ํ•จ"); + assert!( + !undone[0].ok, + "๋ชฉ์ ์ง€ ์žฌ์ ์œ  ์‹œ ๋˜๋Œ๋ฆฌ๊ธฐ ์‹คํŒจ๋ฅผ ๋ณด๊ณ ํ•ด์•ผ ํ•จ" + ); assert!(a_moved.exists(), "์‹คํŒจ ์‹œ ์›๋ณธ์€ ์ด๋™๋œ ์œ„์น˜์— ๊ทธ๋Œ€๋กœ ๋‚จ์Œ"); } } diff --git a/src-tauri/src/dev_artifacts.rs b/src-tauri/src/dev_artifacts.rs index 9d70123a2..fe6cf52be 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -1,14 +1,25 @@ use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; +use std::time::{Duration, Instant}; use crate::scanner; -#[derive(Debug, Clone, serde::Serialize)] +// A development tree can contain millions of generated entries. The inventory remains +// fail-closed for cleanup when this bounded metadata manifest cannot finish; it must never turn +// a partial observation into permission to move a recreated directory to the trash. +const ARTIFACT_MANIFEST_BUDGET: Duration = Duration::from_secs(3); +const ARTIFACT_MANIFEST_MAX_RECORDS: usize = 250_000; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DevArtifact { pub path: String, pub kind: String, pub project: String, pub bytes: u64, + pub files: u64, + pub skipped: u64, + pub scan_complete: bool, + /// Deterministic metadata manifest; file contents are never read. + pub fingerprint: String, pub age_days: u64, } @@ -28,11 +39,125 @@ fn artifact_kind(name: &str) -> Option<&'static (&'static str, &'static [&'stati fn age_days(path: &Path, now_ms: u64) -> u64 { let Ok(md) = path.metadata() else { return 0 }; let Ok(mtime) = md.modified() else { return 0 }; - let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { return 0 }; + let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { + return 0; + }; let mtime_ms = dur.as_millis() as u64; now_ms.saturating_sub(mtime_ms) / 86_400_000 } +#[derive(Default)] +struct ArtifactManifest { + bytes: u64, + files: u64, + skipped: u64, + scan_complete: bool, + records: Vec, + fingerprint: String, +} + +/// Build a bounded, deterministic metadata-only manifest for one generated directory. +/// +/// Paths, kinds, sizes, mtimes, and symlink targets are enough to detect a stale selection while +/// avoiding sensitive content reads. A time/record bound makes the cleanup gate fail closed on +/// unusually large trees instead of blocking the UI indefinitely. +fn artifact_manifest(root: &Path) -> ArtifactManifest { + let mut manifest = ArtifactManifest { + scan_complete: true, + ..ArtifactManifest::default() + }; + let deadline = Instant::now() + ARTIFACT_MANIFEST_BUDGET; + let walker = jwalk::WalkDir::new(root) + .follow_links(false) + .skip_hidden(false) + .process_read_dir(|_depth, _path, _state, children| { + children.retain(|r| r.as_ref().map(scanner::keep_entry).unwrap_or(true)); + }); + + for entry in walker { + if Instant::now() >= deadline || manifest.records.len() >= ARTIFACT_MANIFEST_MAX_RECORDS { + manifest.scan_complete = false; + break; + } + let Ok(entry) = entry else { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + continue; + }; + if entry.read_children_error.is_some() { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + } + let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(root) + .unwrap_or(entry_path.as_path()) + .to_string_lossy() + .replace('\\', "/"); + let relative = if relative.is_empty() { "." } else { &relative }; + let file_type = entry.file_type(); + if file_type.is_dir() { + let modified = entry + .metadata() + .ok() + .and_then(|m| modified_stamp(&m)) + .unwrap_or_else(|| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + manifest.records.push(format!("D\0{relative}\0{modified}")); + } else if file_type.is_file() { + let Ok(metadata) = entry.metadata() else { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + continue; + }; + let modified = modified_stamp(&metadata).unwrap_or_else(|| { + manifest.skipped = manifest.skipped.saturating_add(1); + manifest.scan_complete = false; + "".into() + }); + manifest.bytes = manifest.bytes.saturating_add(metadata.len()); + manifest.files = manifest.files.saturating_add(1); + manifest + .records + .push(format!("F\0{relative}\0{}\0{modified}", metadata.len())); + } + } + + if !manifest.scan_complete { + manifest + .records + .push("!incomplete\0bounded-artifact-manifest".into()); + } + manifest.records.sort_unstable(); + manifest.fingerprint = metadata_fingerprint(&manifest.records); + manifest +} + +fn modified_stamp(metadata: &std::fs::Metadata) -> Option { + let duration = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + Some(format!( + "{}:{}", + duration.as_secs(), + duration.subsec_nanos() + )) +} + +fn metadata_fingerprint(records: &[String]) -> String { + let mut hasher = blake3::Hasher::new(); + for record in records { + hasher.update(&(record.len() as u64).to_le_bytes()); + hasher.update(record.as_bytes()); + } + hasher.finalize().to_hex().to_string() +} + /// ๋งˆ์ปค ์ธ์ ‘ ์•„ํ‹ฐํŒฉํŠธ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ฐพ์•„ mtime ๋‚˜์ด๋กœ ๊ฑธ๋Ÿฌ ํฌ๊ธฐ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ๋ฐ˜ํ™˜. /// /// 2ํŒจ์Šค๋กœ ๋‚˜๋ˆˆ ์ด์œ : jwalk๋Š” ๋ณ‘๋ ฌ๋กœ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ˆœํšŒํ•ด ๋ถ€๋ชจ/์ž์‹ ๋ฐฉ๋ฌธ ์ˆœ์„œ๋ฅผ @@ -57,8 +182,12 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec = top_level .into_iter() .filter_map(|path| { - let age = if now_ms == u64::MAX { u64::MAX } else { age_days(path, now_ms) }; + let age = if now_ms == u64::MAX { + u64::MAX + } else { + age_days(path, now_ms) + }; if age < min_age_days { return None; } let name = path.file_name()?.to_string_lossy().into_owned(); let (kind, _) = artifact_kind(&name)?; let parent = path.parent().unwrap_or(root); - // interval 1: ์ง„ํ–‰ ์ฝœ๋ฐฑ(no-op)์ด ์ž‘์€ ํ…Œ์ŠคํŠธ ํ”ฝ์Šค์ฒ˜์—์„œ๋„ ์‹คํ–‰๋˜์–ด ์ปค๋ฒ„๋ฆฌ์ง€์—์„œ - // 0์œผ๋กœ ๋‚จ์ง€ ์•Š์Œ โ€” ์ฝœ๋ฐฑ์ด ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ํ˜ธ์ถœ ๋นˆ๋„๋Š” ๋™์ž‘์— ๋ฌด๊ด€ - let bytes = scanner::scan_dir_with_interval(path, &AtomicBool::new(false), 1, |_| {}).stats.bytes; + let manifest = artifact_manifest(path); Some(DevArtifact { path: path.to_string_lossy().into_owned(), kind: kind.to_string(), @@ -99,7 +230,11 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec std::path::PathBuf { + fn project( + root: &std::path::Path, + name: &str, + marker: &str, + artifact: &str, + ) -> std::path::PathBuf { let p = root.join(name); fs::create_dir_all(&p).unwrap(); fs::write(p.join(marker), b"{}").unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ba82f67e..ec00cd93c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,50 +1,50 @@ // coverage ๋นŒ๋“œ(๋น„-ํ…Œ์ŠคํŠธ)์—์„œ๋Š” run()์ด ๋น ์ ธ ๋ชจ๋“ˆ ๋‚ด์šฉ์ด ํ…Œ์ŠคํŠธ์—์„œ๋งŒ ์“ฐ์ด๋ฏ€๋กœ dead_code๋งŒ ํ—ˆ์šฉ +pub mod archive_git_tree; #[cfg_attr(coverage, allow(dead_code))] -mod dupes; +pub mod cloud; +#[cfg(not(coverage))] +pub mod cloud_eviction; +pub mod cloud_review; +pub mod cloud_transfer; #[cfg_attr(coverage, allow(dead_code))] mod commands; +pub mod content_digest; #[cfg_attr(coverage, allow(dead_code))] -mod scanner; -#[cfg_attr(coverage, allow(dead_code))] -mod userrules; -#[cfg_attr(coverage, allow(dead_code))] -mod settings; -#[cfg_attr(coverage, allow(dead_code))] -mod safety; -#[cfg_attr(coverage, allow(dead_code))] -pub mod rules; -#[cfg_attr(coverage, allow(dead_code))] -pub mod worktrees; +mod dataset_metadata; #[cfg_attr(coverage, allow(dead_code))] mod dev_artifacts; #[cfg_attr(coverage, allow(dead_code))] -mod ontology; +mod dupes; #[cfg_attr(coverage, allow(dead_code))] mod inventory; #[cfg_attr(coverage, allow(dead_code))] -mod organize; -#[cfg_attr(coverage, allow(dead_code))] mod llm; +pub mod naruon_lineage; #[cfg_attr(coverage, allow(dead_code))] -mod web; -#[cfg_attr(coverage, allow(dead_code))] -mod reasoning; -#[cfg_attr(coverage, allow(dead_code))] -mod dataset_metadata; -pub mod archive_git_tree; +mod ontology; #[cfg_attr(coverage, allow(dead_code))] -pub mod cloud; -#[cfg(not(coverage))] -pub mod cloud_eviction; -pub mod cloud_review; -pub mod cloud_transfer; -pub mod content_digest; -pub mod naruon_lineage; +mod organize; pub mod provider_api_client; pub mod provider_capacity; pub mod provider_evidence; pub mod provider_oauth; pub mod provider_sync; +#[cfg_attr(coverage, allow(dead_code))] +mod reasoning; +#[cfg_attr(coverage, allow(dead_code))] +pub mod rules; +#[cfg_attr(coverage, allow(dead_code))] +mod safety; +#[cfg_attr(coverage, allow(dead_code))] +mod scanner; +#[cfg_attr(coverage, allow(dead_code))] +mod settings; +#[cfg_attr(coverage, allow(dead_code))] +mod userrules; +#[cfg_attr(coverage, allow(dead_code))] +mod web; +#[cfg_attr(coverage, allow(dead_code))] +pub mod worktrees; // coverage ๋นŒ๋“œ์—์„œ ์ œ์™ธ โ€” GUI ๋Ÿฐํƒ€์ž„์€ ํ—ค๋“œ๋ฆฌ์Šค ํ…Œ์ŠคํŠธ๋กœ ์‹คํ–‰ ๋ถˆ๊ฐ€ #[cfg(not(coverage))] @@ -64,6 +64,7 @@ pub fn run() { commands::list_dev_artifacts, commands::list_stale_worktrees, commands::clean_paths, + commands::clean_dev_artifacts, commands::clean_cache_candidates, commands::recent_operations, commands::expand_clean_targets, diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index b1f1c14f6..af9652ad6 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -46,12 +46,14 @@ caches .filter((c) => selectedRules.has(c.id) && c.skipped === 0 && c.scan_complete) .reduce((s, c) => s + c.bytes, 0) + - artifacts.filter((a) => selected.has(a.path)).reduce((s, a) => s + a.bytes, 0), + artifacts + .filter((a) => selected.has(a.path) && a.scan_complete && a.skipped === 0) + .reduce((s, a) => s + a.bytes, 0), ); let selectionCount = $derived( caches.filter((c) => selectedRules.has(c.id) && c.exists && c.skipped === 0 && c.scan_complete).length + - artifacts.filter((a) => selected.has(a.path)).length, + artifacts.filter((a) => selected.has(a.path) && a.scan_complete && a.skipped === 0).length, ); async function executeClean() { @@ -59,13 +61,17 @@ const ruleDirs = caches.filter( (c) => selectedRules.has(c.id) && c.exists && c.skipped === 0 && c.scan_complete, ); - const artifactPaths = artifacts.filter((a) => selected.has(a.path)).map((a) => a.path); + const selectedArtifacts = artifacts.filter( + (a) => selected.has(a.path) && a.scan_complete && a.skipped === 0, + ); const summary = [ ...ruleDirs.map( (c) => `${c.label} (${fmtBytes(c.bytes)}, ${c.files}๊ฐœ) โ€” ๋‚ด์šฉ๋ฌผ ๋น„์šฐ๊ธฐ ยท ์ง€๋ฌธ ${c.fingerprint.slice(0, 12)}`, ), - ...artifactPaths, + ...selectedArtifacts.map( + (a) => `${a.path} (${fmtBytes(a.bytes)}, ${a.files}๊ฐœ) โ€” ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ง€๋ฌธ ${a.fingerprint.slice(0, 12)}`, + ), ]; if (summary.length === 0) return; const okay = await confirm( @@ -94,7 +100,9 @@ })), ) : []; - const artifactResults = artifactPaths.length ? await api.cleanPaths(artifactPaths) : []; + const artifactResults = selectedArtifacts.length && scannedRoot + ? await api.cleanDevArtifacts(scannedRoot, 30, selectedArtifacts) + : []; results = [...cacheResults, ...artifactResults]; selected = new Set(); selectedRules = new Set(); @@ -144,15 +152,21 @@
      {#each artifacts as a (a.path)}
    • -
    {/if} {/if} + + + diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 51a1438a5..cc8931a56 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -63,6 +63,21 @@ describe("api wrappers", () => { [() => api.setSettings(true), "set_settings", { onlineMode: true }], [() => api.reasonUnknownExtensions(["/a.abc"]), "reason_unknown_extensions", { samples: ["/a.abc"] }], [() => api.getUserRules(), "user_rules"], + [() => api.planBrewCleanup(), "plan_brew_cleanup"], + [() => api.judgeBrewCleanup(), "judge_brew_cleanup"], + [() => api.planOrphanCleanup(), "plan_orphan_cleanup"], + [() => api.judgeOrphanCleanup(), "judge_orphan_cleanup"], + [() => api.cleanOrphanCandidates("c".repeat(64), []), "clean_orphan_candidates", { planFingerprint: "c".repeat(64), requests: [] }], + [ + () => api.executeBrewCleanup("a".repeat(64), "b".repeat(64), "DiskSage Homebrew cleanup ์Šน์ธ", "reviewed dry-run"), + "execute_brew_cleanup", + { + planFingerprint: "a".repeat(64), + judgmentId: "b".repeat(64), + confirmationPhrase: "DiskSage Homebrew cleanup ์Šน์ธ", + rationale: "reviewed dry-run", + }, + ], [() => api.listCloudRoots(), "list_cloud_roots"], [() => api.listCloudProviderConnections(), "list_cloud_provider_connections"], [() => api.verifyCloudProviderCapacity("/cloud"), "verify_cloud_provider_capacity", { cloudRoot: "/cloud" }], diff --git a/src/lib/api.ts b/src/lib/api.ts index b1eff29bf..c3e3e509f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -106,6 +106,61 @@ export const cleanDevArtifacts = (root: string, minAgeDays: number, artifacts: D invoke("clean_dev_artifacts", { root, minAgeDays, artifacts }); export const cleanCacheCandidates = (requests: CacheCleanupRequest[]) => invoke("clean_cache_candidates", { requests }); +export interface OrphanRelation { + subject: string; + predicate: string; + object: string; + source: string; +} +export interface OrphanCandidate { + path: string; + kind: string; + bundle_id: string | null; + bytes: number; + files: number; + skipped: number; + scan_complete: boolean; + fingerprint: string; + ontology_class: string; + confidence: string; + relations: OrphanRelation[]; + review_reasons: string[]; + auto_trash_eligible: boolean; +} +export interface OrphanPlan { + schema_version: number; + root: string; + generated_at_ms: number; + plan_fingerprint: string; + candidate_bytes: number; + scan_complete: boolean; + candidates: OrphanCandidate[]; + notices: string[]; +} +export interface OrphanJudgment { + path: string; + plan_fingerprint: string; + verdict: Verdict; + reason: string; + model_name: string; + judged_at_ms: number; +} +export interface OrphanJudgmentReport { + plan_fingerprint: string; + judgments: OrphanJudgment[]; +} +export interface OrphanCleanupRequest { + path: string; + bytes: number; + files: number; + skipped: number; + scan_complete: boolean; + fingerprint: string; +} +export const planOrphanCleanup = () => invoke("plan_orphan_cleanup"); +export const judgeOrphanCleanup = () => invoke("judge_orphan_cleanup"); +export const cleanOrphanCandidates = (planFingerprint: string, requests: OrphanCleanupRequest[]) => + invoke("clean_orphan_candidates", { planFingerprint, requests }); export const expandCleanTargets = (dir: string) => invoke("expand_clean_targets", { dir }); export const recentOperations = (limit = 20) => @@ -138,8 +193,14 @@ export interface OntoClass { disjoints: string[]; target_folder: string | null; } +export interface OntologyRelation { + subject: string; + predicate: string; + object: string; +} export interface Ontology { classes: OntoClass[]; + relations: OntologyRelation[]; } export const diskInventory = (root: string) => @@ -193,6 +254,60 @@ export const fileVerdicts = (paths: string[]) => invoke("file_ver export const summarizeUnknownBucket = (paths: string[]) => invoke("summarize_unknown_bucket", { paths }); +export interface BrewCleanupPlan { + schema_version: number; + platform: "macos"; + brew_path: string; + brew_identity: string; + brew_version: string; + dry_run_output: string; + dry_run_output_truncated: boolean; + observed_at_ms: number; + plan_fingerprint: string; + exact_approval_phrase: string; +} + +export interface BrewCleanupJudgment { + schema_version: number; + plan: BrewCleanupPlan; + plan_fingerprint: string; + judgment_id: string; + verdict: Verdict; + reason: string; + model_name: string; + judged_at_ms: number; + exact_approval_phrase: string; +} + +export interface BrewCleanupExecution { + schema_version: number; + plan_fingerprint: string; + judgment_id: string; + command: string[]; + status_code: number; + stdout: string; + stderr: string; + output_truncated: boolean; + executed: boolean; + executed_at_ms: number; + record_path: string | null; + record_error: string | null; +} + +export const planBrewCleanup = () => invoke("plan_brew_cleanup"); +export const judgeBrewCleanup = () => invoke("judge_brew_cleanup"); +export const executeBrewCleanup = ( + planFingerprint: string, + judgmentId: string, + confirmationPhrase: string, + rationale: string, +) => invoke("execute_brew_cleanup", { + planFingerprint, + judgmentId, + confirmationPhrase, + rationale, +}); + export interface Settings { online_mode: boolean; } export const getSettings = () => invoke("get_settings"); export const setSettings = (online_mode: boolean) => invoke("set_settings", { onlineMode: online_mode }); diff --git a/src/lib/brewCleanupSafetyUiContract.test.ts b/src/lib/brewCleanupSafetyUiContract.test.ts new file mode 100644 index 000000000..96c9ba64b --- /dev/null +++ b/src/lib/brewCleanupSafetyUiContract.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("Homebrew cleanup safety UX", () => { + it("describes prune-prefix scope in the visible panel without claiming general old-file deletion", () => { + const source = readSource("src/lib/BrewCleanup.svelte"); + const panelStart = source.indexOf('
    '); + const panelEnd = source.indexOf(" + {/if} {#if !report.evidence_complete}

    Git ๋ชฉ๋ก timeout์œผ๋กœ ๊ด€๋ฆฌ์ž ๋“ฑ๋ก์„ ์ฝ๊ธฐ ์ „์šฉ fallback์œผ๋กœ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค. prune/remove๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

    {/if} + {#if pruneResult} +

    Git ๋“ฑ๋ก ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ {pruneResult.stale_before - pruneResult.stale_after}๊ฐœ๋ฅผ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. ํŒŒ์ผ์‹œ์Šคํ…œ ์‚ญ์ œ: ์—†์Œ.

    + {/if} {#if report.stale_count > 0}
      - {#each report.worktrees.filter((worktree) => worktree.metadata_prune_eligible) as worktree (worktree.path)} + {#each report.worktrees.filter((worktree) => worktree.prunable_reason !== null || !worktree.exists) as worktree (worktree.path)}
    • {worktree.path} {worktree.branch ?? (worktree.detached ? "detached" : "branch ๋ฏธํ™•์ธ")} {worktree.prunable_reason ?? "๊ฒฝ๋กœ ๋ถ€์žฌ"} +
    • {/each}
    @@ -73,6 +112,9 @@ .error { color: #b00; } .ok { color: #2a7; } .warning { color: #a65b00; } + .prune { margin: 0.5rem 0; } .stale-list { padding-left: 1.2rem; } .stale-list li { display: grid; gap: 0.15rem; margin: 0.5rem 0; } + .eligible { color: #2a7; } + .manual { color: #a65b00; } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 440c57150..15c87423f 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -31,6 +31,15 @@ describe("api wrappers", () => { [() => api.listDevArtifacts("/repo"), "list_dev_artifacts", { root: "/repo", minAgeDays: 30 }], [() => api.listDevArtifacts("/repo", 7), "list_dev_artifacts", { root: "/repo", minAgeDays: 7 }], [() => api.listStaleWorktrees("/repo"), "list_stale_worktrees", { repository: "/repo" }], + [ + () => api.pruneStaleWorktreeMetadata("/repo", "a".repeat(64), "DiskSage stale worktree metadata ์ •๋ฆฌ ์Šน์ธ"), + "prune_stale_worktree_metadata", + { + repository: "/repo", + registrationFingerprint: "a".repeat(64), + confirmation: "DiskSage stale worktree metadata ์ •๋ฆฌ ์Šน์ธ", + }, + ], [() => api.cleanPaths(["/tmp/a"]), "clean_paths", { paths: ["/tmp/a"] }], [ () => diff --git a/src/lib/api.ts b/src/lib/api.ts index c1654bc2f..59f339eff 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -78,6 +78,16 @@ export interface WorktreeAudit { metadata_prune_eligible_count: number; notices: string[]; } +export interface WorktreePruneResult { + repository: string; + before_registration_fingerprint: string; + after_registration_fingerprint: string; + stale_before: number; + stale_after: number; + metadata_pruned: boolean; + filesystem_mutation_executed: boolean; + notices: string[]; +} export interface CleanResult { path: string; ok: boolean; @@ -101,6 +111,15 @@ export const listDevArtifacts = (root: string, minAgeDays = 30) => invoke("list_dev_artifacts", { root, minAgeDays }); export const listStaleWorktrees = (repository: string) => invoke("list_stale_worktrees", { repository }); +export const pruneStaleWorktreeMetadata = ( + repository: string, + registrationFingerprint: string, + confirmation: string, +) => invoke("prune_stale_worktree_metadata", { + repository, + registrationFingerprint, + confirmation, +}); export const cleanPaths = (paths: string[]) => invoke("clean_paths", { paths }); export const cleanDevArtifacts = (root: string, minAgeDays: number, artifacts: DevArtifact[]) => invoke("clean_dev_artifacts", { root, minAgeDays, artifacts }); From ec26eed82a341f66a4181eb2236f376713151c4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:57:46 +0900 Subject: [PATCH 14/24] docs: clarify cloud eviction safety boundary --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00f126970..57f8e620c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ recreated, or incomplete candidate is rejected and must be refreshed. ## Safety first -Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cache and developer-artifact cleanup are bound to the exact candidate path, byte/file counts, age, and metadata fingerprint observed at review time; a changed or incomplete scan is rejected and must be refreshed. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable. +Every destructive action goes through explicit review and the OS trash โ€” DiskSage has **no permanent-delete code path**. Cache and developer-artifact cleanup are bound to the exact candidate path, byte/file counts, age, and metadata fingerprint observed at review time; a changed or incomplete scan is rejected and must be refreshed. Cloud archiving separates copy, provider evidence, and source eviction: only a fresh provider attestation can authorize the explicit OS-Trash step. All destructive operations are journaled and undoable. For a headless, read-only cache inventory, run `cargo run --locked --features cleanup-cli --bin disksage-clean-plan` (add `--id trivy-cache`, `--id pnpm-cache`, or `--id uv-cache` to inspect one candidate). The command prints the current metadata fingerprint; it never deletes files. From a925a413fdf3b373aa3b4090b936c78e1cbf76a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:05:09 +0900 Subject: [PATCH 15/24] feat: persist dynamic cloud goal snapshots --- .../adr/0001-cloud-offload-goal-state.md | 15 +- .../goals/cloud-offload-goal.json | 3 +- docs/cloud-offload-operator-runbook.md | 9 + src-tauri/src/cloud_adr.rs | 192 +++++++++++++++++- src-tauri/src/commands.rs | 30 +++ src/lib/CloudArchive.svelte | 6 +- src/lib/api.ts | 3 + 7 files changed, 248 insertions(+), 10 deletions(-) diff --git a/docs/architecture/adr/0001-cloud-offload-goal-state.md b/docs/architecture/adr/0001-cloud-offload-goal-state.md index 48ce677cd..6371aa292 100644 --- a/docs/architecture/adr/0001-cloud-offload-goal-state.md +++ b/docs/architecture/adr/0001-cloud-offload-goal-state.md @@ -25,11 +25,13 @@ Rust command output and the UI: `copy-verified โ†’ pending-provider-sync โ†’ provider-sync-confirmed โ†’ eviction-ready โ†’ source-evicted`. -After each attestation, DiskSage atomically updates a per-receipt, -machine-readable ADR snapshot at the app-data `cloud-adr` directory. The -snapshot contains only identifiers, state, decision, consequences, and the -evidence record ID; the immutable provider evidence remains the authority for -content hashes and timestamps. `eviction-ready` never deletes the source. +After each attestation, DiskSage atomically updates per-receipt, +machine-readable snapshots at the app-data `cloud-adr` and `cloud-goals` +directories. The ADR contains identifiers, state, decision, consequences, and +the evidence record ID. The Goal snapshot additionally records the current +completion-gate booleans and safety invariant. The immutable provider evidence +remains the authority for content hashes and timestamps. `eviction-ready` +never deletes the source. ## Consequences @@ -39,6 +41,8 @@ content hashes and timestamps. `eviction-ready` never deletes the source. operation. - ADR and Goal state are auditable from the same evidence record and cannot be silently edited in place by the provider check. +- A stale Goal file is replaceable projection data; reconciliation must compare + it with the immutable evidence record before acting. - A separate explicit trash operation is still required after an eviction permit; it is not automatic. - The source-eviction command moves the source to the OS Trash only after a @@ -48,4 +52,5 @@ content hashes and timestamps. `eviction-ready` never deletes the source. - `src-tauri/src/cloud_transfer.rs` (`ProviderSyncState`, `CloudOffloadGoalState`) - `src-tauri/src/cloud_adr.rs` (dynamic ADR snapshot writer) +- `src-tauri/src/cloud_adr.rs` (dynamic Goal snapshot writer) - `src-tauri/src/provider_sync.rs` (iCloud/File Provider/API classification) diff --git a/docs/architecture/goals/cloud-offload-goal.json b/docs/architecture/goals/cloud-offload-goal.json index 1034675d2..61b725450 100644 --- a/docs/architecture/goals/cloud-offload-goal.json +++ b/docs/architecture/goals/cloud-offload-goal.json @@ -1,7 +1,8 @@ { "goal_id": "disksage-cloud-offload", "status": "active", - "state_source": "runtime:cloud-adr/-latest.json", + "state_source": "runtime:cloud-goals/-latest.json", + "adr_source": "runtime:cloud-adr/-latest.json", "states": [ "copy-verified", "pending-provider-sync", diff --git a/docs/cloud-offload-operator-runbook.md b/docs/cloud-offload-operator-runbook.md index 6c6519260..bd6d9124f 100644 --- a/docs/cloud-offload-operator-runbook.md +++ b/docs/cloud-offload-operator-runbook.md @@ -60,6 +60,15 @@ destination, bytes, digest, ์œ„์น˜์™€ ์ผ์น˜ํ•˜๊ณ  `sync_complete`์ธ ๊ฒฝ์šฐ์— permit์ด ์ƒ์„ฑ๋œ๋‹ค. permit ์—†์ด ์›๋ณธ์„ Trash๋กœ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค. ํšŒ์ˆ˜ ์ „์—๋Š” source metadata์™€ content digest๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•˜๊ณ , ์‹คํŒจํ•˜๋ฉด staging์„ ๋ณต๊ตฌํ•œ๋‹ค. +๋ณต์‚ฌ ์งํ›„์™€ ๊ฐ attestationยทํœด์ง€ํ†ต ์ด๋™ ๋’ค์—๋Š” app-data์˜ +`cloud-goals/-latest.json`์„ ์›์ž์ ์œผ๋กœ ๊ฐฑ์‹ ํ•˜๊ณ , attestation ์ดํ›„์—๋Š” +`cloud-adr/-latest.json`๋„ ๊ฐฑ์‹ ํ•œ๋‹ค. ADR์€ ๊ฒฐ์ •ยท๊ฒฐ๊ณผ๋ฅผ, Goal์€ +ํ˜„์žฌ ์ƒํƒœ์™€ completion gate๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๊ต์ฒด ๊ฐ€๋Šฅํ•œ projection์ด๋‹ค. ๋ณต์‚ฌ ์งํ›„์—๋Š” +provider/evidence gate๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ false๋‹ค. `pending-upload`๋‚˜ +`is_local_current=true`/`is_uploaded=false`๋Š” Goal์„ `pending-provider-sync`๋กœ ์œ ์ง€ํ•˜๋ฉฐ +eviction permit์„ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค. ์šด์˜ ๋„๊ตฌ๋Š” Goal ํŒŒ์ผ์„ ๊ถŒํ•œ ์ฆ๊ฑฐ๋กœ ์‚ฌ์šฉํ•˜์ง€ ๋ง๊ณ , +ํ•ญ์ƒ immutable receipt/provider evidence๋ฅผ ์žฌ๊ฒ€์ฆํ•ด์•ผ ํ•œ๋‹ค. + ## 5. ์Šน์ธ ๋ฌธ๊ตฌ์˜ ๋ฒ”์œ„ `์Šน์ธ`, `๋„ค` ๊ฐ™์€ ์ผ๋ฐ˜ ๋™์˜๋Š” ํ˜„์žฌ ํ›„๋ณด์— ๊ฒฐ๋ฐ•๋˜์ง€ ์•Š๋Š”๋‹ค. ์‹คํ–‰ ์ง์ „์— DiskSage๊ฐ€ diff --git a/src-tauri/src/cloud_adr.rs b/src-tauri/src/cloud_adr.rs index c00aadf80..78af621d3 100644 --- a/src-tauri/src/cloud_adr.rs +++ b/src-tauri/src/cloud_adr.rs @@ -1,15 +1,17 @@ //! Dynamic, machine-readable ADR state for one cloud offload goal. //! //! The Markdown ADR documents the policy. This latest snapshot records the decision made by the -//! running application after each provider attestation, so the goal and its evidence cannot drift +//! running application after copy/attestation/eviction, so the goal and its evidence cannot drift //! silently between an operator view and the persisted receipt. -use crate::cloud_transfer::{CloudOffloadGoalState, ProviderSyncState}; +use crate::cloud_transfer::{CloudCopyReceipt, CloudOffloadGoalState, ProviderSyncState}; use crate::provider_evidence::ProviderSyncEvidenceRecord; +use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; pub const CLOUD_ADR_SCHEMA_VERSION: u32 = 1; +pub const CLOUD_GOAL_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -26,6 +28,25 @@ pub struct CloudOffloadAdrSnapshot { pub updated_at_ms: u64, } +/// Runtime Goal projection written beside the ADR snapshot. +/// +/// The immutable provider evidence and receipt remain the authorities. This file is a +/// replaceable, machine-readable view for UI, agents, and reconciliation jobs. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CloudOffloadGoalSnapshot { + pub schema_version: u32, + pub goal_id: String, + pub status: String, + pub receipt_id: String, + pub goal_state: CloudOffloadGoalState, + pub provider_sync_state: ProviderSyncState, + pub completion_gates: BTreeMap, + pub safety_invariant: String, + pub evidence_record_id: Option, + pub updated_at_ms: u64, +} + fn decision_for(goal_state: CloudOffloadGoalState, sync_state: ProviderSyncState) -> String { match goal_state { CloudOffloadGoalState::CopyVerified => "retain-source-after-copy".into(), @@ -74,6 +95,83 @@ pub fn snapshot_from_evidence( } } +/// Build the runtime Goal from the immutable receipt and the same evidence used by the eviction +/// gate. A malformed record never presents as a satisfied completion gate. +pub fn goal_snapshot_from_evidence( + receipt: &CloudCopyReceipt, + record: &ProviderSyncEvidenceRecord, + goal_state: CloudOffloadGoalState, + updated_at_ms: u64, +) -> CloudOffloadGoalSnapshot { + let evidence = &record.evidence; + let evidence_valid = crate::provider_evidence::validate_sync_evidence_record(record).is_ok(); + let content_verified = receipt.copy_verified + && receipt.bytes == evidence.observed_bytes + && receipt.blake3 == evidence.destination_blake3; + let lineage_bound = receipt.lineage.is_some() && receipt.lineage_fingerprint.is_some(); + let provider_sync_complete = evidence_valid + && evidence.sync_complete + && evidence.sync_state == ProviderSyncState::Complete; + let eviction_permit = matches!( + goal_state, + CloudOffloadGoalState::EvictionReady | CloudOffloadGoalState::SourceEvicted + ); + let mut completion_gates = BTreeMap::new(); + completion_gates.insert("metadata-and-lineage-bound".into(), lineage_bound); + completion_gates.insert("copy-content-verified".into(), content_verified); + completion_gates.insert( + "provider-sync-state-complete".into(), + provider_sync_complete, + ); + completion_gates.insert("immutable-evidence-record-valid".into(), evidence_valid); + completion_gates.insert("explicit-eviction-permit".into(), eviction_permit); + CloudOffloadGoalSnapshot { + schema_version: CLOUD_GOAL_SCHEMA_VERSION, + goal_id: "disksage-cloud-offload".into(), + status: if goal_state == CloudOffloadGoalState::SourceEvicted { + "completed".into() + } else { + "active".into() + }, + receipt_id: receipt.receipt_id.clone(), + goal_state, + provider_sync_state: evidence.sync_state, + completion_gates, + safety_invariant: "source-retained-until-an-explicit-trash-step".into(), + evidence_record_id: Some(record.record_id.clone()), + updated_at_ms, + } +} + +/// Build the initial Goal projection immediately after a verified copy, before provider evidence +/// exists. The missing evidence gate is explicit rather than represented by a fabricated record. +pub fn initial_goal_snapshot( + receipt: &CloudCopyReceipt, + updated_at_ms: u64, +) -> CloudOffloadGoalSnapshot { + let mut completion_gates = BTreeMap::new(); + completion_gates.insert( + "metadata-and-lineage-bound".into(), + receipt.lineage.is_some() && receipt.lineage_fingerprint.is_some(), + ); + completion_gates.insert("copy-content-verified".into(), receipt.copy_verified); + completion_gates.insert("provider-sync-state-complete".into(), false); + completion_gates.insert("immutable-evidence-record-valid".into(), false); + completion_gates.insert("explicit-eviction-permit".into(), false); + CloudOffloadGoalSnapshot { + schema_version: CLOUD_GOAL_SCHEMA_VERSION, + goal_id: "disksage-cloud-offload".into(), + status: "active".into(), + receipt_id: receipt.receipt_id.clone(), + goal_state: CloudOffloadGoalState::CopyVerified, + provider_sync_state: ProviderSyncState::Unknown, + completion_gates, + safety_invariant: "source-retained-until-an-explicit-trash-step".into(), + evidence_record_id: None, + updated_at_ms, + } +} + fn secure_directory(directory: &Path) -> Result<(), String> { std::fs::create_dir_all(directory) .map_err(|_| "cloud-adr-directory-create-failed".to_string())?; @@ -118,6 +216,38 @@ pub fn write_latest_snapshot( Ok(path) } +/// Atomically replace the latest Goal snapshot for a receipt. +pub fn write_latest_goal_snapshot( + directory: &Path, + snapshot: &CloudOffloadGoalSnapshot, +) -> Result { + secure_directory(directory)?; + let path = directory.join(format!("{}-latest.json", snapshot.receipt_id)); + let temporary = directory.join(format!( + ".{}-{}-{}-latest.json.tmp", + snapshot.receipt_id, + snapshot.updated_at_ms, + std::process::id() + )); + let encoded = + serde_json::to_vec_pretty(snapshot).map_err(|_| "cloud-goal-json-invalid".to_string())?; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| "cloud-goal-temp-create-failed".to_string())?; + file.write_all(&encoded) + .map_err(|_| "cloud-goal-write-failed".to_string())?; + file.sync_all() + .map_err(|_| "cloud-goal-sync-failed".to_string())?; + drop(file); + if std::fs::rename(&temporary, &path).is_err() { + let _ = std::fs::remove_file(&temporary); + return Err("cloud-goal-rename-failed".into()); + } + Ok(path) +} + #[cfg(test)] mod tests { use super::*; @@ -202,4 +332,62 @@ mod tests { )) .exists()); } + + #[test] + fn goal_snapshot_is_derived_from_receipt_and_provider_evidence() { + let record = ProviderSyncEvidenceRecord { + version: 1, + record_id: "a".repeat(64), + evidence: crate::cloud_transfer::ProviderSyncEvidence { + receipt_id: "b".repeat(64), + provider: crate::cloud::CloudProvider::Icloud, + destination: "/cloud/file.bin".into(), + observed_bytes: 1, + destination_blake3: "c".repeat(64), + confirmed_at_ms: 2, + kind: crate::cloud_transfer::SyncEvidenceKind::ProviderNativeStatus, + evidence_id: "foundation:test".into(), + sync_complete: false, + sync_state: ProviderSyncState::PendingUpload, + remote_content: None, + }, + }; + let receipt = CloudCopyReceipt { + version: crate::cloud_transfer::RECEIPT_VERSION, + receipt_id: "b".repeat(64), + candidate_fingerprint: "d".repeat(64), + provider: crate::cloud::CloudProvider::Icloud, + source: "/source/file.bin".into(), + destination: "/cloud/file.bin".into(), + bytes: 1, + blake3: "c".repeat(64), + sha256: "e".repeat(64), + quick_xor_base64: "".into(), + source_modified_ms: 1, + copied_at_ms: 1, + copy_verified: true, + provider_sync_confirmed: false, + lineage_fingerprint: None, + lineage: None, + }; + let snapshot = goal_snapshot_from_evidence( + &receipt, + &record, + CloudOffloadGoalState::PendingProviderSync, + 3, + ); + assert_eq!(snapshot.status, "active"); + assert!(snapshot.completion_gates["copy-content-verified"]); + assert!(!snapshot.completion_gates["provider-sync-state-complete"]); + assert!(!snapshot.completion_gates["explicit-eviction-permit"]); + let directory = tempfile::tempdir().unwrap(); + let path = write_latest_goal_snapshot(directory.path(), &snapshot).unwrap(); + let encoded = std::fs::read(path).unwrap(); + let persisted: CloudOffloadGoalSnapshot = serde_json::from_slice(&encoded).unwrap(); + assert_eq!( + persisted.goal_state, + CloudOffloadGoalState::PendingProviderSync + ); + assert_eq!(persisted.evidence_record_id, Some(record.record_id)); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index aafc33eb7..b00d2ed3f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1286,6 +1286,7 @@ pub struct CloudCopyOutput { pub goal_state: cloud_transfer::CloudOffloadGoalState, pub receipt: cloud_transfer::CloudCopyReceipt, pub receipt_path: String, + pub goal_path: String, } #[cfg(not(coverage))] @@ -1354,6 +1355,14 @@ fn create_cloud_candidate_receipt( capacity.as_ref(), )? }; + let goal = cloud_adr::initial_goal_snapshot(&receipt, cloud::system_now_ms()); + let goal_path = cloud_adr::write_latest_goal_snapshot( + &app.path() + .app_data_dir() + .map_err(|_| "app-data-directory-unavailable".to_string())? + .join("cloud-goals"), + &goal, + )?; Ok(CloudCopyOutput { action: if adopt_existing { "adopt-existing-copy" @@ -1363,6 +1372,7 @@ fn create_cloud_candidate_receipt( goal_state: cloud_transfer::CloudOffloadGoalState::CopyVerified, receipt, receipt_path: receipt_path.to_string_lossy().into_owned(), + goal_path: goal_path.to_string_lossy().into_owned(), }) } @@ -1434,6 +1444,7 @@ pub struct CloudAttestationOutput { pub evidence_record: provider_evidence::ProviderSyncEvidenceRecord, pub evidence_path: String, pub adr_path: String, + pub goal_path: String, pub permit: Option, pub blockers: Vec, } @@ -1444,6 +1455,7 @@ pub struct CloudEvictionOutput { pub goal_state: cloud_transfer::CloudOffloadGoalState, pub eviction: cloud_eviction::CloudEvictionResult, pub adr_path: String, + pub goal_path: String, } /// Read-only provider attestation. OneDrive and Google Drive access tokens are refreshed from an OS @@ -1468,6 +1480,7 @@ pub async fn attest_cloud_copy( .join(format!("{receipt_id}.json")); let evidence_dir = app_data_dir.join("cloud-provider-evidence"); let adr_dir = app_data_dir.join("cloud-adr"); + let goal_dir = app_data_dir.join("cloud-goals"); let connection_path = oauth_connections_path(&app)?; let cloud_roots = cloud::discover_cloud_roots(&resolve_home(&app)); tauri::async_runtime::spawn_blocking(move || { @@ -1567,12 +1580,20 @@ pub async fn attest_cloud_copy( confirmed_at_ms, ); let adr_path = cloud_adr::write_latest_snapshot(&adr_dir, &adr)?; + let goal = cloud_adr::goal_snapshot_from_evidence( + &receipt, + &evidence_record, + goal_state, + confirmed_at_ms, + ); + let goal_path = cloud_adr::write_latest_goal_snapshot(&goal_dir, &goal)?; Ok(CloudAttestationOutput { goal_state, evidence, evidence_record, evidence_path: evidence_path.to_string_lossy().into_owned(), adr_path: adr_path.to_string_lossy().into_owned(), + goal_path: goal_path.to_string_lossy().into_owned(), permit, blockers, }) @@ -1621,10 +1642,19 @@ pub async fn evict_cloud_source( cloud::system_now_ms(), ); let adr_path = cloud_adr::write_latest_snapshot(&app_data_dir.join("cloud-adr"), &adr)?; + let goal = cloud_adr::goal_snapshot_from_evidence( + &receipt, + &attestation.evidence_record, + cloud_transfer::CloudOffloadGoalState::SourceEvicted, + cloud::system_now_ms(), + ); + let goal_path = + cloud_adr::write_latest_goal_snapshot(&app_data_dir.join("cloud-goals"), &goal)?; Ok(CloudEvictionOutput { goal_state: cloud_transfer::CloudOffloadGoalState::SourceEvicted, eviction, adr_path: adr_path.to_string_lossy().into_owned(), + goal_path: goal_path.to_string_lossy().into_owned(), }) } diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 5bebc6c74..9a9599c61 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -612,6 +612,7 @@ {copied.action === "adopt-existing-copy" ? "๊ธฐ์กด ํด๋ผ์šฐ๋“œ ๋ณต์‚ฌ๋ณธ ๊ฒ€์ฆยท์ฑ„ํƒ ์™„๋ฃŒ" : "๊ฒ€์ฆ ๋ณต์‚ฌ ์™„๋ฃŒ"} ยท ์›๋ณธ ๋ณด์กด๋จ
    ์˜์ˆ˜์ฆ {copied.receipt.receipt_id} ยท {fmtBytes(copied.receipt.bytes)}
    {copied.receipt.destination}
    +

    ๋™์  Goal: {copied.goal_path} ยท ๊ณต๊ธ‰์ž ์ฆ๊ฑฐ ๋Œ€๊ธฐ ์ค‘

    {#if copied.receipt.provider === "google-drive"}
    diff --git a/src/lib/api.ts b/src/lib/api.ts index 59f339eff..87cb14ab2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -584,6 +584,7 @@ export interface CloudCopyOutput { goal_state: CloudOffloadGoalState; receipt: CloudCopyReceipt; receipt_path: string; + goal_path: string; } export type SyncEvidenceKind = "provider-api" | "provider-native-status"; @@ -655,6 +656,7 @@ export interface CloudAttestationOutput { evidence_record: ProviderSyncEvidenceRecord; evidence_path: string; adr_path: string; + goal_path: string; permit: LocalEvictionPermit | null; blockers: string[]; } @@ -679,6 +681,7 @@ export interface CloudEvictionOutput { goal_state: "source-evicted"; eviction: CloudEvictionResult; adr_path: string; + goal_path: string; } export const listCloudRoots = () => invoke("list_cloud_roots"); From 2010244f403de00bb04ceec50d0b5c8485bbdbd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:58:42 +0900 Subject: [PATCH 16/24] test: reject unsafe cloud ADR receipt identifiers --- .../tests/cloud_adr_receipt_id_contract.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src-tauri/tests/cloud_adr_receipt_id_contract.rs diff --git a/src-tauri/tests/cloud_adr_receipt_id_contract.rs b/src-tauri/tests/cloud_adr_receipt_id_contract.rs new file mode 100644 index 000000000..7269580f5 --- /dev/null +++ b/src-tauri/tests/cloud_adr_receipt_id_contract.rs @@ -0,0 +1,72 @@ +use disksage_lib::cloud_adr::{ + write_latest_goal_snapshot, write_latest_snapshot, CloudOffloadAdrSnapshot, + CloudOffloadGoalSnapshot, CLOUD_ADR_SCHEMA_VERSION, CLOUD_GOAL_SCHEMA_VERSION, +}; +use disksage_lib::cloud_transfer::{CloudOffloadGoalState, ProviderSyncState}; +use std::collections::BTreeMap; + +fn invalid_adr_snapshot(receipt_id: &str) -> CloudOffloadAdrSnapshot { + CloudOffloadAdrSnapshot { + schema_version: CLOUD_ADR_SCHEMA_VERSION, + adr_id: "cloud-offload:test".into(), + receipt_id: receipt_id.into(), + goal_state: CloudOffloadGoalState::CopyVerified, + provider_sync_state: ProviderSyncState::Unknown, + sync_complete: false, + decision: "retain-source-after-copy".into(), + consequences: vec!["source-retained".into()], + evidence_record_id: "b".repeat(64), + updated_at_ms: 1, + } +} + +fn invalid_goal_snapshot(receipt_id: &str) -> CloudOffloadGoalSnapshot { + CloudOffloadGoalSnapshot { + schema_version: CLOUD_GOAL_SCHEMA_VERSION, + goal_id: "disksage-cloud-offload".into(), + status: "active".into(), + receipt_id: receipt_id.into(), + goal_state: CloudOffloadGoalState::CopyVerified, + provider_sync_state: ProviderSyncState::Unknown, + completion_gates: BTreeMap::new(), + safety_invariant: "source-retained-until-an-explicit-trash-step".into(), + evidence_record_id: None, + updated_at_ms: 1, + } +} + +#[test] +fn latest_adr_snapshot_rejects_non_hex_receipt_id_before_path_construction() { + let directory = tempfile::tempdir().expect("temporary ADR directory"); + let snapshot = invalid_adr_snapshot("../escape"); + + let error = write_latest_snapshot(directory.path(), &snapshot) + .expect_err("path-shaped receipt identifiers must fail closed"); + + assert_eq!(error, "cloud-adr-receipt-id-invalid"); + assert_eq!( + std::fs::read_dir(directory.path()) + .expect("read ADR directory") + .count(), + 0, + "invalid receipt identifiers must not create temporary or final files" + ); +} + +#[test] +fn latest_goal_snapshot_rejects_non_hex_receipt_id_before_path_construction() { + let directory = tempfile::tempdir().expect("temporary Goal directory"); + let snapshot = invalid_goal_snapshot("not-a-64-character-hex-receipt-id"); + + let error = write_latest_goal_snapshot(directory.path(), &snapshot) + .expect_err("untrusted receipt identifiers must fail closed"); + + assert_eq!(error, "cloud-goal-receipt-id-invalid"); + assert_eq!( + std::fs::read_dir(directory.path()) + .expect("read Goal directory") + .count(), + 0, + "invalid receipt identifiers must not create temporary or final files" + ); +} From 7babed8f34daf408edf5514ed098598205c08a61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:00:34 +0900 Subject: [PATCH 17/24] fix: validate cloud ADR receipt identifiers --- src-tauri/src/cloud_adr.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src-tauri/src/cloud_adr.rs b/src-tauri/src/cloud_adr.rs index 78af621d3..def1d29ec 100644 --- a/src-tauri/src/cloud_adr.rs +++ b/src-tauri/src/cloud_adr.rs @@ -47,6 +47,10 @@ pub struct CloudOffloadGoalSnapshot { pub updated_at_ms: u64, } +fn valid_receipt_id(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn decision_for(goal_state: CloudOffloadGoalState, sync_state: ProviderSyncState) -> String { match goal_state { CloudOffloadGoalState::CopyVerified => "retain-source-after-copy".into(), @@ -189,6 +193,9 @@ pub fn write_latest_snapshot( directory: &Path, snapshot: &CloudOffloadAdrSnapshot, ) -> Result { + if !valid_receipt_id(&snapshot.receipt_id) { + return Err("cloud-adr-receipt-id-invalid".into()); + } secure_directory(directory)?; let path = directory.join(format!("{}-latest.json", snapshot.receipt_id)); let temporary = directory.join(format!( @@ -221,6 +228,9 @@ pub fn write_latest_goal_snapshot( directory: &Path, snapshot: &CloudOffloadGoalSnapshot, ) -> Result { + if !valid_receipt_id(&snapshot.receipt_id) { + return Err("cloud-goal-receipt-id-invalid".into()); + } secure_directory(directory)?; let path = directory.join(format!("{}-latest.json", snapshot.receipt_id)); let temporary = directory.join(format!( From 39a0d5b8c265cc4252caaee81bb029b5042aa9ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:01:44 +0900 Subject: [PATCH 18/24] test: bind orphan relation display by predicate --- src/lib/orphanRelation.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/lib/orphanRelation.test.ts diff --git a/src/lib/orphanRelation.test.ts b/src/lib/orphanRelation.test.ts new file mode 100644 index 000000000..25a18cedb --- /dev/null +++ b/src/lib/orphanRelation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { OrphanRelation } from "./api"; +import { locatedInRelation } from "./orphanRelation"; + +const relation = (predicate: string, object: string): OrphanRelation => ({ + subject: "urn:disk-sage:candidate", + predicate, + object, + source: "disk-sage:test", +}); + +describe("locatedInRelation", () => { + it("selects location semantics independently of relation ordering", () => { + const managedBy = relation("https://disksage.app/ontology#managedBy", "urn:app:example"); + const locatedIn = relation("https://disksage.app/ontology#locatedIn", "/Users/example/Library/Caches"); + + expect(locatedInRelation([locatedIn, managedBy])).toEqual(locatedIn); + expect(locatedInRelation([managedBy, locatedIn])).toEqual(locatedIn); + }); + + it("returns null when the candidate has no location relation", () => { + expect( + locatedInRelation([ + relation("https://disksage.app/ontology#managedBy", "urn:app:example"), + ]), + ).toBeNull(); + }); +}); From 10e687b95d19ed050720b55857ace3e83d8a7ac8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:01:50 +0900 Subject: [PATCH 19/24] feat: select orphan location relation by predicate --- src/lib/orphanRelation.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/lib/orphanRelation.ts diff --git a/src/lib/orphanRelation.ts b/src/lib/orphanRelation.ts new file mode 100644 index 000000000..8dff9b306 --- /dev/null +++ b/src/lib/orphanRelation.ts @@ -0,0 +1,6 @@ +import type { OrphanRelation } from "./api"; + +/** Return the candidate location relation without depending on relation-array ordering. */ +export function locatedInRelation(relations: OrphanRelation[]): OrphanRelation | null { + return relations.find((relation) => relation.predicate.endsWith("locatedIn")) ?? null; +} From bd8252f6065313a98ec4695f9a69828c38398bd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:02:18 +0900 Subject: [PATCH 20/24] fix: render orphan location relation semantically --- src/lib/OrphanCleanup.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/OrphanCleanup.svelte b/src/lib/OrphanCleanup.svelte index b941703d1..3c52f8e08 100644 --- a/src/lib/OrphanCleanup.svelte +++ b/src/lib/OrphanCleanup.svelte @@ -3,6 +3,7 @@ import { confirm } from "@tauri-apps/plugin-dialog"; import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; + import { locatedInRelation } from "./orphanRelation"; let plan: api.OrphanPlan | null = $state(null); let selected: Set = $state(new Set()); @@ -94,6 +95,7 @@ {#if plan.notices.length}
      {#each plan.notices as notice}
    • {notice}
    • {/each}
    {/if}
      {#each plan.candidates as candidate (candidate.path)} + {@const located = locatedInRelation(candidate.relations)}
    • {/each} @@ -135,4 +137,4 @@ .error { color: #b00; } .notices { color: #666; font-size: .8rem; } .badge-safe, .badge-caution, .badge-keep, .badge-unrated { margin-left: .3rem; } - + \ No newline at end of file From 1d397b9057b560ee982fdfa66669240bb8f01710 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:03:35 +0900 Subject: [PATCH 21/24] test: define fail-closed worktree CLI argument contract --- .../src/bin/disksage-git-worktree-audit.rs | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/bin/disksage-git-worktree-audit.rs b/src-tauri/src/bin/disksage-git-worktree-audit.rs index 81aba3d7d..bf6523d8a 100644 --- a/src-tauri/src/bin/disksage-git-worktree-audit.rs +++ b/src-tauri/src/bin/disksage-git-worktree-audit.rs @@ -59,17 +59,55 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + + #[test] + fn parser_distinguishes_help_from_invalid_input() { + assert_eq!(parse_args(&[]).unwrap(), ParseOutcome::Run(Args::default())); + assert_eq!( + parse_args(&[OsString::from("--help")]).unwrap(), + ParseOutcome::Help + ); + assert_eq!( + parse_args(&[OsString::from("-h")]).unwrap(), + ParseOutcome::Help + ); + assert_eq!( + parse_args(&[OsString::from("--unknown")]).unwrap_err(), + "์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž" + ); + } #[test] fn parser_accepts_optional_repository() { - assert_eq!(parse_args(&[]).unwrap(), Args::default()); assert_eq!( - parse_args(&["--repo".into(), "/repo".into()]).unwrap(), - Args { + parse_args(&[OsString::from("--repo"), OsString::from("/repo")]).unwrap(), + ParseOutcome::Run(Args { repository: Some(PathBuf::from("/repo")) - } + }) + ); + assert_eq!( + parse_args(&[OsString::from("--repo")]).unwrap_err(), + "--repo ๊ฐ’์ด ํ•„์š”ํ•จ" + ); + } + + #[cfg(unix)] + #[test] + fn parser_preserves_non_utf8_repository_paths_and_redacts_unknown_arguments() { + use std::os::unix::ffi::OsStringExt; + + let repository = OsString::from_vec(vec![b'/', b'r', b'e', b'p', b'o', 0xff]); + assert_eq!( + parse_args(&[OsString::from("--repo"), repository.clone()]).unwrap(), + ParseOutcome::Run(Args { + repository: Some(PathBuf::from(repository)) + }) + ); + let unknown = OsString::from_vec(vec![b'-', b'-', 0xff]); + assert_eq!( + parse_args(&[unknown]).unwrap_err(), + "์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž" ); - assert!(parse_args(&["--repo".into()]).is_err()); - assert!(parse_args(&["--unknown".into()]).is_err()); } } From 0179881522c3ee89c9da777a362d8a7728bd19ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:04:04 +0900 Subject: [PATCH 22/24] fix: make worktree CLI argument handling fail closed --- .../src/bin/disksage-git-worktree-audit.rs | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/bin/disksage-git-worktree-audit.rs b/src-tauri/src/bin/disksage-git-worktree-audit.rs index bf6523d8a..6af702f1c 100644 --- a/src-tauri/src/bin/disksage-git-worktree-audit.rs +++ b/src-tauri/src/bin/disksage-git-worktree-audit.rs @@ -1,46 +1,65 @@ //! Read-only Git worktree audit. No prune/remove operation is exposed. +use std::ffi::{OsStr, OsString}; use std::path::PathBuf; +const USAGE: &str = "usage: disksage-git-worktree-audit [--repo PATH]"; + #[derive(Debug, Default, PartialEq, Eq)] struct Args { repository: Option, } -fn parse_args(args: &[String]) -> Result { +#[derive(Debug, PartialEq, Eq)] +enum ParseOutcome { + Run(Args), + Help, +} + +fn parse_args(args: &[OsString]) -> Result { let mut parsed = Args::default(); let mut index = 0usize; while index < args.len() { - match args[index].as_str() { - "--repo" => { - index += 1; - parsed.repository = Some(PathBuf::from( - args.get(index) - .ok_or_else(|| "--repo ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?, - )); - } - "--help" | "-h" => { - return Err("usage: disksage-git-worktree-audit [--repo PATH]".into()); - } - unknown => return Err(format!("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: {unknown}")), + let argument = args[index].as_os_str(); + if argument == OsStr::new("--repo") { + index += 1; + parsed.repository = Some(PathBuf::from( + args.get(index) + .ok_or_else(|| "--repo ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?, + )); + } else if argument == OsStr::new("--help") || argument == OsStr::new("-h") { + return Ok(ParseOutcome::Help); + } else { + return Err("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž".into()); } index += 1; } - Ok(parsed) + Ok(ParseOutcome::Run(parsed)) } fn main() { - let raw: Vec = std::env::args().skip(1).collect(); + let raw: Vec = std::env::args_os().skip(1).collect(); let args = match parse_args(&raw) { - Ok(args) => args, + Ok(ParseOutcome::Run(args)) => args, + Ok(ParseOutcome::Help) => { + println!("{USAGE}"); + return; + } Err(error) => { eprintln!("{error}"); std::process::exit(2); } }; - let repository = args - .repository - .unwrap_or_else(|| std::env::current_dir().expect("ํ˜„์žฌ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + let repository = match args.repository { + Some(repository) => repository, + None => match std::env::current_dir() { + Ok(repository) => repository, + Err(_) => { + eprintln!("ํ˜„์žฌ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + std::process::exit(2); + } + }, + }; let report = match disksage_lib::worktrees::audit(&repository, disksage_lib::worktrees::system_now_ms()) { @@ -59,7 +78,6 @@ fn main() { #[cfg(test)] mod tests { use super::*; - use std::ffi::OsString; #[test] fn parser_distinguishes_help_from_invalid_input() { From e9aa5da5e97fb5862fb9a9cb65c1c95214b9d029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:06:13 +0900 Subject: [PATCH 23/24] test: define successful clean-plan help contract --- src-tauri/src/bin/disksage-clean-plan.rs | 51 ++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/bin/disksage-clean-plan.rs b/src-tauri/src/bin/disksage-clean-plan.rs index 8c4075795..847434ed6 100644 --- a/src-tauri/src/bin/disksage-clean-plan.rs +++ b/src-tauri/src/bin/disksage-clean-plan.rs @@ -77,17 +77,54 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; #[test] - fn parser_accepts_optional_id() { - assert_eq!(parse_args(&[]).unwrap(), Args::default()); + fn parser_distinguishes_help_from_invalid_input() { + assert_eq!(parse_args(&[]).unwrap(), ParseOutcome::Run(Args::default())); assert_eq!( - parse_args(&["--id".into(), "trivy-cache".into()]).unwrap(), - Args { + parse_args(&[OsString::from("--help")]).unwrap(), + ParseOutcome::Help + ); + assert_eq!( + parse_args(&[OsString::from("-h")]).unwrap(), + ParseOutcome::Help + ); + assert_eq!( + parse_args(&[OsString::from("--nope")]).unwrap_err(), + "์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž" + ); + } + + #[test] + fn parser_accepts_optional_utf8_cache_id() { + assert_eq!( + parse_args(&[OsString::from("--id"), OsString::from("trivy-cache")]).unwrap(), + ParseOutcome::Run(Args { id: Some("trivy-cache".into()) - } + }) + ); + assert_eq!( + parse_args(&[OsString::from("--id")]).unwrap_err(), + "--id ๊ฐ’์ด ํ•„์š”ํ•จ" + ); + assert_eq!( + parse_args(&[OsString::from("--id"), OsString::from("")]).unwrap_err(), + "--id ๊ฐ’์ด ๋น„์–ด ์žˆ์Œ" + ); + } + + #[cfg(unix)] + #[test] + fn parser_rejects_non_utf8_cache_id_and_redacts_non_utf8_unknown_argument() { + use std::os::unix::ffi::OsStringExt; + + let non_utf8 = OsString::from_vec(vec![0xff]); + assert_eq!( + parse_args(&[OsString::from("--id"), non_utf8]).unwrap_err(), + "--id ๊ฐ’์€ UTF-8์ด์–ด์•ผ ํ•จ" ); - assert!(parse_args(&["--id".into()]).is_err()); - assert!(parse_args(&["--nope".into()]).is_err()); + let unknown = OsString::from_vec(vec![b'-', b'-', 0xff]); + assert_eq!(parse_args(&[unknown]).unwrap_err(), "์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž"); } } From c35e0252a1a960af9e49e0c7a3f2c9faa3245327 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:06:48 +0900 Subject: [PATCH 24/24] fix: make clean-plan help a successful bounded outcome --- src-tauri/src/bin/disksage-clean-plan.rs | 67 +++++++++++++++--------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/bin/disksage-clean-plan.rs b/src-tauri/src/bin/disksage-clean-plan.rs index 847434ed6..527c971a5 100644 --- a/src-tauri/src/bin/disksage-clean-plan.rs +++ b/src-tauri/src/bin/disksage-clean-plan.rs @@ -1,36 +1,45 @@ //! Read-only cache cleanup plan. It exposes the same metadata-bound candidates as the GUI. use disksage_lib::rules::{cache_candidates, BaseDirs}; +use std::ffi::{OsStr, OsString}; + +const USAGE: &str = "usage: disksage-clean-plan [--id CACHE_ID]"; #[derive(Debug, Default, PartialEq, Eq)] struct Args { id: Option, } -fn parse_args(args: &[String]) -> Result { +#[derive(Debug, PartialEq, Eq)] +enum ParseOutcome { + Run(Args), + Help, +} + +fn parse_args(args: &[OsString]) -> Result { let mut parsed = Args::default(); let mut index = 0usize; while index < args.len() { - match args[index].as_str() { - "--id" => { - index += 1; - let id = args - .get(index) - .cloned() - .ok_or_else(|| "--id ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())?; - if id.is_empty() { - return Err("--id ๊ฐ’์ด ๋น„์–ด ์žˆ์Œ".into()); - } - parsed.id = Some(id); - } - "--help" | "-h" => { - return Err("usage: disksage-clean-plan [--id CACHE_ID]".into()); + let argument = args[index].as_os_str(); + if argument == OsStr::new("--id") { + index += 1; + let id = args + .get(index) + .ok_or_else(|| "--id ๊ฐ’์ด ํ•„์š”ํ•จ".to_string())? + .to_str() + .ok_or_else(|| "--id ๊ฐ’์€ UTF-8์ด์–ด์•ผ ํ•จ".to_string())?; + if id.is_empty() { + return Err("--id ๊ฐ’์ด ๋น„์–ด ์žˆ์Œ".into()); } - unknown => return Err(format!("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: {unknown}")), + parsed.id = Some(id.to_owned()); + } else if argument == OsStr::new("--help") || argument == OsStr::new("-h") { + return Ok(ParseOutcome::Help); + } else { + return Err("์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž".into()); } index += 1; } - Ok(parsed) + Ok(ParseOutcome::Run(parsed)) } fn now_ms() -> u64 { @@ -40,12 +49,11 @@ fn now_ms() -> u64 { .unwrap_or(0) } -fn run(args: &[String]) -> Result<(), String> { - let parsed = parse_args(args)?; +fn run(args: &Args) -> Result<(), String> { let bases = BaseDirs::from_env().ok_or("ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ")?; let mut candidates = cache_candidates(&bases); - if let Some(id) = parsed.id { - candidates.retain(|candidate| candidate.id == id); + if let Some(id) = &args.id { + candidates.retain(|candidate| candidate.id == *id); } let mut notices = vec![ "dry-run-only", @@ -68,16 +76,25 @@ fn run(args: &[String]) -> Result<(), String> { } fn main() { - if let Err(error) = run(&std::env::args().skip(1).collect::>()) { - eprintln!("{error}"); - std::process::exit(2); + let raw: Vec = std::env::args_os().skip(1).collect(); + match parse_args(&raw) { + Ok(ParseOutcome::Help) => println!("{USAGE}"), + Ok(ParseOutcome::Run(args)) => { + if let Err(error) = run(&args) { + eprintln!("{error}"); + std::process::exit(2); + } + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } } } #[cfg(test)] mod tests { use super::*; - use std::ffi::OsString; #[test] fn parser_distinguishes_help_from_invalid_input() {