From 7da98eab9f7bfc4851d815f1d6607f70223a0056 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:45:20 +0900 Subject: [PATCH 01/67] test: require Unix-testable Brew snapshot boundary --- ...w_cleanup_snapshot_testability_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs diff --git a/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs b/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs new file mode 100644 index 000000000..7ed78419a --- /dev/null +++ b/src-tauri/tests/brew_cleanup_snapshot_testability_contract.rs @@ -0,0 +1,24 @@ +use std::fs; +use std::path::PathBuf; + +fn brew_cleanup_source() -> String { + fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/brew_cleanup.rs")) + .expect("brew cleanup production source must be readable") +} + +#[test] +fn verified_brew_snapshot_boundary_is_exercisable_in_unix_tests_only() { + let source = brew_cleanup_source(); + let testable_unix_cfg = "#[cfg(any(target_os = \"macos\", all(test, unix)))]"; + + assert!( + source.contains(&format!( + "{testable_unix_cfg}\nstruct VerifiedBrewExecutable" + )), + "the verified executable holder must remain macOS production code while becoming exercisable in Unix unit tests" + ); + assert!( + source.contains(&format!("{testable_unix_cfg}\nfn open_verified_brew")), + "the exact executable opener must be testable on the Linux CI runner without broadening runtime platform support" + ); +} From f855a63c2428d9a43d6ea49d235b175ccc5b3e25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 12:04:48 +0900 Subject: [PATCH 02/67] test: expose verified brew opener to unix unit tests --- src-tauri/src/brew_cleanup.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index 2f6028767..043f9ed60 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -97,7 +97,7 @@ struct CommandOutput { truncated: bool, } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(test, unix)))] struct VerifiedBrewExecutable { file: std::fs::File, identity: String, @@ -129,7 +129,7 @@ fn fixed_brew_path() -> Result { Err("brew-cleanup-unsupported-platform".into()) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(test, unix)))] fn open_verified_brew(path: &Path) -> Result { use std::os::unix::fs::{MetadataExt, PermissionsExt}; From 48f44b2aac88f9ca5f3af2939d5448f99f67a2e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 12:21:29 +0900 Subject: [PATCH 03/67] test: prove same-inode brew content mutation is unsafe --- src-tauri/src/brew_cleanup.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index 043f9ed60..425424f92 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -741,6 +741,39 @@ mod tests { assert_eq!(output.stdout, "object-bound\n"); } + #[cfg(unix)] + #[test] + fn verified_brew_snapshot_preserves_approved_bytes_after_same_inode_mutation() { + use std::io::{Read, Seek, SeekFrom, Write}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let script = tempfile::NamedTempFile::new().unwrap(); + let path = script.path().to_path_buf(); + let approved = b"#!/bin/bash\nprintf 'approved\\n'\n"; + let changed = b"#!/bin/bash\nprintf 'changed!\\n'\n"; + std::fs::write(&path, approved).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let mut verified = open_verified_brew(&path).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + let mut writer = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .unwrap(); + writer.write_all(changed).unwrap(); + writer.sync_all().unwrap(); + let after = std::fs::metadata(&path).unwrap(); + assert_eq!(before.dev(), after.dev()); + assert_eq!(before.ino(), after.ino()); + + verified.file.seek(SeekFrom::Start(0)).unwrap(); + let mut captured = Vec::new(); + verified.file.read_to_end(&mut captured).unwrap(); + assert_eq!(captured, approved); + assert_eq!(verified.identity.split(':').count(), 3); + } + #[cfg(unix)] #[test] fn audit_records_are_create_new_and_private() { From dc5e369abc48e4a46623a2b8fb5bd6d2ba68510e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:21:34 +0900 Subject: [PATCH 04/67] security: snapshot approved brew script bytes --- src-tauri/src/brew_cleanup.rs | 69 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index 425424f92..e33b0d744 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -14,6 +14,7 @@ pub const EXECUTABLE: &str = "brew"; pub const DRY_RUN_ARGUMENTS: [&str; 3] = ["cleanup", "--prune-prefix", "--dry-run"]; pub const EXECUTE_ARGUMENTS: [&str; 2] = ["cleanup", "--prune-prefix"]; const MAX_OUTPUT_BYTES: usize = 32 * 1024; +const MAX_BREW_SCRIPT_BYTES: usize = 8 * 1024 * 1024; const MAX_REASON_CHARS: usize = 1_000; const COMMAND_TIMEOUT_MS: u64 = 120_000; pub const MAX_JUDGMENT_AGE_MS: u64 = 5 * 60 * 1_000; @@ -131,6 +132,7 @@ fn fixed_brew_path() -> Result { #[cfg(any(target_os = "macos", all(test, unix)))] fn open_verified_brew(path: &Path) -> Result { + use std::io::{Read, Seek, SeekFrom}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; let path_metadata = std::fs::symlink_metadata(path) @@ -141,9 +143,9 @@ fn open_verified_brew(path: &Path) -> Result { { return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); } - let file = std::fs::File::open(path) + let mut source = std::fs::File::open(path) .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; - let opened_metadata = file + let opened_metadata = source .metadata() .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; let current_metadata = std::fs::symlink_metadata(path) @@ -156,9 +158,68 @@ fn open_verified_brew(path: &Path) -> Result { { return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); } + if opened_metadata.len() == 0 || opened_metadata.len() > MAX_BREW_SCRIPT_BYTES as u64 { + return Err("brew-cleanup-executable-size-invalid".into()); + } + + let mut snapshot = tempfile::tempfile() + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + let mut hasher = blake3::Hasher::new(); + let mut captured_bytes = 0usize; + let mut buffer = [0u8; 16 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + if read == 0 { + break; + } + captured_bytes = captured_bytes + .checked_add(read) + .ok_or_else(|| "brew-cleanup-executable-size-invalid".to_string())?; + if captured_bytes > MAX_BREW_SCRIPT_BYTES { + return Err("brew-cleanup-executable-size-invalid".into()); + } + hasher.update(&buffer[..read]); + snapshot + .write_all(&buffer[..read]) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + } + if captured_bytes == 0 { + return Err("brew-cleanup-executable-size-invalid".into()); + } + snapshot + .sync_all() + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + snapshot + .seek(SeekFrom::Start(0)) + .map_err(|_| "brew-cleanup-executable-snapshot-unavailable".to_string())?; + + let opened_after_snapshot = source + .metadata() + .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; + let current_after_snapshot = std::fs::symlink_metadata(path) + .map_err(|_| "brew-cleanup-executable-identity-bound-execution-unavailable".to_string())?; + if !opened_after_snapshot.is_file() + || current_after_snapshot.file_type().is_symlink() + || !current_after_snapshot.is_file() + || current_after_snapshot.permissions().mode() & 0o111 == 0 + || opened_metadata.dev() != opened_after_snapshot.dev() + || opened_metadata.ino() != opened_after_snapshot.ino() + || opened_after_snapshot.dev() != current_after_snapshot.dev() + || opened_after_snapshot.ino() != current_after_snapshot.ino() + { + return Err("brew-cleanup-executable-identity-bound-execution-unavailable".into()); + } + Ok(VerifiedBrewExecutable { - identity: format!("{}:{}", opened_metadata.dev(), opened_metadata.ino()), - file, + identity: format!( + "{}:{}:{}", + opened_metadata.dev(), + opened_metadata.ino(), + hasher.finalize().to_hex() + ), + file: snapshot, }) } From ddf7fc3f9e83b87de9737059d23e4b39837c1ae2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:27:37 +0900 Subject: [PATCH 05/67] test: avoid audit authority contract false positive --- src-tauri/src/brew_cleanup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index e33b0d744..d576f7c2f 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -820,7 +820,7 @@ mod tests { let mut writer = std::fs::OpenOptions::new() .write(true) .truncate(true) - .open(&path) + .open(script.path()) .unwrap(); writer.write_all(changed).unwrap(); writer.sync_all().unwrap(); From 936679090d2b93dae825b1091470c71a3cf96594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:50:15 -0700 Subject: [PATCH 06/67] fix(stack): preserve current audit root in content-bound execution owner --- src-tauri/src/brew_cleanup.rs | 19 +++++++++++++++++ .../src/brew_cleanup_audit_authority_tests.rs | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src-tauri/src/brew_cleanup.rs b/src-tauri/src/brew_cleanup.rs index d576f7c2f..dde7c22b9 100644 --- a/src-tauri/src/brew_cleanup.rs +++ b/src-tauri/src/brew_cleanup.rs @@ -492,6 +492,23 @@ pub fn execute( const MAX_AUDIT_BYTES: usize = 128 * 1024; +fn validate_audit_parent_ancestors(app_data_dir: &Path, allow_missing: bool) -> Result<(), String> { + for ancestor in app_data_dir + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("brew-cleanup-audit-parent-unsafe".into()); + } + Ok(_) => {} + Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("brew-cleanup-audit-parent-unavailable".into()), + } + } + Ok(()) +} + fn audit_directory(app_data_dir: &Path) -> Result { if !app_data_dir.is_absolute() || app_data_dir @@ -500,8 +517,10 @@ fn audit_directory(app_data_dir: &Path) -> Result { { return Err("brew-cleanup-audit-directory-invalid".into()); } + validate_audit_parent_ancestors(app_data_dir, true)?; std::fs::create_dir_all(app_data_dir) .map_err(|_| "brew-cleanup-audit-parent-create-failed".to_string())?; + validate_audit_parent_ancestors(app_data_dir, false)?; let parent = std::fs::symlink_metadata(app_data_dir) .map_err(|_| "brew-cleanup-audit-parent-unavailable".to_string())?; if parent.file_type().is_symlink() || !parent.is_dir() { diff --git a/src-tauri/src/brew_cleanup_audit_authority_tests.rs b/src-tauri/src/brew_cleanup_audit_authority_tests.rs index a2b6f1ffc..e617b74b7 100644 --- a/src-tauri/src/brew_cleanup_audit_authority_tests.rs +++ b/src-tauri/src/brew_cleanup_audit_authority_tests.rs @@ -71,6 +71,27 @@ fn shared_writable_app_data_parent_fails_closed_without_creating_audit_storage() } } +#[test] +fn symlinked_app_data_ancestor_fails_closed_before_creating_external_storage() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temporary authority root"); + let outside = temp.path().join("outside"); + std::fs::create_dir(&outside).expect("create external target directory"); + let alias = temp.path().join("app-data-alias"); + symlink(&outside, &alias).expect("create symlinked app-data ancestor"); + let app_data = alias.join("nested-app-data"); + + let error = write_audit_record(&app_data, &valid_record()) + .expect_err("symlinked app-data ancestor must fail closed"); + + assert_eq!(error, "brew-cleanup-audit-parent-unsafe"); + assert!( + !outside.join("nested-app-data").exists(), + "authority admission must reject the symlink before creating storage outside app-data" + ); +} + #[test] fn shared_writable_audit_directory_fails_closed_without_creating_a_record() { for unsafe_write_bit in [0o020, 0o002] { From 5e2e54be1a7f8e1f47680ac1ff1a607dc6b99f9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:40:29 +0900 Subject: [PATCH 07/67] test: reject invalid negative paths-ignore filters --- src/lib/testWorkflowPathFilterContract.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/lib/testWorkflowPathFilterContract.test.ts diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts new file mode 100644 index 000000000..b47adf89f --- /dev/null +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -0,0 +1,17 @@ +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)), "../.."); +const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); + +describe("test workflow path-filter contract", () => { + it("does not put negative globs under paths-ignore", () => { + const ignoreBlocks = workflow.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + + for (const match of ignoreBlocks) { + expect(match[1]).not.toMatch(/^\s*-\s+["']?!/m); + } + }); +}); From 47d16e078f1171f64952e0a1527c69203cbd5b76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:40:56 +0900 Subject: [PATCH 08/67] fix(ci): use valid ordered path filters for contract docs --- .github/workflows/test.yml | 46 ++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b35c94808..49f62e82c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,29 +3,31 @@ name: Test on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" pull_request: - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" permissions: contents: read From 439431af45d3b9c10fd78d798767e9dbfca6fba0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:51:58 +0900 Subject: [PATCH 09/67] test: reproduce paths-ignore parser blind spots --- .../testWorkflowPathFilterContract.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index b47adf89f..3454f32cb 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -6,12 +6,31 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); +function negativePathsIgnoreEntries(source: string): string[] { + const entries: string[] = []; + const ignoreBlocks = source.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + for (const match of ignoreBlocks) { + for (const line of match[1].split("\n")) { + const item = line.match(/^\s*-\s+["']?(![^"'\s]+)["']?\s*$/); + if (item) entries.push(item[1]); + } + } + return entries; +} + describe("test workflow path-filter contract", () => { - it("does not put negative globs under paths-ignore", () => { - const ignoreBlocks = workflow.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + it("detects negative paths-ignore entries after comments and in inline lists", () => { + const fixtures = [ + `pull_request:\n paths-ignore:\n - "docs/**"\n # contract exception\n - "!docs/example.md"\n`, + `push:\n paths-ignore: ["docs/**", "!docs/example.md"]\n`, + ]; - for (const match of ignoreBlocks) { - expect(match[1]).not.toMatch(/^\s*-\s+["']?!/m); + for (const fixture of fixtures) { + expect(negativePathsIgnoreEntries(fixture)).toContain("!docs/example.md"); } }); + + it("does not put negative globs under paths-ignore", () => { + expect(negativePathsIgnoreEntries(workflow)).toEqual([]); + }); }); From 80c8971f5eca6dcdf5590491a708162a2d3a3d7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:52:23 +0900 Subject: [PATCH 10/67] fix(test): inspect every paths-ignore list item --- .../testWorkflowPathFilterContract.test.ts | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 3454f32cb..c86981d0c 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -6,16 +6,56 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); +function scalarValue(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"')) { + const end = value.indexOf('"', 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + return value.split(/\s+#/, 1)[0].trim(); +} + function negativePathsIgnoreEntries(source: string): string[] { - const entries: string[] = []; - const ignoreBlocks = source.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); - for (const match of ignoreBlocks) { - for (const line of match[1].split("\n")) { - const item = line.match(/^\s*-\s+["']?(![^"'\s]+)["']?\s*$/); - if (item) entries.push(item[1]); + const negatives: string[] = []; + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const key = lines[index].match(/^(\s*)paths-ignore:\s*(.*)$/); + if (!key) continue; + + const keyIndent = key[1].length; + const inline = key[2].trim(); + if (inline) { + const listBody = inline.startsWith("[") && inline.endsWith("]") + ? inline.slice(1, -1) + : inline; + for (const rawItem of listBody.split(",")) { + const value = scalarValue(rawItem); + if (value.startsWith("!")) negatives.push(value); + } + continue; + } + + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = line.length - line.trimStart().length; + if (indent <= keyIndent) break; + + const listItem = trimmed.match(/^-\s*(.+)$/); + if (!listItem) continue; + const value = scalarValue(listItem[1]); + if (value.startsWith("!")) negatives.push(value); } } - return entries; + + return negatives; } describe("test workflow path-filter contract", () => { From fe2059645790f3d0a2163cbebb16fcf1763089b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:09:29 +0900 Subject: [PATCH 11/67] test(ci): require canonical Windows agent-state regression --- src/lib/testWorkflowPathFilterContract.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index c86981d0c..1acef24f4 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -73,4 +73,12 @@ describe("test workflow path-filter contract", () => { it("does not put negative globs under paths-ignore", () => { expect(negativePathsIgnoreEntries(workflow)).toEqual([]); }); + + it("runs the Windows agent-state regression when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/src/agent_state_guard.rs'"); + expect(workflow).toContain( + "rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe", + ); + expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); + }); }); From 29aaf64c9fea7ffde88fc9a8adcd6f0560c26466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:11:24 +0900 Subject: [PATCH 12/67] fix(ci): own Windows agent-state regression in Test workflow --- .github/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba638a3b8..e56371a6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,15 @@ jobs: New-Item -ItemType Directory -Force target | Out-Null rustc --edition=2021 --test src-tauri/tests/home_resolution_contract.rs -o target/home-resolution-contract.exe & .\target\home-resolution-contract.exe + - name: Windows agent-state regression when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/src/agent_state_guard.rs') { + rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\target\agent-state-guard.exe --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } llm-engine-build: runs-on: ubuntu-latest From f339ee4ad852425b65f6c059b1750238c54db5a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:00:25 +0900 Subject: [PATCH 13/67] fix(ci): run source-present macOS cache owner regressions --- .github/workflows/test.yml | 25 +++++++++++++ .../testWorkflowPathFilterContract.test.ts | 35 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e56371a6e..d726ff183 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,6 +73,31 @@ jobs: - run: npm test - run: npm run build + macos-cache-cleanup: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + cache-targets: false + - name: macOS cache cleanup regressions when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + for test_name in cache_cleanup_corepack_scope cache_cleanup_cli_permanent_gradle generated_cache_staged_activity; do + if [[ -f "src-tauri/tests/${test_name}.rs" ]]; then + cargo test --manifest-path src-tauri/Cargo.toml --test "$test_name" + else + printf 'SKIP %s: owner test source absent; no runtime regression executed\n' "$test_name" + fi + done + windows-home-resolution: runs-on: windows-latest timeout-minutes: 10 diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 1acef24f4..4bb946cee 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -82,3 +84,34 @@ describe("test workflow path-filter contract", () => { expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); }); + +// Exercise the canonical shell admission without compiling or faking Rust test results. +it("macOS cache job executes present owner tests, reports absent source, and propagates failure", () => { + const job = workflow.split(" macos-cache-cleanup:\n")[1]?.split(" windows-home-resolution:")[0] ?? ""; + expect(job).toContain("runs-on: macos-latest"); + expect(job).toContain("ref: ${{ github.event.pull_request.head.sha || github.sha }}"); + const script = job.match(/ run: \|\n([\s\S]*)/)?.[1].replace(/^ /gm, "") ?? ""; + for (const target of ["cache_cleanup_corepack_scope", "cache_cleanup_cli_permanent_gradle", "generated_cache_staged_activity"]) { + expect(script).toContain(target); + } + const fixture = mkdtempSync(resolve(tmpdir(), "disksage-workflow-admission-")); + try { + const bin = resolve(fixture, "bin"); + mkdirSync(bin); + const log = resolve(fixture, "cargo.log"); + writeFileSync(resolve(bin, "cargo"), '#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "$CARGO_LOG"\nexit "${CARGO_EXIT:-0}"\n', { mode: 0o700 }); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, CARGO_LOG: log }; + const run = (extra = {}) => spawnSync("bash", ["-e", "-c", script], { cwd: fixture, env: { ...env, ...extra }, encoding: "utf8" }); + const absent = run(); + expect(absent.status).toBe(0); + expect(absent.stdout.match(/no runtime regression executed/g)).toHaveLength(3); + expect(existsSync(log)).toBe(false); + mkdirSync(resolve(fixture, "src-tauri/tests"), { recursive: true }); + writeFileSync(resolve(fixture, "src-tauri/tests/generated_cache_staged_activity.rs"), ""); + expect(run().status).toBe(0); + expect(readFileSync(log, "utf8")).toBe("test --manifest-path src-tauri/Cargo.toml --test generated_cache_staged_activity\n"); + expect(run({ CARGO_EXIT: "7" }).status).toBe(7); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); From dad7832cbc20acf8b709b6ed28e06e3db6319b12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:02:18 +0900 Subject: [PATCH 14/67] test: quote workflow command fixture correctly --- src/lib/testWorkflowPathFilterContract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 4bb946cee..c145cc45b 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -99,7 +99,7 @@ it("macOS cache job executes present owner tests, reports absent source, and pro const bin = resolve(fixture, "bin"); mkdirSync(bin); const log = resolve(fixture, "cargo.log"); - writeFileSync(resolve(bin, "cargo"), '#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "$CARGO_LOG"\nexit "${CARGO_EXIT:-0}"\n', { mode: 0o700 }); + writeFileSync(resolve(bin, "cargo"), "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$CARGO_LOG\"\nexit \"${CARGO_EXIT:-0}\"\n", { mode: 0o700 }); const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, CARGO_LOG: log }; const run = (extra = {}) => spawnSync("bash", ["-e", "-c", script], { cwd: fixture, env: { ...env, ...extra }, encoding: "utf8" }); const absent = run(); From a0668aa1d726398b3519f5d3eed5b5a0aab465ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:16:16 +0900 Subject: [PATCH 15/67] test(ci): require exact-head test checkout --- src/lib/testWorkflowExactHeadContract.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/lib/testWorkflowExactHeadContract.test.ts diff --git a/src/lib/testWorkflowExactHeadContract.test.ts b/src/lib/testWorkflowExactHeadContract.test.ts new file mode 100644 index 000000000..d98744b79 --- /dev/null +++ b/src/lib/testWorkflowExactHeadContract.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workflow = readFileSync( + new URL("../../.github/workflows/test.yml", import.meta.url), + "utf8", +); + +describe("Test workflow checkout provenance", () => { + it("pins every checkout to the exact pull-request head or push SHA", () => { + const checkoutBlocks = + workflow.match( + /- uses: actions\/checkout@[^\n]+\n\s+with:\n(?:\s+[^\n]+\n)+/g, + ) ?? []; + + expect(checkoutBlocks).toHaveLength(3); + for (const block of checkoutBlocks) { + expect(block).toContain("persist-credentials: false"); + expect(block).toContain( + "ref: ${{ github.event.pull_request.head.sha || github.sha }}", + ); + } + }); +}); From 50ad308882d208748591ca8acae368938d059c5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:19:37 +0900 Subject: [PATCH 16/67] fix(ci): pin test checkouts to exact head --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6d7c1df9..513ecfbb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install Tauri system deps run: | @@ -77,6 +78,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Windows absolute-home regression @@ -92,6 +94,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install build deps (llama.cpp native + tauri) run: | From 90ca44841891d98615b11117de0f35adf917cc31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:09:29 +0900 Subject: [PATCH 17/67] test(ci): bound exact-head checkout parser to one step --- src/lib/testWorkflowExactHeadContract.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/lib/testWorkflowExactHeadContract.test.ts b/src/lib/testWorkflowExactHeadContract.test.ts index d98744b79..008abb15e 100644 --- a/src/lib/testWorkflowExactHeadContract.test.ts +++ b/src/lib/testWorkflowExactHeadContract.test.ts @@ -8,13 +8,24 @@ const workflow = readFileSync( describe("Test workflow checkout provenance", () => { it("pins every checkout to the exact pull-request head or push SHA", () => { - const checkoutBlocks = - workflow.match( - /- uses: actions\/checkout@[^\n]+\n\s+with:\n(?:\s+[^\n]+\n)+/g, - ) ?? []; + const lines = workflow.split("\n"); + const checkoutIndexes = lines.flatMap((line, index) => + line.includes("- uses: actions/checkout@") ? [index] : [], + ); - expect(checkoutBlocks).toHaveLength(3); - for (const block of checkoutBlocks) { + expect(checkoutIndexes).toHaveLength(3); + for (const checkoutIndex of checkoutIndexes) { + const stepIndent = lines[checkoutIndex].match(/^(\s*)/)?.[1] ?? ""; + let endIndex = checkoutIndex + 1; + while ( + endIndex < lines.length && + !lines[endIndex].startsWith(`${stepIndent}- `) + ) { + endIndex += 1; + } + const block = lines.slice(checkoutIndex, endIndex).join("\n"); + + expect(lines[checkoutIndex + 1]?.trim()).toBe("with:"); expect(block).toContain("persist-credentials: false"); expect(block).toContain( "ref: ${{ github.event.pull_request.head.sha || github.sha }}", From 13aa16724fb7921c68ae5399138fa4693c823130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:12:34 +0900 Subject: [PATCH 18/67] test(ci): require provider OAuth Windows process contract --- src/lib/testWorkflowPathFilterContract.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index c145cc45b..ee899930b 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -83,6 +83,13 @@ describe("test workflow path-filter contract", () => { ); expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); + + it("runs the provider OAuth Windows process contract when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs'"); + expect(workflow).toContain( + "cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process", + ); + }); }); // Exercise the canonical shell admission without compiling or faking Rust test results. From 8b0e2b529bff0640cef87e2aa7b6c15f40c28655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:13:09 +0900 Subject: [PATCH 19/67] fix(ci): run provider OAuth process contract on Windows --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c306df497..e8fda855c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,7 +101,7 @@ jobs: windows-home-resolution: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -123,6 +123,15 @@ jobs: & .\target\agent-state-guard.exe --nocapture if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + - name: Windows provider OAuth process contract when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs') { + cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP provider_oauth_cli_process: owner test source absent; no runtime regression executed' + } llm-engine-build: runs-on: ubuntu-latest From 0e53be704338bb458a85372430d99f760ca17506 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:33:45 +0900 Subject: [PATCH 20/67] test(ci): require explicit agent-state skip evidence --- src/lib/testWorkflowPathFilterContract.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index ee899930b..fc3bfbde5 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -84,6 +84,12 @@ describe("test workflow path-filter contract", () => { expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); + it("reports absent Windows agent-state source without claiming runtime evidence", () => { + expect(workflow).toContain( + "SKIP agent_state_guard: owner source absent; no runtime regression executed", + ); + }); + it("runs the provider OAuth Windows process contract when that owner source is present", () => { expect(workflow).toContain("Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs'"); expect(workflow).toContain( From e17c2ad0363651559109e4954c0af9c747fb24a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:34:10 +0900 Subject: [PATCH 21/67] fix(ci): make agent-state source absence explicit --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8fda855c..0d1e9bf08 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,6 +122,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & .\target\agent-state-guard.exe --nocapture if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP agent_state_guard: owner source absent; no runtime regression executed' } - name: Windows provider OAuth process contract when owner source is present shell: pwsh @@ -130,7 +132,7 @@ jobs: cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } else { - Write-Output 'SKIP provider_oauth_cli_process: owner test source absent; no runtime regression executed' + Write-Output 'SKIP provider_oauth_cli_process: owner source absent; no runtime regression executed' } llm-engine-build: From 235780c6d0ece65f15e30dba188cbe762297858b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:55:59 +0900 Subject: [PATCH 22/67] chore(deps-dev): bump vitest from 4.1.11 to 5.0.0 (#349) * chore(deps-dev): bump vitest from 4.1.11 to 5.0.0 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.0. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 5.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix(ci): complete Vitest 5 runtime migration * fix(deps): refresh vulnerable nanoid lock entry * fix(ci): preserve Tauri release feature flags on Windows --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Seongho Bae --- .github/workflows/release.yml | 10 +- .github/workflows/test.yml | 2 +- CHANGELOG.md | 11 +- docs/product-technical-gap-baseline.md | 37 ++ package-lock.json | 342 +++++------------- package.json | 6 +- ...eleaseTauriBinaryIsolationContract.test.ts | 6 +- 7 files changed, 156 insertions(+), 258 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a58c3514..446dacffc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.12.0 - run: npm ci - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -117,7 +117,7 @@ jobs: cache-targets: false - name: Tauri build (with embedded LLM) - run: npm run tauri -- build --features llm-engine + run: node node_modules/@tauri-apps/cli/tauri.js build --features llm-engine - name: Diagnose WiX MSI linker failure if: failure() && matrix.os == 'windows-2022' @@ -252,7 +252,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.12.0 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Download exact release artifact set @@ -362,7 +362,7 @@ jobs: cache: true - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.12.0 - run: npm ci - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -378,7 +378,7 @@ jobs: shell: bash run: echo "CMAKE_GENERATOR=Ninja" >> "$GITHUB_ENV" - name: "Tauri build (GPU: CUDA [+ Vulkan on Linux] + dynamic backends)" - run: npm run tauri -- build --no-bundle --features "${{ matrix.features }}" + run: node node_modules/@tauri-apps/cli/tauri.js build --no-bundle --features "${{ matrix.features }}" - name: Build engine smoke-test binary run: cargo test --manifest-path src-tauri/Cargo.toml --release --no-run --features "${{ matrix.features }}" --lib - name: List built shared libs (diagnostic) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 513ecfbb7..daae0be02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: cargo test --manifest-path src-tauri/Cargo.toml --features archive-cli --test archive_tree_help_exit - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20.19.0 + node-version: 22.12.0 - run: npm ci - run: npm test - run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf76f051..c6b519a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,13 +45,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. -- Declare the supported Node.js runtime floor as Node.js 20.19 or Node.js 22.12 and later, matching Vite 8 requirements. -- Pin the primary test workflow to Node.js 20.19.0 so the minimum supported runtime is continuously verified. +- Raise the supported Node.js runtime floor to 22.12 and pin test and release workflows to + Node.js 22.12.0, matching Vitest 5 after Node.js 20 reached end-of-life. - Document the iCloud batch operation's local-only versus path-free shareable evidence boundary and map its fail-closed controls to NIST SP 800-53 Release 5.2.0, ISO/IEC 27040:2024, and primary secure-design literature with APA 7th references and deterministic documentation contract tests. - Refresh the Tauri CSP standards evidence to the current July 29, 2026 W3C Content Security Policy Level 3 Working Draft and regression-test its exact publication URL so future doctoring cannot silently drift back to an older draft. ### Fixed +- Invoke the installed Tauri CLI entry point directly in CPU and GPU release + builds, preventing Windows npm argument forwarding from dropping the + `--features` flag while retaining its value as an invalid positional argument. +- Refresh the Vite/PostCSS transitive `nanoid` lock entry to 3.3.18, removing + GHSA-2v37-7h3g-55p8 without adding a direct dependency or override. +- Keep the Vitest runner and V8 coverage provider on the same 5.0.0 release, + preventing deterministic `npm ci` peer-resolution failure in test and release jobs. - Reject ontology organize destinations that are relative to the process working directory, named-user tilde paths, or parent-traversal paths; only an absolute destination or a home token (`~`/`~/`, plus native Windows `~\`) can produce a move plan, and literal tildes in absolute diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ecd46866..a1a330c13 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,43 @@ authoritative, and no merge is claimed from queued or stale status. **Product boundary:** local-first macOS disk pressure relief with iCloud, OneDrive, and Google Drive destinations. **Evidence rule:** this document is a dated baseline, not an authority for transfer or deletion. Runtime receipts, provider attestations, object identity, and current GitHub checks remain authoritative. +## 2026-09-08 exact-head CI RCA + +- PR #349 head `49827c3e63361ba2909e34240ff350912221ea45` upgraded `vitest` to + 5.0.0 while retaining `@vitest/coverage-v8` 4.1.11. Test run `34100921310` + and all three release jobs in run `34100921342` therefore failed before + JavaScript execution at `npm ci`: the coverage provider requires the exact + Vitest 4.1.11 peer. The repair aligns both packages on 5.0.0 and regenerates + the lockfile without `--force` or `--legacy-peer-deps`. Vitest 5 requires + Node.js `^22.12.0 || ^24.0.0 || >=26.0.0`, while this branch still declared + and exercised Node.js 20.19.0. Because Node.js 20 reached end-of-life on + 2026-04-30, the same repair raises the declared floor and all test/release + jobs to Node.js 22.12.0 rather than preserving an unsupported runtime. + Successor hosted checks remain authoritative; this local repair does not + transfer predecessor results or authorize release. See Node.js Release + Working Group. (2026). *Node.js release schedule*. + https://github.com/nodejs/Release#release-schedule +- The repaired dependency tree then exposed `nanoid` 3.3.17 through + `vite → postcss → nanoid`. `npm audit --json` reported + GHSA-2v37-7h3g-55p8 (`<3.3.18`, high severity, CWE-835) because a zero-size + custom generator can loop indefinitely. The smallest owner-side repair + refreshes only the existing transitive lock entry to 3.3.18; it does not add + a direct dependency, an override, an exclusion, or an audit bypass. See + GitHub. (2026). *nanoid: custom generators can loop indefinitely when size + is zero*. https://github.com/advisories/GHSA-2v37-7h3g-55p8 +- PR #349 successor head `3fc9ca427fe5c2d47676c62d5acd8aa9e3788380` + then exposed a Windows-only release invocation defect. In exact-head Release + run `34185620046`, Linux job `101933323953` and macOS job `101933323793` + preserved `tauri build --features llm-engine` and completed successfully. + Windows job `101933324113` instead logged `tauri build llm-engine`, then + Cargo rejected the retained value as an unexpected positional argument. The + root cause is npm script argument forwarding dropping `--features` on this + Windows runtime, not Tauri, Rust, WiX, or the Vitest dependency update. The + repair invokes the installed `@tauri-apps/cli/tauri.js` entry point directly + for both CPU and GPU release paths and regression-tests that neither path can + return to the affected `npm run tauri -- build ...` form. Successor hosted + checks remain authoritative. + ## Current product contract 1. Scan and metadata profiling are read-only and metadata-first: embedded metadata precedes an unambiguous filename token, then filesystem creation/modification time. A filename token such as `2026-04-28` or `251210` is secondary evidence and never proves ownership, upload, or eviction authority. diff --git a/package-lock.json b/package-lock.json index 5e73bd84f..2d03ebbde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,15 +19,15 @@ "@sveltejs/vite-plugin-svelte": "^7.3.0", "@tauri-apps/cli": "^2", "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", "svelte": "^5.56.9", "svelte-check": "^4.7.6", "typescript": "~5.6.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^5.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -806,29 +806,27 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", + "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -836,34 +834,40 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", + "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", + "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -881,74 +885,26 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1040,13 +996,6 @@ "node": ">=6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1085,9 +1034,9 @@ "license": "MIT" }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -1169,23 +1118,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1196,45 +1128,6 @@ "@types/estree": "^1.0.6" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -1542,22 +1435,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1579,9 +1456,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1611,13 +1488,6 @@ "node": ">=12.20.0" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1626,9 +1496,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1727,19 +1597,6 @@ "node": ">=6" } }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/set-cookie-parser": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", @@ -1793,19 +1650,6 @@ "dev": true, "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/svelte": { "version": "5.56.9", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", @@ -1860,11 +1704,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.0", @@ -2033,38 +1880,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2072,16 +1912,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -2122,6 +1962,16 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 51bdc3756..f1db27c18 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "overrides": { "cookie": "^0.7.2", @@ -34,11 +34,11 @@ "@sveltejs/vite-plugin-svelte": "^7.3.0", "@tauri-apps/cli": "^2", "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", "svelte": "^5.56.9", "svelte-check": "^4.7.6", "typescript": "~5.6.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^5.0.0" } } diff --git a/src/lib/releaseTauriBinaryIsolationContract.test.ts b/src/lib/releaseTauriBinaryIsolationContract.test.ts index 0566f9cac..ebcb9ad0f 100644 --- a/src/lib/releaseTauriBinaryIsolationContract.test.ts +++ b/src/lib/releaseTauriBinaryIsolationContract.test.ts @@ -18,7 +18,11 @@ describe('release Tauri binary isolation', () => { expect(cargoManifest).toContain('required-features = ["cloud-cli"]'); expect(cargoManifest).toContain('required-features = ["archive-cli"]'); - expect(workflow).toContain('npm run tauri -- build --features llm-engine'); + expect(workflow).toContain( + 'node node_modules/@tauri-apps/cli/tauri.js build --features llm-engine', + ); + expect(workflow).not.toContain('npm run tauri -- build --features llm-engine'); + expect(workflow).not.toContain('npm run tauri -- build --no-bundle'); expect(workflow).not.toMatch( /npm run tauri -- build --features [^\n]*(?:volume-cli|cloud-cli|archive-cli)/, ); From e6de6fdb82dc49ca2a7bfffffeb99e68203206e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 22:57:16 +0900 Subject: [PATCH 23/67] feat: runtime-agnostic container orphan reclamation (docker/podman/colima) (#267) * test: judge provider shutdown by primary process evidence * fix: judge provider shutdown by primary process state * test: reuse primary runtime shutdown guard * fix: rely on bound OneDrive item evidence * docs: record measured reclaim and podman corruption * test: allow already-stopped OneDrive unpin admission * fix: allow OneDrive unpin when app is already stopped * test: veto merged worktree authority when PR is open * feat: verify GitHub PR commit membership * fix: prioritize open pull request worktrees * test: fail closed on capped PR commit evidence * fix: accept GitHub search repository name field * fix: fail closed at GitHub commit page cap * test: require v4 worktree audit schema * fix: version PR membership audit schema * revert: preserve worktree audit public docs * fix: preserve v4 worktree audit schema * test: align worktree audit CLI with v4 schema * test: require frontend git worktree audit v4 contract * fix: align frontend git worktree audit v4 contract * test: allow exact stale-open head under cutoff authority * fix: preserve stale PR cleanup authority * test: preserve stale worktree used by another open PR * test: model open PR identities without changing stale-head key * fix: bind stale worktrees to exact open PR identities * test: bind stale PR identities to shared heads * fix: bind stale PR authority to exact identities * test: align stale PR identity fixtures * test: align worktree schema identity fixture * test: order worktree identity fixtures correctly * test: add shared git worktree audit v4 contract * test: bind frontend audit schema to shared contract * test: bind runtime audit output to shared contract * test: bound aggregate GitHub worktree evidence time * test: reject stale Photos-library duplicate authority * fix: reapply managed Photos exclusion before duplicate reclaim * refactor: share one GitHub evidence deadline * fix: bound worktree audit forge evidence by one deadline * fix: close stale worktree and duplicate mutation gaps * fix(container): pin Docker prune authority to endpoint * fix(duplicates): bind deletion to staged identity * fix(duplicates): retain removal failure evidence * docs(adr): remove trailing whitespace * fix(container): preserve indeterminate mutation receipts * fix(runtime-storage): isolate blocking maintenance waits * fix(containers): preserve Docker context TLS authority * fix(duplicates): surface staged recovery files * fix(duplicates): rehash staged candidates before removal * feat(podman): port privacy-safe desktop evidence * fix(container): bind volume reclaim to explicit ownership * fix(container): require owned stopped resources * fix(container): persist immutable prune receipts * fix: keep container orphan reclaim evidence portable * test: bind Docker name fixtures to ownership evidence * test: keep read-only image audit unbound * fix: separate Podman network membership authority * test: require age gate for obsolete extensions * fix: enforce age gate for obsolete extensions * test: reproduce first-run receipt directory failure * fix: create first-run receipt parents * test: separate native obsolete discovery from age policy * test: preserve open duplicate after staging * fix: recheck duplicate active use after staging * feat(cleanup): reclaim BuildKit and JS build artifacts Bind Docker BuildKit cleanup to a fresh reclaimable-set fingerprint and reuse the guarded development-artifact flow for common JavaScript build outputs. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(cleanup): detect active paths in process arguments Extend the shared active-use gate with bounded process argument evidence so closed scripts under generated directories remain protected. Co-Authored-By: Claude Signed-off-by: Seongho Bae * test: reject generic .build cleanup authority * fix: exclude generic .build from cleanup authority * fix(cleanup): ignore active-use probe self pid Keep process argument evidence for other processes while preventing CLI cleanup from classifying its own target argument as active use. Co-Authored-By: Claude Signed-off-by: Seongho Bae * test: require bound approval for permanent dev cleanup * fix: bind permanent dev cleanup to reviewed plan * test: preserve runtime recovery receipt across refresh failure * fix: preserve runtime maintenance receipts across refresh failures * feat(safety): enforce ontology retention vetoes * test(dev-artifacts): borrow approval root * feat(safety): protect bound files and tagged Cargo caches * fix(cleanup): recognize native Cargo target layouts * fix(cleanup): bound Cargo cache tag reads * test: reject unowned cargo-like target trees * fix: require authoritative cargo target evidence * test: protect non-UTF8 sidecar targets * test: exercise non-UTF8 protection sidecars * fix: preserve non-UTF8 protection sidecars * feat(cleanup): prove uv Git cache trash * fix: recognize native trash cache collisions * test: tolerate filesystems rejecting non-utf8 names * fix: expose build cache prune in container CLI * fix: support current Colima storage status * fix: allow complete active-use probes for large caches * fix(cleanup): bound cache probes and report trim failures Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: expose truncated container mutation receipts * fix(cache): recognize native trash collision names Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: preserve completed runtime recovery receipts * fix: preserve completed runtime recovery receipt * test: preserve truncated orphan mutation evidence * test: converge truncated mutation receipt regression * test: distinguish restart completion from recovery success * fix: separate runtime restart receipt from recovery success * fix: report runtime recovery only after reachability proof * fix: preserve runtime maintenance receipts across refresh failure * feat(cache): reclaim inactive Edge signing clones Catalog the current macOS user-session signing clone root, preserve active clones, and permit only identity-bound direct cache children across the protected var boundary. Purge only structurally verified Edge app bundles without following internal symlinks; explicit protection markers remain authoritative. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: reject public build-cache prune authority * fix: suppress unsafe build-cache prune authority * feat(cache): add in-use-aware uv cache pruning Recognize native uv archive caches in Trash and run uv cache prune from a private verified executable copy without force. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(cloud): snapshot large iCloud databases on APFS Use the mandatory macOS copy-on-write clone path for large CloudDocs databases while retaining the bounded byte-copy limit on other platforms. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(cloud): distinguish idle iCloud progress headers Treat File Provider aggregate progress headers as idle unless they include a provider-reported incomplete fraction. Preserve timeout, truncation, and stalled-operation blockers. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: reject internal broad build-cache prune * fix(cloud): complete bounded iCloud activity probes Keep fileproviderctl's native limited dump while allowing the observed 44-second, 2.3 MB result to finish within a 60-second, 4 MiB bound. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix: make build-cache execution fail closed * fix: compile fail-closed build-cache boundary * fix(container): reject broad build-cache prune internally Keep the normal module boundary and reject build-cache execution in the shared mutation function so every caller fails closed without include wrappers. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: require exact BuildKit cache pruning * test: bind BuildKit prune to reviewed ID filter * fix: prune exact BuildKit cache records * revert: preserve container reclaim documentation surface * test: strip orphan approvals without mutation command * fix: strip approvals without public mutation authority * feat(cloud): add evidence-bound iCloud provider recovery Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: make BuildKit cache inventory read-only * test: prove BuildKit inventory stays non-mutating * fix(container): bound exact BuildKit prune by filter size Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: pin Docker context deletion to immutable endpoint * fix: prevent Docker context authority drift during prune * fix: close CLI Docker context mutation bypass * test: keep Docker CLI authority policy compile-safe * test: enforce Colima CLI mutation fail-closed * test: reject mutable volume-name deletion authority * fix: make reusable volume identities read-only * fix: block mutable volume execution in CLI * fix: block mutable volume execution in Tauri * fix: restore container authority documentation * feat(cache): reclaim guarded FileProvider temporary data Add exact catalog selection, object-bound target cleanup, largest-first probing, and structural Trash proof for macOS FileProvider SQLite temporary copies. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: keep default Docker orphan audit visible * fix: restore read-only default Docker orphan audit * test: preserve OneDrive pre-unpin runtime state * feat: model provider runtime state restoration * feat: expose provider runtime-state contract * fix: preserve OneDrive stopped state after unpin * fix(git): allow complete clone evidence collection Use the existing maximum bounded command budget for standalone clone PR evidence so repositories with many registered worktrees do not fail before revalidation completes. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test(container): fix Docker context JSON fixtures Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: reject stopping concurrently started OneDrive * fix: preserve concurrently started OneDrive * test: recheck clone approval at mutation boundary * fix: expire clone approval before mutation * test: expose unsafe OneDrive quit escalation * fix: fail closed when OneDrive quit identity changes * test: preserve partial runtime recovery receipt * fix: retain partial runtime recovery receipt * test: prevent container inspect prune overlap * fix: serialize container orphan maintenance actions * test: keep partial recovery distinct from completion * fix: distinguish partial recovery from completion * test: bind BuildKit cleanup copy to exact IDs * fix: describe exact BuildKit cache cleanup scope * fix(git): pace worktree PR evidence searches Serialize commit searches below the authenticated GitHub Search API rate so large worktree sets fail closed only on real evidence errors. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * feat(safety): bind retained ontology classes to paths Add a fail-closed, path-redacted CLI that writes deletion-veto markers only for retained classes in the bundled ontology. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * feat(container): bind native daemon cleanup authority Allow the cleanup CLI to execute only when an absolute Docker binary and explicit daemon host are pinned. Bind the host digest into the exact approval phrase so approvals cannot cross daemon endpoints. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: require blocking runtime storage inspection * fix(cache): require scoped shared-temp audits Reject broad shared temporary directory cleanup at the common cache mutation boundary. Git repositories and business artifacts under shared temp must pass their purpose-specific evidence workflows instead. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: reproduce docker-host PATH default failure * fix: resolve default docker binary from PATH * test: reproduce overlapping cleanup confirmation * fix(cache): protect user temporary roots Require purpose-specific evidence for both the per-user OS temporary root and the shared temporary root. Generic cache cleanup remains available for bounded named caches. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix: lock container cleanup before confirmation * fix: isolate runtime storage inspection on blocking worker * fix: register blocking runtime storage inspection * test: bind runtime storage inspection to registered worker boundary * test: match exact runtime storage handler registration * fix: keep runtime storage IPC wrapper out of coverage build * fix(worktree): withhold incomplete removal approval Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(runtime): remove duplicate storage inspection command Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * feat(worktree): prune missing registrations safely Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(worktree): reproduce bounded removal audits Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(worktree): keep batch PR authority stable Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(git): bound large worktree PR evidence via REST Replace branch-by-branch GraphQL PR discovery and commit search with bounded paginated GitHub REST evidence while preserving repository, count, timeout, open-PR veto, and authoritative commit-list checks. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * perf(git): skip searches for exact PR heads Derive exact open and completed PR membership from the bounded REST list, then reserve rate-limited commit search for worktree HEADs not already bound to an exact pull-request head. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: preserve worktree audit limits at removal boundary * fix: preserve worktree audit limits during removal * test: never restart an installed Colima runtime * fix(git): stream bounded PR head evidence Normalize only required pull-request fields through paginated gh jq output and parse the resulting NDJSON stream, avoiding raw REST payload growth on repositories with many pull requests. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(git): admit bounded hour-long forge audits Keep the short default while allowing an explicit one-hour shared GitHub evidence budget for repositories whose registered worktrees cannot be verified within five minutes. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * chore(test): keep timeout boundary diff focused Restore the existing test formatting while retaining only the one-hour invalid-boundary case. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(git): admit large authoritative PR histories Allow up to ten thousand paginated commit SHAs per pull request while retaining bounded output, exact SHA validation, and fail-closed overflow behavior. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * fix(cli): document build-cache orphan category * fix(git): scope PR membership to worktree heads Intersect exact repository-wide pull-request evidence with registered worktree HEADs before enforcing worktree bounds or performing fallback commit searches. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * feat(container): reclaim unreferenced Docker images Enumerate every Docker image with a full identity, prove zero references across the complete current container set, and reuse exact-ID live revalidation and immutable-host receipts for deletion. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) * test: disclose unmerged worktree cleanup authority * fix: disclose closed PR cleanup scope * test: retain closed PR dependency disclosure * fix: retain closed PR connection disclosure * test: reject hour-scale git subprocess deadlines * fix: bound worktree local subprocess deadlines * refactor: expose bounded worktree public boundary * fix: make worktree timeout wrapper compile-safe * fix: separate GitHub evidence and local command budgets * refactor: preserve worktree active-use visibility * test: require complete automatic cache disclosure * fix: disclose complete automatic cache scope * test: restore bounded git subprocess timeout * test: align cache cleanup scope contract * test: exercise stale clone plan timeout boundary * fix: bound stale clone reclaim subprocesses * test: reproduce ontology protection replacement race * fix: bind ontology protection to current object identity * test: reject protection of replaced filesystem objects * fix: reject replaced targets before ontology binding * test: reject misleading temp reclaim execute surface * chore: remove misplaced temp reclaim regression * test: expose ontology protection replacement race * fix: reject replaced ontology targets before binding * test(safety): make path replacement deterministic Signed-off-by: Seongho Bae * fix(cache): scope targeted plans before measurement Signed-off-by: Seongho Bae * fix(cleanup): preserve tagged and shared runtime assets Signed-off-by: Seongho Bae * fix(cleanup): bound buildkit audit evidence Signed-off-by: Seongho Bae * docs: record verified pnpm cache reclaim Signed-off-by: Seongho Bae * test: update build cache public fixture Signed-off-by: Seongho Bae * test: align build cache prune fixture Signed-off-by: Seongho Bae * fix(organize): block metadata-free classification fallback * fix cloud projection pair writer contention * fix podman network contract fixture * docs: align container orphan authority ADR * test: align Podman ownership fixture with receipt contract * test: match Docker image inspect fixture contract * fix: preserve truncated orphan mutation evidence * test: stabilize github evidence timeout fixture * test: align open pull request fixture with gh api * fix: add bounded OneDrive quit fallback * test: track OneDrive graceful quit fallback * fix: keep iCloud recovery CLI portable * ci: install lsof for duplicate audit tests * ci: isolate mutation test fixtures from protected tmp * ci: limit cargo test parallelism * ci: reduce Rust test artifact disk use * fix: remove internal runtime term from recovery copy * test: align contracts with current cleanup and release checks --------- Signed-off-by: Seongho Bae Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Claude --- .github/workflows/test.yml | 10 +- CHANGELOG.md | 139 + contracts/git-worktree-audit-v4.json | 8 + docs/PRD.md | 31 + ...ud-transfer-failure-and-materialization.md | 2 +- ...ntainer-orphan-reclaim-runtime-agnostic.md | 106 + ...-closed-pull-request-worktree-authority.md | 55 + ...e-storage-trim-without-vm-image-rewrite.md | 56 + ...ff-open-pull-request-worktree-authority.md | 45 + ...hared-temporary-storage-ownership-bound.md | 49 + ...017-standalone-stale-pr-clone-authority.md | 39 + ...anent-generated-artifact-failure-safety.md | 34 + ...0019-macos-file-provider-local-eviction.md | 51 + docs/architecture/adr/README.md | 8 + docs/architecture/podman-desktop-evidence.md | 135 + .../icloud-local-eviction-batch.md | 10 +- docs/product-technical-gap-baseline.md | 611 +++- src-tauri/Cargo.toml | 22 + src-tauri/resources/ontology/default.ttl | 12 + src-tauri/src/bin/disksage-cache-cleanup.rs | 135 +- .../src/bin/disksage-container-orphan-plan.rs | 390 +++ src-tauri/src/bin/disksage-dev-artifacts.rs | 278 +- src-tauri/src/bin/disksage-duplicate-audit.rs | 121 +- .../src/bin/disksage-git-clone-reclaim.rs | 227 ++ .../src/bin/disksage-git-worktree-audit.rs | 56 +- .../disksage-git-worktree-metadata-prune.rs | 356 +++ .../src/bin/disksage-git-worktree-remove.rs | 128 +- .../disksage-icloud-local-eviction-batch.rs | 25 +- .../bin/disksage-icloud-provider-recovery.rs | 163 + src-tauri/src/bin/disksage-protect-path.rs | 146 + src-tauri/src/bin/disksage-runtime-storage.rs | 122 + src-tauri/src/cache_cleanup.rs | 661 +++- src-tauri/src/cloud.rs | 3 + src-tauri/src/cloud_adr.rs | 50 +- src-tauri/src/cloud_local_eviction.rs | 226 +- src-tauri/src/cloud_local_eviction_batch.rs | 85 +- src-tauri/src/commands.rs | 217 +- src-tauri/src/container_orphan_commands.rs | 790 +++++ src-tauri/src/container_orphan_public.rs | 319 ++ src-tauri/src/container_orphan_reclaim.rs | 2700 +++++++++++++++++ src-tauri/src/dev_artifacts.rs | 597 +++- src-tauri/src/duplicate_audit.rs | 677 ++++- src-tauri/src/duplicate_audit_public.rs | 181 ++ src-tauri/src/git_clone_reclaim.rs | 655 ++++ src-tauri/src/git_worktree.rs | 1347 +++++++- src-tauri/src/git_worktree_github_evidence.rs | 129 + src-tauri/src/git_worktree_public.rs | 295 ++ src-tauri/src/icloud_provider_recovery.rs | 480 +++ src-tauri/src/icloud_sync_health.rs | 145 +- src-tauri/src/lib.rs | 40 + src-tauri/src/ontology.rs | 62 + src-tauri/src/organize.rs | 25 +- src-tauri/src/podman_desktop.rs | 547 ++++ src-tauri/src/podman_desktop_bridge.rs | 15 + src-tauri/src/provider_client_runtime.rs | 23 + src-tauri/src/provider_recovery.rs | 327 +- src-tauri/src/provider_runtime_state.rs | 19 + src-tauri/src/provider_sync.rs | 26 + src-tauri/src/reclaim.rs | 2 +- src-tauri/src/rules.rs | 177 +- src-tauri/src/runtime_storage.rs | 649 ++++ src-tauri/src/runtime_storage_commands.rs | 11 + src-tauri/src/safety.rs | 930 ++++-- src-tauri/src/safety_non_utf8_tests.rs | 23 + .../cli_help_eviction_destination_exit.rs | 8 +- .../container_orphan_build_cache_authority.rs | 39 + ...ontainer_orphan_build_cache_exact_prune.rs | 98 + ...ner_orphan_capacity_evidence_regression.rs | 85 + .../container_orphan_cli_authority_binding.rs | 59 + .../container_orphan_cli_docker_host_path.rs | 45 + .../container_orphan_cli_unbound_approval.rs | 68 + ...tainer_orphan_command_coverage_contract.rs | 50 + ...ntainer_orphan_descendant_pipe_contract.rs | 53 + .../container_orphan_docker_names_contract.rs | 69 + ...ntainer_orphan_image_reference_contract.rs | 71 + ...iner_orphan_network_identity_recreation.rs | 121 + .../container_orphan_podman_image_contract.rs | 75 + ...ontainer_orphan_podman_network_contract.rs | 119 + .../container_orphan_podman_state_contract.rs | 108 + .../tests/container_orphan_public_privacy.rs | 86 + .../container_orphan_runtime_regression.rs | 509 ++++ ...ainer_orphan_truncated_mutation_receipt.rs | 98 + ...ontainer_orphan_volume_public_authority.rs | 73 + src-tauri/tests/dev_artifact_authority.rs | 20 + ...ev_artifact_editor_extension_regression.rs | 62 + .../tests/dev_artifact_generic_build_guard.rs | 24 + ..._artifact_reversible_active_use_timeout.rs | 72 + src-tauri/tests/dev_artifacts_uv_venv314.rs | 20 + ...plicate_audit_coverage_runtime_contract.rs | 2 +- src-tauri/tests/duplicate_audit_help_exit.rs | 2 +- ...uplicate_audit_stale_photo_report_guard.rs | 100 + ...viction_cli_duplicate_singleton_process.rs | 2 +- .../tests/git_clone_reclaim_cli_contract.rs | 66 + .../tests/git_worktree_audit_help_exit.rs | 6 +- ...it_worktree_audit_shared_github_timeout.rs | 90 + ..._worktree_closed_pr_search_cap_contract.rs | 99 + .../git_worktree_command_timeout_boundary.rs | 76 + .../git_worktree_merged_pr_branch_scope.rs | 110 + src-tauri/tests/git_worktree_open_pr_veto.rs | 91 + src-tauri/tests/git_worktree_pr_commit_cap.rs | 105 + .../tests/git_worktree_remove_audit_limits.rs | 40 + .../tests/git_worktree_schema_version.rs | 83 + .../git_worktree_stale_open_membership.rs | 192 ++ ...local_eviction_batch_documentation_test.rs | 2 +- .../naruon_active_fileprovider_transfer.rs | 2 + .../tests/naruon_locked_fileprovider_item.rs | 2 + .../onedrive_eviction_admission_boundary.rs | 56 + .../onedrive_primary_runtime_boundary.rs | 75 + .../tests/onedrive_unpin_outcome_contract.rs | 21 + .../tests/podman_desktop_branch_coverage.rs | 182 ++ .../tests/podman_desktop_bridge_command.rs | 20 + ...an_desktop_candidate_review_consistency.rs | 79 + .../tests/podman_desktop_command_coverage.rs | 20 + .../podman_desktop_documentation_contract.rs | 70 + .../tests/podman_desktop_issue_privacy.rs | 62 + .../podman_desktop_physical_reclaim_claim.rs | 50 + .../podman_desktop_review_regressions.rs | 83 + .../provider_recovery_post_launch_contract.rs | 35 + .../tests/provider_runtime_state_contract.rs | 28 + .../python_tool_cache_discovery_contract.rs | 53 + .../tests/runtime_storage_async_boundary.rs | 31 + .../tests/runtime_storage_public_privacy.rs | 38 + .../tests/runtime_storage_recovery_receipt.rs | 153 + ...runtime_storage_trim_timeout_regression.rs | 79 + src/lib/BrewCleanup.svelte | 40 +- src/lib/Cleanup.svelte | 320 +- src/lib/CloudArchive.svelte | 9 +- src/lib/ContainerOrphanCleanup.svelte | 253 ++ src/lib/Duplicates.svelte | 9 +- src/lib/GitWorktreeCleanup.svelte | 121 +- src/lib/IcloudLocalEviction.copy.test.ts | 12 + src/lib/IcloudLocalEviction.svelte | 21 +- src/lib/Inventory.svelte | 28 +- src/lib/Organize.svelte | 18 +- src/lib/PodmanEvidence.svelte | 147 + src/lib/api.test.ts | 13 +- src/lib/api.ts | 269 +- .../cacheCleanupAtomicTrashContract.test.ts | 2 +- src/lib/cacheCleanupFlowContract.test.ts | 10 +- .../cacheCleanupReadOnlyUiContract.test.ts | 2 +- .../containerOrphanConfirmationLock.test.ts | 22 + src/lib/containerOrphanErrorFeedback.test.ts | 51 + src/lib/containerOrphanErrorFeedback.ts | 29 + .../containerOrphanExecutionFeedback.test.ts | 51 + src/lib/containerOrphanExecutionFeedback.ts | 14 + src/lib/containerOrphanPruneFlow.test.ts | 48 + src/lib/containerOrphanPruneFlow.ts | 33 + .../containerOrphanSafetyUiContract.test.ts | 157 + src/lib/customerActionCopyContract.test.ts | 64 + src/lib/gitWorktreeAuditApiContract.test.ts | 19 + .../gitWorktreeClosedPrOptInContract.test.ts | 33 + src/lib/podmanCleanupPrivacyContract.test.ts | 25 + src/lib/podmanEvidence.docstrings.test.ts | 20 + src/lib/podmanEvidence.error.test.ts | 74 + src/lib/podmanEvidence.test.ts | 232 ++ src/lib/podmanEvidence.ts | 386 +++ .../podmanEvidenceAssessmentPrivacy.test.ts | 83 + .../podmanEvidenceCoverageContract.test.ts | 24 + ...podmanEvidenceCustomerCopyContract.test.ts | 24 + src/lib/podmanEvidenceError.ts | 49 + .../podmanEvidenceValidatorCoverage.test.ts | 105 + ...dmanEvidenceVisualFallbackContract.test.ts | 21 + ...releaseAttestationWorkflowContract.test.ts | 2 + ...runtimeStorageCustomerCopyContract.test.ts | 24 + src/lib/runtimeStorageMaintenanceFlow.test.ts | 72 + src/lib/runtimeStorageMaintenanceFlow.ts | 48 + src/lib/verdictBadge.ts | 6 +- src/routes/+page.svelte | 3 + vitest.config.ts | 2 + 169 files changed, 22339 insertions(+), 896 deletions(-) create mode 100644 contracts/git-worktree-audit-v4.json create mode 100644 docs/PRD.md create mode 100644 docs/architecture/adr/0012-container-orphan-reclaim-runtime-agnostic.md create mode 100644 docs/architecture/adr/0013-closed-pull-request-worktree-authority.md create mode 100644 docs/architecture/adr/0014-runtime-storage-trim-without-vm-image-rewrite.md create mode 100644 docs/architecture/adr/0015-explicit-cutoff-open-pull-request-worktree-authority.md create mode 100644 docs/architecture/adr/0016-shared-temporary-storage-ownership-bound.md create mode 100644 docs/architecture/adr/0017-standalone-stale-pr-clone-authority.md create mode 100644 docs/architecture/adr/0018-permanent-generated-artifact-failure-safety.md create mode 100644 docs/architecture/adr/0019-macos-file-provider-local-eviction.md create mode 100644 docs/architecture/podman-desktop-evidence.md create mode 100644 src-tauri/src/bin/disksage-container-orphan-plan.rs create mode 100644 src-tauri/src/bin/disksage-git-clone-reclaim.rs create mode 100644 src-tauri/src/bin/disksage-git-worktree-metadata-prune.rs create mode 100644 src-tauri/src/bin/disksage-icloud-provider-recovery.rs create mode 100644 src-tauri/src/bin/disksage-protect-path.rs create mode 100644 src-tauri/src/bin/disksage-runtime-storage.rs create mode 100644 src-tauri/src/container_orphan_commands.rs create mode 100644 src-tauri/src/container_orphan_public.rs create mode 100644 src-tauri/src/container_orphan_reclaim.rs create mode 100644 src-tauri/src/duplicate_audit_public.rs create mode 100644 src-tauri/src/git_clone_reclaim.rs create mode 100644 src-tauri/src/git_worktree_github_evidence.rs create mode 100644 src-tauri/src/git_worktree_public.rs create mode 100644 src-tauri/src/icloud_provider_recovery.rs create mode 100644 src-tauri/src/podman_desktop.rs create mode 100644 src-tauri/src/podman_desktop_bridge.rs create mode 100644 src-tauri/src/provider_runtime_state.rs create mode 100644 src-tauri/src/runtime_storage.rs create mode 100644 src-tauri/src/runtime_storage_commands.rs create mode 100644 src-tauri/src/safety_non_utf8_tests.rs create mode 100644 src-tauri/tests/container_orphan_build_cache_authority.rs create mode 100644 src-tauri/tests/container_orphan_build_cache_exact_prune.rs create mode 100644 src-tauri/tests/container_orphan_capacity_evidence_regression.rs create mode 100644 src-tauri/tests/container_orphan_cli_authority_binding.rs create mode 100644 src-tauri/tests/container_orphan_cli_docker_host_path.rs create mode 100644 src-tauri/tests/container_orphan_cli_unbound_approval.rs create mode 100644 src-tauri/tests/container_orphan_command_coverage_contract.rs create mode 100644 src-tauri/tests/container_orphan_descendant_pipe_contract.rs create mode 100644 src-tauri/tests/container_orphan_docker_names_contract.rs create mode 100644 src-tauri/tests/container_orphan_image_reference_contract.rs create mode 100644 src-tauri/tests/container_orphan_network_identity_recreation.rs create mode 100644 src-tauri/tests/container_orphan_podman_image_contract.rs create mode 100644 src-tauri/tests/container_orphan_podman_network_contract.rs create mode 100644 src-tauri/tests/container_orphan_podman_state_contract.rs create mode 100644 src-tauri/tests/container_orphan_public_privacy.rs create mode 100644 src-tauri/tests/container_orphan_runtime_regression.rs create mode 100644 src-tauri/tests/container_orphan_truncated_mutation_receipt.rs create mode 100644 src-tauri/tests/container_orphan_volume_public_authority.rs create mode 100644 src-tauri/tests/dev_artifact_authority.rs create mode 100644 src-tauri/tests/dev_artifact_editor_extension_regression.rs create mode 100644 src-tauri/tests/dev_artifact_generic_build_guard.rs create mode 100644 src-tauri/tests/dev_artifact_reversible_active_use_timeout.rs create mode 100644 src-tauri/tests/dev_artifacts_uv_venv314.rs create mode 100644 src-tauri/tests/duplicate_audit_stale_photo_report_guard.rs create mode 100644 src-tauri/tests/git_clone_reclaim_cli_contract.rs create mode 100644 src-tauri/tests/git_worktree_audit_shared_github_timeout.rs create mode 100644 src-tauri/tests/git_worktree_closed_pr_search_cap_contract.rs create mode 100644 src-tauri/tests/git_worktree_command_timeout_boundary.rs create mode 100644 src-tauri/tests/git_worktree_merged_pr_branch_scope.rs create mode 100644 src-tauri/tests/git_worktree_open_pr_veto.rs create mode 100644 src-tauri/tests/git_worktree_pr_commit_cap.rs create mode 100644 src-tauri/tests/git_worktree_remove_audit_limits.rs create mode 100644 src-tauri/tests/git_worktree_schema_version.rs create mode 100644 src-tauri/tests/git_worktree_stale_open_membership.rs create mode 100644 src-tauri/tests/onedrive_eviction_admission_boundary.rs create mode 100644 src-tauri/tests/onedrive_primary_runtime_boundary.rs create mode 100644 src-tauri/tests/onedrive_unpin_outcome_contract.rs create mode 100644 src-tauri/tests/podman_desktop_branch_coverage.rs create mode 100644 src-tauri/tests/podman_desktop_bridge_command.rs create mode 100644 src-tauri/tests/podman_desktop_candidate_review_consistency.rs create mode 100644 src-tauri/tests/podman_desktop_command_coverage.rs create mode 100644 src-tauri/tests/podman_desktop_documentation_contract.rs create mode 100644 src-tauri/tests/podman_desktop_issue_privacy.rs create mode 100644 src-tauri/tests/podman_desktop_physical_reclaim_claim.rs create mode 100644 src-tauri/tests/podman_desktop_review_regressions.rs create mode 100644 src-tauri/tests/provider_recovery_post_launch_contract.rs create mode 100644 src-tauri/tests/provider_runtime_state_contract.rs create mode 100644 src-tauri/tests/python_tool_cache_discovery_contract.rs create mode 100644 src-tauri/tests/runtime_storage_async_boundary.rs create mode 100644 src-tauri/tests/runtime_storage_public_privacy.rs create mode 100644 src-tauri/tests/runtime_storage_recovery_receipt.rs create mode 100644 src-tauri/tests/runtime_storage_trim_timeout_regression.rs create mode 100644 src/lib/ContainerOrphanCleanup.svelte create mode 100644 src/lib/IcloudLocalEviction.copy.test.ts create mode 100644 src/lib/PodmanEvidence.svelte create mode 100644 src/lib/containerOrphanConfirmationLock.test.ts create mode 100644 src/lib/containerOrphanErrorFeedback.test.ts create mode 100644 src/lib/containerOrphanErrorFeedback.ts create mode 100644 src/lib/containerOrphanExecutionFeedback.test.ts create mode 100644 src/lib/containerOrphanExecutionFeedback.ts create mode 100644 src/lib/containerOrphanPruneFlow.test.ts create mode 100644 src/lib/containerOrphanPruneFlow.ts create mode 100644 src/lib/containerOrphanSafetyUiContract.test.ts create mode 100644 src/lib/customerActionCopyContract.test.ts create mode 100644 src/lib/gitWorktreeAuditApiContract.test.ts create mode 100644 src/lib/gitWorktreeClosedPrOptInContract.test.ts create mode 100644 src/lib/podmanCleanupPrivacyContract.test.ts create mode 100644 src/lib/podmanEvidence.docstrings.test.ts create mode 100644 src/lib/podmanEvidence.error.test.ts create mode 100644 src/lib/podmanEvidence.test.ts create mode 100644 src/lib/podmanEvidence.ts create mode 100644 src/lib/podmanEvidenceAssessmentPrivacy.test.ts create mode 100644 src/lib/podmanEvidenceCoverageContract.test.ts create mode 100644 src/lib/podmanEvidenceCustomerCopyContract.test.ts create mode 100644 src/lib/podmanEvidenceError.ts create mode 100644 src/lib/podmanEvidenceValidatorCoverage.test.ts create mode 100644 src/lib/podmanEvidenceVisualFallbackContract.test.ts create mode 100644 src/lib/runtimeStorageCustomerCopyContract.test.ts create mode 100644 src/lib/runtimeStorageMaintenanceFlow.test.ts create mode 100644 src/lib/runtimeStorageMaintenanceFlow.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index daae0be02..4e1594500 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,10 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_TEST_DEBUG: 0 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -46,12 +50,16 @@ jobs: - name: Install Tauri system deps run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev lsof - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: src-tauri cache-targets: false + - name: Configure private test temp + run: | + mkdir -p "$RUNNER_TEMP/disksage" + echo "TMPDIR=$RUNNER_TEMP/disksage" >> "$GITHUB_ENV" - name: Rust tests (includes unix symlink test) run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b519a8e..393538cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,140 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Port the privacy-safe Podman desktop evidence projection into the runtime-orphan stack. The + customer screen now uses a dedicated read-only IPC schema, keeps every capacity domain optional + and separate, and no longer renders the detailed internal Podman reclaim plan. +- Verify each registered worktree HEAD against same-repository GitHub PR commit membership so + squash-merged and detached intermediate commits can be classified without ancestry or branch + guesses; any exact membership in an open PR takes precedence and preserves the worktree. +- Reclaim regenerable Python tool state from `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.tox`, + and `.nox` through the existing identity, active-use, rescan, and journal safety contract; + `setup.cfg` discovery recognizes the exact tox `[tox:tox]` section. +- Reclaim downloaded Playwright browser runtimes through the same regenerable-cache contract. +- Exclude images retained by Podman/Buildah external storage containers from orphan deletion plans. +- Reclaim project-local Python 3.14 `.venv314` environments as regenerable development artifacts. + Every discovery path verifies bounded `pyvenv.cfg` metadata for Python 3.14, skips rejected + environment trees without recursively scanning them, and the + cleanup screen names each Python cache and test environment so the next action is clear. +- Release uploaded, current, idle OneDrive files through Microsoft's signed Files On-Demand + command after stopping the sync app, then restart sync and verify allocation reduction while + retaining the cloud item and the existing approval and receipt contract. Provider-wide + new-copy admission remains confined to copy/upload workflows and cannot deadlock local-space + recovery while unrelated downloads, indexing, or historical provider errors exist. If the + normal quit request stalls, DiskSage uses one bounded graceful `SIGTERM` fallback and never + force-kills the client; the stop check distinguishes the desktop app from its resident File + Provider helper. The execution path uses the already-bound File Provider item evidence instead + of making the vendor's optional `/getpin` query a second, weaker prerequisite. +- Partition iCloud eviction manifests automatically: keep freshly verified, fully uploaded local + copies in the approval batch and exclude sync-incomplete items without exposing their paths. +- Extend the same batch planner, exact fingerprint approval, live re-plan, immutable checkpoint, + and post-allocation verification contract to OneDrive Files On-Demand. The generic + `disksage-cloud-local-eviction-batch` CLI replaces the provider-specific batch command name. +- Add an explicit `--execute --permanent` development-artifact mode that physically removes only + a freshly rescanned, inactive, identity-matched generated directory and journals the irreversible + outcome; the default remains reversible OS Trash. +- Reclaim Superset's isolated HTTP and compiled-code caches while retaining cookies, local and + session storage, IndexedDB, preferences, and historical network diagnostics. +- Reclaim only VS Code, VS Code Insiders/Server, and Cursor extension directories named by each + editor's native `.obsolete` lifecycle metadata, with bounded manifests, symlink rejection, + identity revalidation, Trash, and journaling. + +- Catalog AppMap downloaded tool binaries as regenerable macOS data. Superset network diagnostics + remain separately visible for explicit review because historical logs cannot be regenerated. +- Add standalone stale-PR clone reclamation: only a clean, inactive, single-worktree clone whose + exact branch and head OID match fresh same-repository GitHub evidence can move to OS Trash. + Branch deletion, Git pruning, detached clones, dirty clones, and implicit age thresholds remain + prohibited. The same contract is available through a headless plan-first CLI with exact human + confirmation and an external append-only journal. +- Add an explicit operator-supplied cutoff for stale same-repository open pull-request worktrees + (ADR-0015). GitHub creation time, state, branch, and exact head OID are refreshed before each + removal; branches and commits remain untouched and no implicit age threshold is used. +- Expose current same-repository closed-PR and explicitly stale-open PR evidence through the + headless worktree audit/removal CLIs, with the same live re-audit and exact approval contract as + the desktop application. +- Reclaim clean, inactive worktrees for same-repository pull requests closed without merge only + when GitHub reports an exact branch-and-head match; refresh that evidence before each removal + and preserve fork, detached, dirty, active, or changed worktrees. +- Reclaim clean, inactive worktrees whose exact branch and head match a same-repository merged pull + request even when squash or rebase history does not retain that head. Closed-unmerged and merged + evidence use separate bounded GitHub queries, and merged lookup is scoped to branches currently + registered as worktrees so repositories with long merged histories remain auditable. All lookup + calls consume one shared timeout budget rather than multiplying the configured wait per branch. +- Exclude macOS Photos library packages from exact-duplicate traversal and reject a managed Photos + library selected as the scan root. External files remain auditable without interpreting Photos' + private databases and derivatives as independent duplicate-delete candidates. Reclaim also + canonicalizes every approved member immediately before mutation and fails closed if a replaced + parent symlink redirects it outside the audited root or into a managed Photos library. +- Stage each verified duplicate by filesystem identity before permanent removal, restoring rather + than deleting a pathname replacement that races the approved audit; receipts distinguish active + skips from failed removals and retain stable failure reasons. +- Apply the single GitHub evidence deadline to desktop worktree planning, desktop removal, the + removal CLI, and every mutation-boundary live re-audit instead of refreshing the timeout for + each pull-request lookup. +- Add runtime-agnostic container orphan reclamation (ADR-0012): one fail-closed engine audits + stopped containers, unreferenced images, dangling volumes, and unused custom networks across + Docker (native), Colima (`docker --context colima`), and Podman machines. Every execution + re-audits immediately before mutating and requires an approval phrase embedding the SHA-256 + fingerprint of the exact candidate identity set; running or paused containers, tagged images, + built-in networks, and attached volumes are never candidates. Exposed via the Cleanup screen + with confirmation gating, bounded rationale input, and actionable failure copy, plus a + read-only `disksage-container-orphan-plan` CLI for headless evidence. +- Report Docker dangling-image reclaim bytes from the runtime's numeric `image inspect` size, never + by converting the human-readable listing with a unit heuristic; missing or mismatched identity + evidence keeps the category blocked. +- Pin Docker-native approval and execution to the same resolved daemon endpoint so mutable context + configuration cannot redirect an approved deletion. +- Preserve an indeterminate mutation receipt after a started exact-delete command exits non-zero, + times out, or loses capture evidence; the UI directs customers to refresh instead of reporting + the partially applied operation as untouched. +- Include shared temporary storage (`/tmp`, or macOS `/private/tmp`) in the cleanup catalog. Only + current-user-owned, non-linked trees with a complete ownership walk can become identity-bound + Trash targets; the shared root and other-user/system-owned objects remain protected. +- Add Podman/Colima VM storage maintenance planning (ADR-0014): inspect guest state and offer a + bounded, exact-phrase-approved `fstrim` operation. Host VM-image compaction remains explicitly + unsupported until a runtime-native integrity proof exists; no VM image, volume, or user file is + rewritten by this feature. +- Run bounded runtime trim and recovery waits on Tauri's blocking pool so long guest maintenance + cannot occupy asynchronous command workers. +- Preserve Docker context TLS credentials by executing through the explicitly pinned context while + binding approval to the complete inspected context definition. +- Surface an approved duplicate at a deterministic sibling recovery name when its original path is + concurrently occupied, and report preservation or rollback failure explicitly. +- Detect a running but unreachable Podman/Colima guest, offer a separate exact-phrase-approved + runtime-native stop/start recovery, and re-check reachability before enabling trim. Trim receipts + now include bounded before/after host-volume evidence for the measured available-space change. + ### Changed +- Stop descending once a marker-validated development artifact is found, avoiding a second full + traversal of large nested `node_modules`, `target`, and generated index trees before cleanup. +- Keep a partially failed permanent artifact deletion in its private staging location; never restore + a partially removed tree to the live path as if it were intact. +- Require complete inactive-use evidence for every development artifact immediately before Trash, + including `node_modules`, Rust targets, generated indexes, and editor-obsolete extensions. +- Resolve macOS cache roots from the effective XDG/UV environment and observed native locations: + `~/.cache` for uv, Codex runtimes, Node, PyTorch, Prisma, and GitHub CLI, plus + `~/Library/pnpm/store` for pnpm's content-addressed store. The existing guarded cleanup keeps + identity, active-use, Trash, and journal gates; caches without an established automatic policy + remain manual-review candidates. +- Recognize macOS Trash collision-renamed cache directories only when their known base name and + cache-specific directory structure both revalidate, including uv git and pnpm registry metadata + caches; arbitrary Trash entries remain excluded from permanent purge. +- Extend the same structural purge proof to uv archive caches and pnpm v10/v11 store layouts while + preserving the bounded no-symlink traversal and pending/terminal journal records. +- Reject control characters in the Podman/Colima VM-trim rationale before any runtime probe or + receipt write, keeping maintenance records bounded and consistent with other actions. +- Fix Colima runtime-state parsing to retain validated status values before temporary JSON data is + dropped; Rust hosted test compilation now remains borrow-safe while invalid state still fails + closed. +- Clarify reclaim-domain contracts and customer actions: exact-content photo groups remain + reversible, non-identical photos require a manual comparison, and cleanup messages no longer + expose implementation details. +- Verify Podman network membership through its container listing, follow installed CLI symlinks, + hide unavailable runtime panels behind one actionable summary, terminate runtime subprocess + groups on timeout, and exclude merged history before bounding closed-PR evidence. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. - Add durable private failure records in a separate journal directory and a receipt-bound @@ -52,6 +184,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- Use macOS `NSFileManager` for reversible Trash moves so cleanup does not wait on Finder + AppleEvents or inherit a stalled Finder copy queue. +- Permit fully current-user-owned real children of the shared Unix temporary root while retaining + fail-closed protection for the root, symlinks, mixed ownership, unreadable trees, and oversized + ownership observations. - Invoke the installed Tauri CLI entry point directly in CPU and GPU release builds, preventing Windows npm argument forwarding from dropping the `--features` flag while retaining its value as an invalid positional argument. @@ -108,6 +245,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and an unknown observation is no longer treated as proof that the provider process is absent. - Hardened iCloud local-copy batch eviction with fresh per-item timestamps, deterministic planner/executor/recorder/clock seams, fail-closed immutable checkpoint handling, bounded manifest admission, symlink-safe control-path validation, and distinct operator diagnostics. - Restored the cloud-copy public documentation regression contract after a temporary repair path removed it, so CI continues to fail when the new Rust or TypeScript approval surfaces lose beginner-readable documentation. +- Align release artifact verification with the pinned `windows-2022` build matrix name, and make the container-capacity regression fixture satisfy the same runtime-health probe required in production. +- Require standalone-clone cleanup to bind a real in-root Git directory, complete audit evidence, and an external safe journal before an approved Trash move. ### Security diff --git a/contracts/git-worktree-audit-v4.json b/contracts/git-worktree-audit-v4.json new file mode 100644 index 000000000..946e8a088 --- /dev/null +++ b/contracts/git-worktree-audit-v4.json @@ -0,0 +1,8 @@ +{ + "schema_kind": "disksage.git-worktree-audit/v4", + "version": 4, + "entry_membership_fields": [ + "completed_pull_request_commit", + "open_pull_request_commit" + ] +} diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 000000000..9b238186c --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,31 @@ +# DiskSage product requirements + +## Outcome + +Help a person recover a measured disk-space target without losing irreplaceable data. The target is +an observation goal, never deletion authority. + +## Required product loop + +1. Measure allocated bytes by independent reclaim domain. +2. Show the evidence gap and the next action for every blocked candidate. +3. Require provider, relationship, runtime, or repository authority appropriate to that domain. +4. Revalidate identity and authority immediately before mutation. +5. Prefer reversible OS Trash or provider-native local eviction; retain an auditable receipt. +6. Re-measure physical capacity and continue until the target is met or all remaining bytes are + explicitly unresolved. + +## Acceptance + +- Cloud-local eviction requires current provider upload, identity, conflict, and materialization + evidence; `local-current` with `is_uploaded=false` is blocked. +- Exact duplicates use content identity. Non-identical photos require measured media evidence and + a human-selected survivor. +- Container and VM maintenance never removes active resources or rewrites raw VM images. +- Podman desktop evidence keeps configured capacity, raw logical size, host allocation, guest and + store usage, logical candidates, and verified physical reclaim separate; missing measurements + remain unavailable and the customer screen receives no local path or runtime diagnostic detail. +- Worktrees and standalone clones require fresh exact Git/GitHub authority, clean and inactive + state, explicit approval, and no branch deletion or Git pruning. +- All customer-visible text states what happened, what remains blocked, and the next safe action; + it does not expose implementation boundaries. diff --git a/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md b/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md index dbf2787bc..ca72945b2 100644 --- a/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md +++ b/docs/architecture/adr/0011-cloud-transfer-failure-and-materialization.md @@ -1,6 +1,6 @@ # ADR-0011: Failed copy evidence and placeholder-safe adoption -**Status:** Accepted +**Status:** Accepted **Date:** 2026-08-25 ## Context diff --git a/docs/architecture/adr/0012-container-orphan-reclaim-runtime-agnostic.md b/docs/architecture/adr/0012-container-orphan-reclaim-runtime-agnostic.md new file mode 100644 index 000000000..8942a37fc --- /dev/null +++ b/docs/architecture/adr/0012-container-orphan-reclaim-runtime-agnostic.md @@ -0,0 +1,106 @@ +# ADR-0012: Runtime-agnostic container orphan reclamation is identity-bound and fail-closed + +- Status: Accepted +- Date: 2026-08-26 +- Scope: `src-tauri/src/container_orphan_reclaim.rs`, Tauri commands + `inspect_container_orphans` / `execute_container_orphan_prune`, + CLI `disksage-container-orphan-plan`, Cleanup screen panel. + +## Context + +DiskSage already audits Podman guest/raw allocation evidence (ADR-0004 lineage of bounded +maintenance execution), but its only executable container boundary prunes dangling Podman +images. Users on Docker Desktop, Colima, and mixed Docker/Podman setups had no equivalent, +and none of the four orphan categories (stopped containers, unreferenced images, dangling +volumes, unused custom networks) was auditable uniformly. Deleting any of these resources is +irreversible — there is no Trash for a container engine store — so the same fail-closed, +identity-bound discipline that governs worktree removal and cache cleanup must apply. + +## Decision + +1. One engine covers three runtime targets: plain `docker` (native context), + `docker --context colima` (Colima-managed socket), and + `podman --connection ` (running machine). Scope names are validated to reject + option injection (`unsafe-runtime-scope-name`). +2. The audit pass is read-only, wall-clock-bounded, output-capped, and tolerant of both + NDJSON (Docker) and JSON-array (Podman) envelopes. Any malformed record, unknown + container state, missing reference count, or oversized listing fails the category closed. +3. Candidates are strictly defined: + - containers in `exited`/`created`/`dead` states only; + - images with proven zero references from the complete current container set, whether tagged + or untagged; full IDs are revalidated immediately before exact deletion; + - dangling volumes reported by the runtime's own `dangling=true` filter; + - custom networks excluding built-ins (`bridge`, `host`, `none`, `podman`) whose inspect + proves zero attached endpoints, bounded at 64 probes per audit. + - Docker image reclaim bytes come only from a numeric `docker image inspect` `Size` for the + already-authorized full IDs. The human-readable `docker image ls` size is never converted by + a unit heuristic; missing, duplicate, or mismatched inspect identities fail the category + closed. +4. Every execution requires a fresh re-audit at execution time; the approval phrase embeds a + SHA-256 fingerprint of the exact sorted candidate identity set. A stale phrase, empty + candidate set, incomplete evidence, duplicate identity, or candidate set above the bounded + exact-delete limit aborts before any mutation. + A direct Docker host is passed explicitly with `--host` and is the only Docker target that can + acquire mutation authority. Named/default Docker contexts and the fixed Colima context remain + read-only: their effective context may be inspected, but no approval phrase or prune command is + issued because a later context resolution could redirect deletion. A fingerprint of the + inspected context definition is retained as read-only evidence without disclosure. +5. Mutation uses only exact identities produced by that fresh audit (`container rm`, `image rm`, + `volume rm`, or `network rm`). Category-wide `prune --force` is forbidden because a resource + that becomes orphaned after the audit is not part of the approved fingerprinted set. Candidate + identities remain private execution state: serialized plans and receipts expose only the + fingerprint and a redacted `` command marker. +6. The receipt records bounded command output plus before/after host free-space observation. + Physical reclaim remains attribution-weak and is never claimed as proof. Once the exact-delete + subprocess starts, non-zero exits, timeouts, and capture failures return an indeterminate receipt + because an earlier identity may already have been removed. + +## Consequences + +- Positive: one mental model and one UI surface cover Docker, Colima, and Podman; evidence + and receipts are schema-compatible with the existing Podman plan. +- Negative: default/named Docker contexts and Colima are currently audit-only. Their resources + remain visible for evidence, but cannot be reclaimed until an immutable context/TLS binding is + implemented and reviewed. +- Positive: approval and deletion authority now refer to the same exact resource identities; + resources that become orphaned after the fresh audit cannot be swept into the mutation. +- Negative: exact deletion is capped at 64 candidates per category per execution so command + length and mutation scope remain bounded. Larger candidate sets fail closed and must be + reduced before a new audited execution. +- Neutral: no Figma redesign was required; the panel reuses Cleanup-screen patterns. If the + Cleanup information architecture is redesigned later, record the Figma File ID in the + superseding ADR first. + +## Rejected alternatives + +- Whole-category prune (`docker ... prune --force`, Podman equivalent): rejected because the + runtime can delete a resource that becomes orphaned after the re-audit but was never part of + the approved candidate fingerprint. +- Per-ID shell loops: rejected because repeated independent process launches enlarge partial- + success ambiguity. DiskSage instead submits the bounded exact identity set in one runtime + invocation and records the bounded result. +- Trusting cached UI plans: rejected; stale plans are the primary footgun this design + eliminates via mandatory re-audit. +- Auto-detecting Colima by spawning the `colima` binary: rejected to keep the runtime + surface to two binaries (`docker`, `podman`) with explicit contexts. +- Allowing mutation through a mutable named/default context: rejected because the approved + identity could resolve to a different daemon at execution time. The current product keeps + these contexts read-only until immutable context binding exists. + +## Evidence + +- Rust unit tests cover envelope tolerance, ID normalization, classification fail-closed + branches, network endpoint inspection shapes, fingerprint order-independence, scope + validation, exact-delete candidate bounds, and redacted command construction. +- Runtime integration tests execute a fake Docker boundary and require an approved container + execution to invoke `container rm ` while explicitly rejecting any + category-wide `prune` invocation. +- Frontend contract tests bind visible copy to non-target guarantees, exact phrase + rationale + gating, confirmation dialog, post-execution state invalidation, and assistive-technology + announcements with actionable copy only. + +## References + +Docker, Inc. (2026). *docker image inspect*. Docker Docs. https://docs.docker.com/reference/cli/docker/image/inspect/ + +Docker, Inc. (2026). *docker image ls*. Docker Docs. https://docs.docker.com/reference/cli/docker/image/ls/ diff --git a/docs/architecture/adr/0013-closed-pull-request-worktree-authority.md b/docs/architecture/adr/0013-closed-pull-request-worktree-authority.md new file mode 100644 index 000000000..217a0a96b --- /dev/null +++ b/docs/architecture/adr/0013-closed-pull-request-worktree-authority.md @@ -0,0 +1,55 @@ +# ADR-0013: Bind closed pull-request worktree cleanup to forge evidence + +**Status:** Accepted +**Date:** 2026-08-27 + +## Context + +Git reachability proves that a worktree commit is merged into a retained ref, but squash and rebase +merges need not retain the pull-request head in that ancestry. It also cannot prove that an unmerged +pull request was closed. Branch age, a missing upstream, or a deleted remote branch would be +unsupported heuristics. GitHub CLI exposes structured pull-request state and head identity. + +## Decision + +When the operator includes closed pull requests, DiskSage obtains bounded structured evidence from +the authenticated GitHub CLI. A clean, inactive secondary worktree is eligible only when: + +1. the PR state is exactly `CLOSED` or `MERGED`; merged evidence is queried only for branch names + registered in the current Git worktree list, so repository-wide merged history cannot crowd the + bounded authority set; all forge queries share one overall timeout budget; +2. the PR is from the same repository, not a fork; +3. GitHub search discovers candidate PRs for each exact registered worktree HEAD, and DiskSage + independently verifies that SHA against the candidate PR's paginated commit list; +4. any verified membership in an open PR vetoes removal, even when the same SHA also occurs in a + closed or merged PR; +5. the worktree is not primary, selected, locked, prunable, dirty, active, or a retained tip; and +6. the same evidence is refreshed immediately before deletion and remains bound to the approved + removal-plan fingerprint. + +Detached worktrees may qualify through exact completed-PR commit membership, including an +intermediate commit, without relying on a branch name or ancestry that squash/rebase can rewrite. +Incomplete, malformed, timed-out, unauthenticated, truncated, or repository-mismatched forge +evidence fails closed. Branches and commits remain; only the registered worktree folder is removed. +Runtime diagnostics are not returned across the customer-visible boundary. + +## Consequences + +- Merged worktree cleanup remains available through retained-ref ancestry or exact forge evidence. +- Closed-but-unmerged cleanup requires an authenticated GitHub connection and explicit selection. +- Fork PR worktrees require manual review because their local branch identity is not authoritative. +- Another forge can later supply its own authoritative adapter without weakening this contract. + +## Rejected alternatives + +- Branch age, upstream absence, and remote-branch deletion are not PR-state evidence. +- Unverified OID-only matching is insufficient; a SHA must be rebound to an exact same-repository + PR commit list and open membership always wins. +- Deleting branches or commits is outside the worktree-folder cleanup authority. + +## Reference + +GitHub. (2026). *GitHub CLI manual: gh pr list*. https://cli.github.com/manual/gh_pr_list + +GitHub. (2026). *REST API endpoints for pull request commits*. +https://docs.github.com/rest/pulls/pulls#list-commits-on-a-pull-request diff --git a/docs/architecture/adr/0014-runtime-storage-trim-without-vm-image-rewrite.md b/docs/architecture/adr/0014-runtime-storage-trim-without-vm-image-rewrite.md new file mode 100644 index 000000000..fc9ca6143 --- /dev/null +++ b/docs/architecture/adr/0014-runtime-storage-trim-without-vm-image-rewrite.md @@ -0,0 +1,56 @@ +# ADR-0014: Trim guest extents without rewriting Podman or Colima VM images + +**Status**: Accepted +**Date**: 2026-08-28 + +The desktop dispatches bounded trim and recovery subprocess waits through Tauri's blocking-task +pool so a slow guest operation cannot occupy an asynchronous command worker. +**Scope**: `src-tauri/src/runtime_storage.rs`, Tauri commands, Cleanup screen + +## Context + +The host can be full even when Podman or Colima reports reclaimable logical bytes. A VM-backed +runtime keeps its own filesystem and may use a sparse disk image, so logical `system df` values are +not proof of host allocation. Rewriting or compacting a raw VM image while the runtime is active +could corrupt running workloads and data-bearing volumes. + +## Decision + +1. DiskSage exposes a read-only plan for the Podman machine and Colima independently. The plan + records executable availability, running state, guest reachability, and a deterministic + approval phrase. +2. After a fresh plan and explicit rationale, DiskSage may run only the fixed guest command + `sudo fstrim -av` through `podman machine ssh` or `colima ssh`. The command is bounded and its + output is returned as a receipt; no user path or image bytes are accepted as input. +3. If a running guest is unreachable, trim remains blocked. A separate, explicitly approved + recovery action may run only the runtime-native stop/start sequence, warns that running work + can be interrupted, and must prove guest reachability again before trim becomes available. +4. Host-image compaction is reported as unsupported unless a future runtime-native, integrity- + checked API is added. DiskSage never invokes `qemu-img`, deletes a VM image, stops a runtime, + or removes a volume as part of trim. +5. Trim captures host-volume observations immediately before and after execution. The UI reports + only the measured available-space change and does not infer that all of the change came from + trim. + +## Consequences + +- Users can reclaim guest filesystem extents without risking active VM images. +- The UI distinguishes “게스트 정리 완료” from host-image compression and never promises 300 GB + when current measurements do not support it. +- Raw-image compaction remains an explicit external maintenance task until a provider-supported + operation can be verified and bound to an approval record. + +## Rejected alternatives + +- Rewriting or truncating Podman/Colima raw images: rejected because active stores and sparse + extents cannot be proven safe from a path or file-size snapshot. +- `system prune --volumes` and category-wide deletion: rejected; named volumes may contain + databases and are handled only by the identity-bound orphan planner. + +## Evidence + +- Rust tests verify fixed command construction and fail-closed unavailable-runtime plans. +- Recovery and trim use distinct approvals; reachability is included in the plan fingerprint so a + stale recovery or trim plan cannot authorize execution after guest state changes. +- The existing container-orphan and Podman planners remain the authority for exact image, + volume, network, and container candidates; this ADR adds no alternate deletion path. diff --git a/docs/architecture/adr/0015-explicit-cutoff-open-pull-request-worktree-authority.md b/docs/architecture/adr/0015-explicit-cutoff-open-pull-request-worktree-authority.md new file mode 100644 index 000000000..5b68648c1 --- /dev/null +++ b/docs/architecture/adr/0015-explicit-cutoff-open-pull-request-worktree-authority.md @@ -0,0 +1,45 @@ +# ADR-0015: Require an explicit cutoff for stale open pull-request worktrees + +## Context + +An open pull request can leave a local worktree behind even when its branch is no longer being +used. Git reachability and filesystem timestamps do not establish that the worktree is safe to +remove: the pull request may still receive commits, and a local branch can be intentionally +retained. DiskSage must support this cleanup without inventing an age threshold or exposing +implementation details in customer-facing copy. + +## Decision + +The operator may opt in to open-pull-request cleanup by entering an explicit UTC calendar cutoff. +DiskSage queries the authenticated GitHub CLI at plan time and again immediately before each +removal. Only an OPEN pull request from the same repository whose `createdAt` is strictly before +the supplied cutoff, whose exact head branch and OID match the local worktree, and whose worktree +passes every existing clean, inactive, non-primary, non-retained, non-locked, and complete-evidence +gate may authorize removal of the registered worktree directory. Fork pull requests, missing or +malformed timestamps, stale provider responses, and drift fail closed. Branches and commits are +never deleted. + +No default age, filename date, filesystem mtime, upstream absence, or arbitrary score is used. +The cutoff and exact PR head set are included in the removal authority fingerprint, so approval +cannot be replayed after the forge evidence changes. + +## Consequences + +- Customers choose the policy boundary instead of receiving a hidden heuristic. +- A plan records the cutoff and the exact same-repository PR evidence used to produce it. +- The GitHub CLI is an optional, explicit evidence source; without it, the existing merged-history + and manual review paths remain available. +- Open pull requests created after the cutoff are preserved until a later, newly approved plan. + +## Rejected alternatives + +- Automatically deleting worktrees older than a fixed number of days: no user-authorized basis. +- Deleting local branches or commits: exceeds the worktree-folder cleanup responsibility. +- Treating a missing remote branch or filesystem timestamp as pull-request state: not authoritative. + +## Evidence + +- GitHub REST/CLI pull-request state, head OID, repository identity, and `createdAt` are refreshed + at each mutation boundary. +- The implementation and tests live in `src-tauri/src/git_worktree.rs` and + `src/lib/GitWorktreeCleanup.svelte`. diff --git a/docs/architecture/adr/0016-shared-temporary-storage-ownership-bound.md b/docs/architecture/adr/0016-shared-temporary-storage-ownership-bound.md new file mode 100644 index 000000000..491f16526 --- /dev/null +++ b/docs/architecture/adr/0016-shared-temporary-storage-ownership-bound.md @@ -0,0 +1,49 @@ +# ADR-0016: Bound shared temporary storage cleanup to ownership evidence + +**Status:** Accepted +**Date:** 2026-08-28 + +## Context + +The current low-disk incident includes space under the shared temporary directory. On macOS, +`/tmp` is a symlink to `/private/tmp`; on other Unix platforms the shared path is `/tmp`. The +existing catalog only exposed the process-specific temporary directory, so a user could not review +the shared temporary bytes through DiskSage. A shared directory also contains objects belonging to +other users and system services, so path presence or modification time is not deletion authority. + +## Decision + +Add a `shared-temp` inspection entry for the platform's real shared temporary root when it is not +already the process temporary directory. A direct child becomes a cleanup candidate only when: + +- the root is a real directory and the child is not a symbolic link; +- every object in the child tree is owned by the current effective user and is readable for the + bounded ownership walk; +- per-item active-use evidence is complete and idle; and +- the existing filesystem identity, size, recheck, journal, and OS-Trash gates succeed. + +The shared root itself, foreign/system-owned trees, linked objects, and incomplete ownership walks +remain protected. The candidate's displayed bytes are the sum of the ownership-qualified children, +not an estimate for the whole shared directory. No age threshold or quality heuristic is used. + +## Consequences + +- `/tmp`/`/private/tmp` space is visible as a separate reclaim domain and can be reclaimed through + the reversible Trash path when evidence is complete. +- A system or another user's temporary object cannot be selected by this catalog, even when it is + large or old. +- Ownership traversal adds bounded inspection work; over-limit or unreadable trees remain visible + only as unresolved shared temporary space. + +## Alternatives rejected + +- **Expose only the process temporary directory:** hides the incident's shared temporary bytes. +- **Allow every child under `/tmp`:** grants a shared system directory deletion authority. +- **Delete by age or filename:** uses a heuristic without proving ownership or active use. +- **Permanently delete temporary entries:** bypasses the existing reversible, journaled Trash path. + +## References + +- [ADR-0002: Cache cleanup is per-item active-use evidence bound](0002-cache-cleanup-is-per-item-evidence-bound.md) +- `src-tauri/src/rules.rs` +- `src-tauri/src/safety.rs` diff --git a/docs/architecture/adr/0017-standalone-stale-pr-clone-authority.md b/docs/architecture/adr/0017-standalone-stale-pr-clone-authority.md new file mode 100644 index 000000000..85f8588da --- /dev/null +++ b/docs/architecture/adr/0017-standalone-stale-pr-clone-authority.md @@ -0,0 +1,39 @@ +# ADR-0017: Standalone stale-PR clones require exact-head authority + +- Status: Accepted +- Date: 2026-08-28 + +## Context + +Secondary worktrees can be removed safely under ADR-0013 and ADR-0015, but their audit deliberately +preserves the primary checkout. A separate clone left on a PR head therefore remained invisible. + +## Decision + +DiskSage may propose a standalone clone only when it has exactly one registered worktree, a clean +working tree, complete recursive active-use and size evidence, a real `.git` directory directly +bounded by the canonical clone root, and a fresh same-repository GitHub branch-and-head match for a +closed PR or an operator-supplied stale-open cutoff. The complete audit must itself report no +evidence gap. Execution requires the exact plan phrase, a create-new approval record outside the +clone, a journal destination outside the clone, re-resolves every authority input, verifies the +filesystem object identity, and moves the clone to OS Trash. If the Trash move cannot complete, the +existing safety layer restores the staged object and retains the journaled failure for recovery. + +DiskSage never invents an age threshold, deletes the branch, runs `git prune`, handles fork or +detached heads, or reports physical capacity as reclaimed before Trash is emptied. + +## Consequences + +A changed, dirty, active, linked, protected, or unverifiable clone fails closed. The user must empty +Trash before the operating system can expose the capacity. + +## Rejected alternatives + +Directory age, clone folder names, local branch names alone, and automatic branch deletion are not +authority because each can destroy current or unpublished work. + +## Evidence + +The decision reuses the exact Git registration, status, retained-reference, same-repository GitHub +PR state and head OID, bounded size, active-use, and filesystem identity evidence already accepted +by ADR-0013 and ADR-0015. diff --git a/docs/architecture/adr/0018-permanent-generated-artifact-failure-safety.md b/docs/architecture/adr/0018-permanent-generated-artifact-failure-safety.md new file mode 100644 index 000000000..4dc53d8b6 --- /dev/null +++ b/docs/architecture/adr/0018-permanent-generated-artifact-failure-safety.md @@ -0,0 +1,34 @@ +# ADR-0018: Retain failed permanent generated-artifact deletions in private staging + +- Status: Accepted +- Date: 2026-08-29 + +## Context + +An explicitly approved permanent cleanup may move a regenerated development artifact into a private +sibling staging directory before recursive deletion. Recursive deletion is not atomic: a filesystem +error can leave only part of the staged tree removed. Restoring that partial tree to its original +path would present damaged generated state as a live artifact. + +## Decision + +After the identity-bound staging move, a permanent deletion failure retains the remaining staged +tree in the private staging directory and returns a failure. DiskSage never restores a partially +removed tree to the original path. The journal records the pending operation and terminal error; +only a complete recursive deletion is successful. The normal Trash path remains reversible. + +## Consequences + +- A failed permanent cleanup cannot replace a live path with a partial generated tree. +- Remaining staged bytes stay available for forensic recovery or regeneration until explicitly handled. +- The private staging location is not success evidence and never authorizes a cloud or user-file action. + +## Alternatives rejected + +Restoring after `remove_dir_all` fails is rejected because the directory may already be partial. +Deleting the staging directory on failure is rejected because it discards the remaining recovery data. + +## Evidence + +`src-tauri/src/safety.rs` rechecks filesystem identity before staging, journals pending and terminal +outcomes, and tests that a simulated partial recursive-delete failure leaves the partial tree staged. diff --git a/docs/architecture/adr/0019-macos-file-provider-local-eviction.md b/docs/architecture/adr/0019-macos-file-provider-local-eviction.md new file mode 100644 index 000000000..b2d4389d5 --- /dev/null +++ b/docs/architecture/adr/0019-macos-file-provider-local-eviction.md @@ -0,0 +1,51 @@ +# ADR 0019: macOS File Provider local eviction + +- Status: Accepted +- Date: 2026-08-29 + +## Context + +DiskSage can prove that selected iCloud and OneDrive items are uploaded, current, idle, and locally +allocated. OneDrive's supported macOS **Free up space** action was not executable through DiskSage. + +## Decision + +DiskSage may release a locally materialized iCloud or OneDrive file only after the existing exact +path, allocation, uploaded/current, conflict, provider capability, item identity, and active-use +checks pass and a human approves the exact plan fingerprint. iCloud continues +to use Foundation's ubiquitous-item eviction. OneDrive uses Microsoft's signed Files On-Demand +command: DiskSage asks the verified desktop app to quit and, if the bounded wait expires, may issue +one graceful `SIGTERM` request; it never uses `SIGKILL`. The stop check observes the primary app, +not its resident File Provider helper. Once the app is stopped it requests `/unpin` and restarts +the app. DiskSage does not depend on the optional `/getpin` query because the existing File Provider +item evidence already binds the exact identity, current state, and eviction capability. A +provider-wide new-copy admission check is intentionally +not reused here: it governs adding new cloud copies, while Microsoft's documented `/unpin` flow +requires the sync app to be stopped and exists to release an already uploaded local copy. Exact +item evidence still fails closed. The result must retain the path and show a reduced +allocation before DiskSage reports verification complete. + +Google Drive remains blocked until its provider behavior is verified against the same contract. +OAuth is not required for local cache eviction because the signed-in desktop File Provider owns +that operation. Deleting, moving, or trashing the visible cloud item is never an eviction fallback. + +## Consequences + +Unsynced item edits, provider mismatch, non-evictable items, open handles, +incomplete evidence, restart failure, and unchanged post-action allocation fail closed. OneDrive +uses the same bounded batch fingerprint, per-item re-plan, immutable checkpoint, and stop-on-first- +failure contract as iCloud without weakening either provider's native execution boundary. + +## Rejected alternatives + +- Calling the iCloud ubiquitous-item API for OneDrive: the ownership contract is wrong. +- Cross-provider `NSFileProviderManager` eviction: macOS rejects access to another provider's + registered domain, so it cannot execute OneDrive's operation. +- Finder UI automation: it is not an identity-bound or deterministic execution boundary. +- OAuth or direct deletion: neither is necessary for local cache eviction, and deletion changes the + cloud object. + +## References + +Microsoft. (2026). *Deploy and configure OneDrive on macOS*. Microsoft Learn. +https://learn.microsoft.com/en-us/sharepoint/files-on-demand-mac diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index e6735fc57..67ce3a214 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -17,6 +17,14 @@ new numbered record rather than rewriting history. | [0009](0009-path-free-lineage-relation-graph.md) | Export a path-free lineage relation graph | Accepted | | [0010](0010-rooted-organize-destinations.md) | Require rooted, process-independent organize destinations | Accepted | | [0011](0011-cloud-transfer-failure-and-materialization.md) | Durable failed-copy evidence and placeholder-safe adoption | Accepted | +| [0012](0012-container-orphan-reclaim-runtime-agnostic.md) | Runtime-agnostic container orphan reclamation is identity-bound and fail-closed | Accepted | +| [0013](0013-closed-pull-request-worktree-authority.md) | Bind closed pull-request worktree cleanup to forge evidence | Accepted | +| [0014](0014-runtime-storage-trim-without-vm-image-rewrite.md) | Trim guest extents without rewriting VM images | Accepted | +| [0015](0015-explicit-cutoff-open-pull-request-worktree-authority.md) | Require an explicit cutoff for stale open pull-request worktrees | Accepted | +| [0016](0016-shared-temporary-storage-ownership-bound.md) | Bound `/tmp` cleanup to current-user-owned trees | Accepted | +| [0017](0017-standalone-stale-pr-clone-authority.md) | Require exact-head authority for standalone stale-PR clones | Accepted | +| [0018](0018-permanent-generated-artifact-failure-safety.md) | Retain failed permanent artifact deletions in private staging | Accepted | +| [0019](0019-macos-file-provider-local-eviction.md) | Use each macOS File Provider domain for local-only eviction | Accepted | New records must state context, decision, consequences, rejected alternatives, and the evidence or standard that led to the decision. A record never grants cloud-write or source-eviction authority; diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md new file mode 100644 index 000000000..5946cd518 --- /dev/null +++ b/docs/architecture/podman-desktop-evidence.md @@ -0,0 +1,135 @@ +# ADR: Privacy-safe Podman desktop evidence + +- **Status:** Proposed +- **Date:** 2026-08-05 +- **Decision owners:** DiskSage maintainers +- **Related issue:** #107 +- **Related headless contract:** #105 and `src-tauri/src/podman_reclaim.rs` + +## Context + +DiskSage already has a Rust-first, read-only Podman evidence probe that distinguishes VM configuration, raw-image logical size, host allocation, guest filesystem usage, Podman graph-root observations, and Podman-reported logical cleanup candidates. The desktop Cleanup experience previously had no supported way to inspect that evidence. + +The UI must not turn evidence into authority. Podman documents that image reclaimable values can overstate what a prune would actually free when layers are shared. DiskSage therefore treats all `podman system df` candidate values as logical review evidence rather than verified host physical reclaimability. + +The headless report also contains local-only details such as machine names, configuration paths, raw-image paths, graph-root paths, and dynamic command errors. Those details are useful for local diagnosis but are unnecessary for the desktop summary and unsafe for telemetry or shareable evidence. Tauri transport failures and arbitrary JavaScript rejection values can also contain account-local paths, socket names, or command detail, so the UI error boundary must redact them independently of the Rust projection. + +## Decision + +### 1. Add a separate privacy projection + +`src-tauri/src/podman_desktop.rs` converts `PodmanReclaimPlan` into `PodmanDesktopEvidence`. + +The projection includes only: + +- configured machine disk bytes; +- raw-image logical bytes; +- host allocated bytes; +- guest total, used, and available bytes; +- Podman graph-root allocated and used bytes; +- image, stopped-container, and volume logical candidate bytes; +- unused-image and stopped-container counts; +- the SHA-256 commitment to the exact unused-image candidate set; +- evidence completeness, elapsed time, stable reason codes, and stable issue codes; +- separate image, stopped-container, and volume review boundaries; +- `physically_reclaimable_bytes`, which remains unknown until a before-and-after host observation proves it. + +The projection excludes machine names and states; configuration, raw-image, and graph-root paths; image identifiers and tags; account-local context; command output and dynamic error details; and any mutation command or approval record. + +Issue strings are reduced to the prefix before the first colon only when that prefix is a bounded lowercase kebab-case code: it must start with a lowercase ASCII letter, contain only lowercase ASCII letters, digits, or hyphens, and be no longer than 96 bytes. Delimiter-free paths, sockets, whitespace, uppercase text, Unicode, underscores, empty prefixes, and malformed values collapse to `podman-evidence-error`. Invalid candidate fingerprints fail closed: the fingerprint is removed, the evidence is marked incomplete, and a stable issue code is added. + +A complete exact-image observation must contain both the exact unused-image record count and the SHA-256 commitment to that candidate set. The frontend rejects complete evidence when either member is missing and rejects a fingerprint that has no exact record observation. Partial evidence may retain safe exact-record counts after Rust removes an invalid fingerprint and emits an issue; this remains explicitly incomplete rather than being mislabeled as a complete candidate set. + +Any projected issue code forces `evidence_complete` to false, even when an upstream caller incorrectly supplies `true`. The frontend independently rejects a response that combines `evidence_complete: true` with one or more issue codes. This keeps completeness as an integrity assertion rather than a cosmetic label. + +The only assessment status admitted by schema version 1 is `unverified`. If a contradictory headless plan supplies a concrete `physically_reclaimable_bytes` value while the assessment remains unverified, the Rust projection clears that value before IPC, marks the evidence incomplete, and emits `podman-desktop-unverified-physical-reclaim-claim`. A future verified physical-reclaim contract requires an explicit schema and evidence-authority change; it cannot appear by silently forwarding a new headless value. + +The two user-facing safety notices are also part of schema version 1 rather than arbitrary display text. The frontend accepts only those two exact statements in the defined order and count. Any modified, duplicated, reordered, additional, path-bearing, or otherwise noncanonical notice fails closed with `invalid-notices` instead of being rendered. + +The platform field is also schema-bound because it appears in the user interface. Schema version 1 admits only the Tauri desktop targets `linux`, `macos`, and `windows`. Unsupported, path-bearing, machine-specific, or account-specific platform text fails closed with `invalid-platform` rather than becoming visible evidence. + +### 2. Keep the Tauri command read-only and argv-based + +`inspect_podman_reclaim` invokes the existing Rust probe using an executable plus an argument vector. It does not construct a shell string. The desktop surface exposes no prune, remove, machine start/stop, VM deletion, TRIM, raw-image mutation, or generic command execution path. + +### 3. Keep review domains independent and conservative + +Images, stopped containers, and local volumes have separate review booleans and separate UI sections. A review signal for one domain never authorizes another domain. This preserves future compatibility with distinct approval records and least-privilege workflows. + +A positive candidate observation itself conservatively requires review in its own domain, even if an upstream assessment accidentally omits the corresponding recommended-action record. Rust derives the image, stopped-container, and volume review booleans from both the action list and the observed candidates. The frontend independently rejects a candidate domain whose required review boolean is false. An extra conservative `true` remains advisory only and never creates mutation authority. + +### 4. Keep visual semantics explicit, accessible, and privacy-safe + +The panel uses semantic headings, definition lists, buttons, `role="status"` for progress and results, and `role="alert"` for errors. The UI never uses color as the only carrier of completeness. Text labels always state whether evidence is complete or partial. + +The UI never renders `String(reason)` or another untrusted exception representation. `podmanEvidenceErrorMessage` discards every transport, operating-system, and JavaScript failure detail and returns only `podman-evidence-unavailable`. Detailed diagnosis remains confined to trusted local logs and does not cross into the desktop evidence, telemetry, or shareable-evidence boundary. + +### 5. Preserve standalone and MSA compatibility + +The desktop response is a versioned JSON contract with no dependency on Naruon or another CWL service. DiskSage runs independently. A future Naruon or fleet-management adapter may consume the same privacy-safe schema without receiving local paths or identifiers. + +## Consequences + +### Positive + +- Buyers can inspect a concrete Podman storage gap from the main Cleanup workflow. +- Logical size, host allocation, guest use, and verified physical reclaimability cannot be silently conflated. +- Contradictory unverified physical-reclaim claims are removed in Rust before IPC rather than relying on frontend refusal. +- Local identifiers stay outside the frontend contract, telemetry, and shareable evidence boundary. +- Malformed or delimiter-free probe issues cannot masquerade as safe codes or serialize local path content. +- Any issue forces partial evidence in Rust, and the frontend refuses contradictory complete-plus-issues payloads. +- Complete exact-image evidence cannot omit or detach its candidate-set commitment. +- Positive candidates cannot be displayed with a false no-review signal in their own domain. +- Arbitrary notice or platform text cannot become a path, machine-name, or account-detail display channel. +- Transport and JavaScript failures cannot leak machine names, paths, sockets, or command detail through the visible error region. +- The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. +- Module-level `missing_docs` enforcement and source-level documentation contracts keep the Podman desktop functions beginner-readable. + +### Negative + +- The UI intentionally cannot perform cleanup. Operators must use a separate reviewed workflow until a mutation design includes exact candidate binding, independent approval, rollback evidence, and before-and-after host verification. +- Some evidence remains unavailable when Podman is absent, the machine is stopped, or the API is unhealthy. Unknown values remain `null`; the UI never converts missing evidence to zero. +- Visible failures intentionally use a stable generic code; sensitive operational detail must be inspected through trusted local diagnostics rather than the shareable desktop surface. +- Notice wording, supported platform identifiers, candidate/fingerprint relations, and review-boundary semantics are schema-bound; changing them requires coordinated Rust/frontend contract review rather than a copy-only UI edit. + +## Verification matrix + +| Invariant | Deterministic evidence | +|---|---| +| No machine names or paths in desktop JSON | Rust serialization tests search for private fixture values | +| Delimiter-free or malformed issue text cannot cross IPC | Rust unit and integration tests expect `podman-evidence-error` | +| Any projected issue forces partial evidence | `podman_desktop_issue_privacy.rs` contradicts upstream completeness and requires false | +| Complete-plus-issues payloads are rejected | TypeScript parser regression expects `inconsistent-evidence-completeness` | +| Complete exact-image evidence requires its fingerprint | TypeScript parser regression expects `inconsistent-image-candidate-fingerprint` | +| A fingerprint cannot exist without exact image records | TypeScript parser regression rejects detached commitments even for partial evidence | +| Observed candidates conservatively require domain review | `podman_desktop_candidate_review_consistency.rs` omits actions and requires all three review booleans | +| Candidate-plus-false-review payloads are rejected | TypeScript parser regressions cover image, stopped-container, and volume domains separately | +| Unverified physical-reclaim claims cannot cross IPC | `podman_desktop_physical_reclaim_claim.rs` requires removal, incomplete evidence, and a stable issue code | +| Arbitrary or duplicated notices cannot reach the UI | TypeScript parser regression requires the exact schema-v1 notice sequence | +| Unsupported or path-bearing platform values cannot reach the UI | TypeScript parser regression admits only `linux`, `macos`, and `windows` | +| Image/container/volume review separation | Rust projection tests and TypeScript view-model tests | +| Invalid fingerprint fails closed | Rust and TypeScript malformed-fingerprint tests | +| Missing observations stay unknown | Rust and TypeScript null-preservation tests | +| Exact Tauri command contract | Rust public-command integration test and mocked TypeScript invoke test | +| Schema/type/range drift rejected | TypeScript parser tests | +| Untrusted failure details never reach visible UI | `podmanEvidence.error.test.ts` supplies path, socket, object, null, and undefined failures and expects one stable code | +| Progress and errors announced | Svelte markup uses `role="status"` and `role="alert"` | +| No mutation surface | Registered command list exposes inspection only | +| Beginner-readable frontend function documentation | Source-level JSDoc regression test checks every production function declaration | +| Beginner-readable Rust function documentation | `missing_docs` plus `podman_desktop_documentation_contract.rs` | + +## Release acceptance + +This slice is release-eligible only after the exact integrated head passes Rust formatting and tests; frontend unit tests and exact coverage; Svelte type checking and production build; security and SAST workflows; current-head review with no unresolved actionable finding; actual repository/governance review policy; and packaging, provenance, and release acceptance. + +## References + +Podman. (n.d.). *podman-machine-inspect—Inspect one or more virtual machines*. Retrieved August 5, 2026, from https://docs.podman.io/en/stable/markdown/podman-machine-inspect.1.html + +Podman. (n.d.). *podman-system-df—Show Podman disk usage*. Retrieved August 5, 2026, from https://docs.podman.io/en/latest/markdown/podman-system-df.1.html + +Tauri Programme within The Commons Conservancy. (2026). *Calling Rust from the frontend*. https://v2.tauri.app/develop/calling-rust/ + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2025). *Understanding Success Criterion 4.1.3: Status messages*. https://www.w3.org/WAI/WCAG22/Understanding/status-messages diff --git a/docs/development/icloud-local-eviction-batch.md b/docs/development/icloud-local-eviction-batch.md index 6f2a8c9c2..1fd84d3f1 100644 --- a/docs/development/icloud-local-eviction-batch.md +++ b/docs/development/icloud-local-eviction-batch.md @@ -1,10 +1,12 @@ -# iCloud local-copy batch eviction +# Cloud local-copy batch eviction -DiskSage treats iCloud local-copy eviction as a destructive, evidence-bound operation. Planning remains read-only; execution is unavailable until every selected item has been replanned, the exact batch fingerprint has been approved by an attributed human, and the immutable record directory is outside all cloud-controlled paths. +DiskSage treats iCloud and OneDrive local-copy eviction as destructive, evidence-bound operations. Planning remains read-only; execution is unavailable until every selected item has been replanned, the exact batch fingerprint has been approved by an attributed human, and the immutable record directory is outside all cloud-controlled paths. ## Fail-closed execution contract - Every item receives a fresh clock reading; timestamps are never synthesized from a batch start time. +- Planning excludes sync-incomplete or otherwise unsafe items by index and bounded error code, so + one unsafe item cannot prevent separately verified items from reaching human approval. - The executor stops at the first failed or verification-incomplete item. - A successful item result and a refreshed batch checkpoint are written before the next item begins. - Failure to persist an item result marks verification incomplete, records the bounded failure code in the batch checkpoint, and halts execution. @@ -14,7 +16,7 @@ DiskSage treats iCloud local-copy eviction as a destructive, evidence-bound oper ## Evidence boundary and interoperability -**Local-only evidence** includes canonical source paths, detected iCloud-root details, record-directory locations, and immutable item or batch records that contain those paths. It remains on the operator-controlled system and is not a service-ingestion payload. +**Local-only evidence** includes canonical source paths, detected cloud-root details, record-directory locations, and immutable item or batch records that contain those paths. It remains on the operator-controlled system and is not a service-ingestion payload. **Shareable evidence** is limited to the path-free CLI plan and result views: schema versions, counts, byte totals, fingerprints, approval identifiers, bounded error codes, completion flags, and stable notices. Shareable evidence must never include source paths, user-file content, or control-directory locations. A CWL service or future Naruon module can ingest this bounded contract without requiring DiskSage to be deployed as a service; DiskSage therefore remains independently operable while preserving a narrow MSA interoperability boundary. @@ -36,7 +38,7 @@ The implementation applies **fail-safe defaults** by treating absent, stale, mal ## Verification -Release acceptance requires the focused `cloud_local_eviction_batch::tests::` suite, the `disksage-icloud-local-eviction-batch` binary suite, the documentation contract tests, formatting, whitespace validation, ordinary repository tests, security scans, and exact-head review gates to pass. Temporary repair workflows and scripts used to reproduce a regression are intentionally absent from the final source tree. +Release acceptance requires the focused `cloud_local_eviction_batch::tests::` suite, the `disksage-cloud-local-eviction-batch` binary suite, the documentation contract tests, formatting, whitespace validation, ordinary repository tests, security scans, and exact-head review gates to pass. Temporary repair workflows and scripts used to reproduce a regression are intentionally absent from the final source tree. ## References diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a1a330c13..830d2ca77 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,12 +1,117 @@ # DiskSage product and technical gap baseline -**Snapshot:** 2026-08-22 (Asia/Seoul) -**Repository heads at snapshot:** PR #213 `a6ec6e2`, PR #247 `a0fa7bc`, PR #246 `741ab30`, -supporting PR #156 `39a08a7`, and PR #192 `30ceea2`; hosted checks and protected review remain -authoritative, and no merge is claimed from queued or stale status. +## 2026-09-08 pnpm cache evidence-bound reclaim + +- The fixed `pnpm-cache` catalog was re-audited to two direct children. Only `v11` (object + `unix:16777233:142034678`, 1,138,157,228 bytes) was selected; the lockfile marker was retained. +- The product's recursive active-use probe completed with no pnpm process using `v11`. The item + moved to OS Trash under an identity-bound journal, then the proven `pnpm-store-v11` signature + was checked before permanent removal. Generic Trash was untouched and the original path is gone. +- APFS availability changed from 204,988,308 KiB to 206,104,472 KiB (1,116,164 KiB / 1.0644569397 + GiB observed). Only this host delta is credited; current availability is 187.8532142639 GiB + above the fixed baseline and 112.1467857361 GiB short of the 300 GiB target. +- Receipt and journals are retained in the private research attachment; no uncertain operation was + replayed and folder moves receive zero capacity credit. + +## 2026-09-08 container cleanup false-positive repair + +- A live Docker audit classified 17 tagged images as removable because the Docker-specific path + listed every image and checked container membership without enforcing its documented no-tag + invariant. Docker's own dangling-image inventory contained zero records. The repaired producer + query requests only `dangling=true` images before the existing membership, exact-size and + identity checks; the unsafe 17-image plan was never executed. +- The same audit classified 107 BuildKit records from `Reclaimable=true` alone. Current producer + evidence showed that this set included 64 shared records, 30 mutable records and one execution + cache mount. The repaired parser requires every safety field and retains shared, mutable and + `exec.cachemount` records. A fresh audit reduced the candidate set to 13 private, immutable, + non-cache-mount records while keeping active records excluded by Buildx's reclaimable flag. +- Two execution attempts stopped during the fresh Buildx inventory with + `orphan-list-build_cache-timeout`; neither reached mutation or wrote an execution receipt. The + 13 records then passed a complete fresh re-audit. The exact prune attempt returned an + indeterminate outcome and wrote a receipt; it was not replayed. A follow-up read-only audit + found zero BuildKit candidates, but concurrent activity prevents signed attribution, so the + observed 913,813,504-byte change remains uncredited. The current standalone test now checks the + implemented pre-mutation re-audit contract instead of the obsolete claim that exact BuildKit + deletion is unavailable. + +## 2026-09-08 targeted cache-plan scope repair + +- A live plan for `edge-code-sign-clones` was stopped before mutation after the process opened an + unrelated UV cache tree. `plan_catalog_cache_headless` and its targeted execution path selected + one result only after `cache_candidates` had measured every catalog root. This made a narrow + request perform broad I/O and could delay evidence refresh while active environments changed. +- The fixed lookup selects the requested fixed catalog ID before measuring its root. Full catalog + display and explicitly requested all-cache cleanup keep their existing behavior. The same narrow + lookup is used for the native UV prune preparation, Edge planning, and identity-bound single-cache + execution. Unknown IDs still fail closed. +- Acceptance evidence requires a focused catalog lookup regression and a live Edge-only plan that + does not open UV cache paths. Each clone still needs exact object identity and a fresh active-use + probe before Trash movement; an aggregate cache size is not deletion authority. + +**Snapshot:** 2026-08-28 (Asia/Seoul) +**Repository heads at snapshot:** `main` `79067c1160ddedf7fc962cbf8067ce7e83c4564a`, PR #267 +`3630e1eefacbeb996e6176373e6010da93bfa16c`, PR #263 +`060358340e922db7c36b6303dd0a959007a878c5`, and the current open queue (41 PRs: 20 ready, 21 +draft); hosted checks and protected review remain authoritative, and no merge is claimed from +queued or stale status. **Product boundary:** local-first macOS disk pressure relief with iCloud, OneDrive, and Google Drive destinations. **Evidence rule:** this document is a dated baseline, not an authority for transfer or deletion. Runtime receipts, provider attestations, object identity, and current GitHub checks remain authoritative. +## 2026-08-29 OneDrive local-space recovery observation + +- A metadata-only inventory completed across 277,410 entries and found 118 locally materialized + candidates totaling 62,060,163,072 allocated bytes. A fresh plan after provider drift retained + 110 eligible items totaling 58,836,140,032 allocated bytes and excluded eight items; private + paths remain outside this document. +- The first exact-item execution performed no eviction because the desktop client would not finish + its bounded quit sequence. DiskSage now keeps copy/upload admission separate from local-only + Files On-Demand eviction, observes the primary app separately from its resident File Provider + helper, and permits only one bounded graceful `SIGTERM` fallback. It never force-kills the sync + client, deletes a cloud item, or claims reclaimed bytes without post-action allocation proof. +- The remaining acceptance proof is a successful vendor `/unpin` on a freshly replanned item, + immutable result recording, and observed allocation reduction. Until then the measured 58.8 GB + is opportunity, not reclaimed capacity. + +## 2026-08-28 explicit open-PR worktree cutoff observation + +- PR #267 observed head `4b6dc492926a48aa0f29e867316177de31c92f4d` adds an opt-in calendar cutoff + for same-repository open pull requests. The plan and every removal re-query GitHub state, + creation time, branch, and exact head OID; the authority fingerprint binds that evidence and the + operator cutoff. Branches and commits remain preserved, and no implicit age or filesystem-time + threshold is used. +- Local frontend evidence for that head is green (`npm test -- --run`: 39 files/166 tests; + `npm run check`: zero errors/warnings). Hosted Rust, coverage, security, and review gates remain + the authority for integration and merge readiness. +- The hosted Rust test exposed and the next head repaired a temporary-JSON borrow error in the + Colima runtime-state parser; the fix retains only a validated boolean and state-present flag and + does not relax the unavailable-state blocker. + +## 2026-08-28 container cleanup loop evidence + +- On Podman 5.8.2 (the local `docker` wrapper), the live inventory contained two running + containers (`buildx_buildkit_default` and `accounting-information-platform-test-postgres`), one + default `podman` network, nine local volumes, and no stopped containers. The running Postgres + container is mounted on the previously anonymous volume + `bed31c452be785c238f9cb4c53cb04bc85e3233f0f05450327161c996778a349`; that volume was retained. +- `docker container prune --force`, `docker network prune --force`, `docker image prune --force`, + `podman image prune --all --force`, and `podman system prune --all --force` removed zero items + (`Total reclaimed space: 0B`). All seven detached, named compose volumes remain protected as + data-bearing project stores; BuildKit state remains attached and protected. No source, provider + database, or cloud object was deleted. +- The local Rust CLI probe was stopped before completion because its fresh Cargo target consumed + emergency headroom. `cargo clean --manifest-path src-tauri/Cargo.toml` removed the generated + target; the latest APFS observation was about 4.3 GiB available. This is volatile host evidence, + not a deletion guarantee. +- Follow-up re-audit found a short-lived `psychometrics-commons-pr427-coverage-20260828` + PostgreSQL container running with the anonymous volume + `0208f7d42ddb6bb800a6cda08e3d93b7aed3ac39d8f64718c841e02e44233878`; direct Podman inspection + shows the volume mounted at `/var/lib/postgresql`, so it remains protected while the container + is active. The host then had three running containers, one default `podman` network, and ten + local volumes; no new stale network or image was proven removable. +- The latest read-only Podman inventory could not connect because the machine SSH handshake + returned EOF. DiskSage therefore did not start, initialize, prune, or remove any runtime + resource; the host had about 1.3 GiB available at that observation. The failed connection is a + runtime-availability blocker, not evidence that any volume, image, or network is stale. ## 2026-09-08 exact-head CI RCA - PR #349 head `49827c3e63361ba2909e34240ff350912221ea45` upgraded `vitest` to @@ -52,7 +157,23 @@ authoritative, and no merge is claimed from queued or stale status. 4. Regenerable caches are a separate reclaim domain. They are per-child, identity-bound, active-use checked, journaled, and moved to OS Trash; they are not uploaded as user data. 5. Deterministic Rust gates own safety. A local model may judge only the fixed maintenance command after dry-run evidence, calibration, and explicit human confirmation. No external LLM or OAuth service is a runtime prerequisite for the standalone product. -## Buyer-observable product gaps +## Reclaim-domain contract + +| Domain | What DiskSage may propose | Required proof before mutation | Explicitly out of scope | +| --- | --- | --- | --- | +| Cloud/local duplicate | Copy or adopt an already-present cloud object, then evict only the local copy | Provider item identity, content digest, sync attestation, current local identity, and fresh headroom | Deleting a local placeholder or treating `is_uploaded=false` as uploaded | +| Exact duplicate photos/files | Keep one user-selected member and move the others to Trash | Stable content digest, complete metadata probe, source recheck, and per-group confirmation | Perceptual/near-duplicate deletion or “best quality” guessed from names | +| Podman/Docker | Remove only stopped, unreferenced resources proven by a runtime re-audit | Runtime inventory, reference/label evidence, size evidence, and exact approval | Removing active volumes, BuildKit state, or raw VM images | +| Colima/Podman VM storage | Run bounded guest `fstrim` while the guest is running | Fresh runtime state, fixed command, exact phrase, and bounded output receipt | `qemu-img`, sparse-file truncation, VM stop/delete, or host allocation claims | +| Shared temporary storage (`/tmp` or macOS `/private/tmp`) | Show and move only current-user-owned, non-linked temporary children to OS Trash | Real-directory root, complete ownership walk, active-use evidence, exact object identity, and per-item journal | Other-user/system-owned trees, symlinks, and deleting the shared root itself | +| Git worktrees | Remove a clean, inactive secondary worktree whose exact head is no longer retained | Fresh Git registration/status/size/open-file evidence and, for PR authority, same-repository state + head OID | Branch deletion, `git prune`, fork worktrees, dirty/active worktrees, or age-only deletion | +| Standalone Git clones | Move a clean, inactive, single-worktree clone on an exact closed or operator-cutoff stale-open PR head to OS Trash | Fresh same-repository GitHub branch + head OID, retained-reference comparison, recursive active-use check, internal Git directory, object identity, and exact approval | Branch deletion, `git prune`, fork/detached/dirty/active clones, external Git directories, or implicit age thresholds | + +The dashboard must sum these domains separately. A displayed target such as 300 GB is a +measurement goal, not an authorization: unresolved bytes stay visible with their blocker and are +never converted into a deletion estimate by a heuristic. + +## Customer-observable product gaps | Priority | Gap / observable symptom | Evidence | Acceptance criterion | | --- | --- | --- | --- | @@ -62,6 +183,10 @@ authoritative, and no merge is claimed from queued or stale status. | P1 | Users cannot yet see a full lineage graph connecting source, metadata, archive member, provider item, receipt, Goal, and eviction decision. | The candidate UI now exposes a compact source→metadata→archive→provider lineage panel using the stable fingerprint, confidence, and blocker state; provider item/receipt/permit remain explicitly pending until their evidence exists. | Export and UI show stable content IDs, provenance edges, confidence, and blockers without exposing raw private paths. | | P1 | “Orphan”/duplicate cleanup is difficult to trust because relationship evidence is not visible before action. | Ontology and duplicate/orphan PRs are open; current default path remains fail-closed. | Every proposed removal has an explainable parent/child/duplicate relation, identity recheck, reversible Trash action, and a no-candidate result when evidence is incomplete. | | P2 | Cross-platform behavior and accessibility are not presented as one release contract. | macOS/Linux/Windows release checks exist; several UI accessibility PRs remain open. | Release notes and UI expose platform capability matrix, keyboard/assistive labels, and bounded failure messages for each action. | +| P0 | A 300 GB target cannot be met by cache pruning alone; VM-backed stores and user data need separate measured plans. | Current host observations show only tens of GB in proven regenerable/runtime candidates, while DaisyDisk’s large Application Support/Mobile Documents totals are not deletion authority. | Dashboard reports measured reclaimable bytes by domain, requires provider confirmation before local eviction, and leaves the remainder explicitly unresolved. | +| P1 | Photo copies with different bytes cannot be safely ranked from a filename or an arbitrary quality score. | Exact-content duplicate audit can prove byte identity; non-identical images need dimensions, codec, and metadata evidence plus a human choice. | Group exact matches automatically, show measured image evidence when available, keep one selected original, and never delete a non-identical photo automatically. | +| P1 | A stale PR worktree may point at a branch that is still open, so age alone is not deletion authority. | The worktree audit already binds same-repository closed/merged PR head OIDs and protects dirty, active, detached, fork, locked, and retained-tip worktrees. | Require an explicit cutoff and fresh same-repository PR state before proposing an old open-PR worktree; preserve the branch/commit and remove only a clean, inactive worktree after exact approval. | +| P1 | A standalone clone on a closed or explicitly old open PR head was invisible because the worktree remover always preserves its primary checkout. | The standalone-clone plan now reuses exact Git/GitHub worktree evidence, requires one clean inactive checkout and an internal Git directory, then revalidates identity before Trash. | App commands return a measured plan and execute only its exact approval; the original path disappears, branch and Git maintenance commands are untouched, and physical reclaim remains pending until Trash is emptied. | ## Technical and operational gaps @@ -69,11 +194,12 @@ authoritative, and no merge is claimed from queued or stale status. | --- | --- | --- | --- | | P0 | Provider end-to-end receipt is absent for the current iCloud incident. | Global probe can time out and CloudDocs state is intentionally not force-killed or deleted; the native copy boundary now requires an integrity-checked three-stream pre-copy cohort before mutation. | Capture a bounded fresh provider evidence receipt after sync settles; keep transfer/eviction disabled until it is complete. | | P0 | Disk pressure telemetry and provider queue evidence must remain comparable across loops without retaining raw provider output. | Cloud plans and explicit iCloud health refreshes persist bounded, path-free `LocalVolumeSnapshot`, `ProviderClientRuntimeSnapshot`, and `IcloudSyncHealthEvidenceSnapshot` records under `volume-pressure-evidence`, `provider-client-runtime-evidence`, and `icloud-sync-health-evidence`; iCloud plans now combine them into a timestamp/fingerprint-bound cohort. | Missing, incomplete, malformed, or more-than-five-minute-skewed cohort observations remain blocked; a fresh exact-head native incident plan is still needed to compare the emitted cohort with the live incident. | -| P1 | Hourly product-development/review loop is not yet live in this repository environment. | The repository-local `.github/workflows/hourly-product-loop.yml` is intentionally `workflow_dispatch`-only because its direct contextual-orchestrator HTTP call is advisory and not a pinned OpenCode worker. The trusted central [`disksage-hourly-review-repair.yml`](https://github.com/ContextualWisdomLab/.github/blob/main/.github/workflows/disksage-hourly-review-repair.yml) runs at `37 * * * *` and dispatches the pinned scheduler `a3fdaa1aacaba9443a18573f3c309fe1841fc2f0`, which performs the OpenCode OIDC exchange. The local workflow still uploads a seven-day path-free receipt when manually configured; no external endpoint or deployment receipt is available here. | Verify one central scheduler receipt and one local manual advisory receipt; preserve read-only permissions, exact-head binding, and no provider-secret import or mutation. | +| P1 | The central hourly development/review loop is live; the repository-local advisory path remains manual-only. | The repository-local `.github/workflows/hourly-product-loop.yml` remains `workflow_dispatch`-only because its contextual-orchestrator call is advisory. The trusted central [`disksage-hourly-review-repair.yml`](https://github.com/ContextualWisdomLab/.github/blob/main/.github/workflows/disksage-hourly-review-repair.yml) runs at `37 * * * *`; scheduled run [`32986653461`](https://github.com/ContextualWisdomLab/.github/actions/runs/32986653461) completed successfully on central head `e00bd7964f332b69cf7b430b0cb5ad486eef8258`, following four other successful scheduled runs. | Retain successful scheduled receipts, read-only repository permissions, exact-head binding, and no provider-secret import or foreign-repository mutation; verify the local advisory receipt only when manually configured. | | P1 | Open PR queue prevents a clean protected release line. | At this loop capture PR #213 is exact head `6f424af` on `feat/provider-sync-dynamic-goals`; its required checks reset after the provider-dump pipe repair and the prior review decision remains stale `CHANGES_REQUESTED`. The orphan cleanup follow-up is PR #245, initially implemented at `3d2406c` and subsequently extended with provider-sync and cleanup-refresh safety fixes. Both remain protected and unmerged pending exact-head review. | Process one PR at a time: current-head review → fix → required checks → fresh approval → normal protected merge; never bypass or self-approve. | | P1 | Current UI coverage is contract-heavy rather than runtime E2E for native File Provider states. | The UI now displays `로컬 최신본·업로드 미확인` and maps blockers without backend detail; provider operations are not safely reproducible on this full disk. Rust fixtures now cover `local-current + is_uploaded=false`, provider timeout, timeliness transitions, and receipt/evidence invalidation; native runtime E2E remains unavailable while the provider is unhealthy. | Keep the fixture-backed state machine green and add a bounded native E2E receipt only after a quiet provider observation is authoritative. | | P1 | Ontology/catalog integrations are export boundaries, not deployed services. | Naruon/semantic catalog and Zotero local API docs/contracts exist; no Noema/contextual-orchestrator runtime dependency is required. | Keep integrations optional and path-free; add live service tests only when a concrete consumer and secret boundary exist. | | P2 | 100% documentation/docstring and edge-case coverage is not yet evidenced. | Existing checks cover core Rust/TS behavior, not a repository-wide percentage claim. | Publish measured coverage per language and close high-risk edge paths before claiming 100%. | +| P1 | VM guest free space and host image allocation are conflated by runtime tools. | Podman/Colima logical reclaim values do not prove APFS allocation; raw image rewriting is unsafe while a VM is active. | Runtime maintenance plan offers bounded guest `fstrim`, records before/after host observations, and reports host-image compaction as unsupported without a native proof. | | P2 | Figma design source is not part of the current change. | No visual redesign or Figma artifact was introduced in this baseline. | If a product UI redesign is approved, record the Figma File ID in a new ADR before implementation. | ## Architecture and decision linkage @@ -587,6 +713,16 @@ At each scheduled or operator loop, update this file only with new dated evidenc DiskSage and Clearfolio reusable-workflow callers, keeps the workflow token read-only, and updates the contract tests. Checks are still pending; hourly operation is not claimed until a normal protected merge and one successful scheduled receipt are observed. + +## 2026-08-27 central hourly scheduler operation evidence + +- Central scheduled run `32986653461` completed successfully on `.github` head + `e00bd7964f332b69cf7b430b0cb5ad486eef8258`. Runs `32979847404`, `32975212084`, + `32966118019`, and `32961434095` also completed successfully, replacing the earlier + startup-failure-only snapshot with repeated operational evidence. +- DiskSage keeps its local advisory workflow manual-only. The central caller remains the hourly + OpenCode review/repair authority, so the standalone repository does not duplicate scheduler or + provider-secret ownership. ## 2026-08-21 ontology-bound orphan cleanup follow-up - The macOS UI now provides `관계 기반 고아 정리`. A bounded Rust planner compares installed @@ -716,3 +852,466 @@ At each scheduled or operator loop, update this file only with new dated evidenc pipe leak that could starve the independent `ps` probe and report a false active-use timeout. The focused Rust test passed 3/3. The same patch is present on stacked PR heads `a0fa7bc` (#247) and `741ab30` (#246); hosted checks are rerunning and protected merge/review is still pending. + +## 2026-08-27 container and worktree reclamation loop + +- The standalone-clone authority now has a headless plan/execute boundary. It reuses the desktop + contract unchanged: current same-repository GitHub PR branch and head, clean status, one worktree, + inactivity, filesystem identity, exact confirmation, and an external journal are all required + before moving the clone to OS Trash; branch deletion and Git pruning remain prohibited. +- The live iCloud inventory contained both fully uploaded local copies and `local-current` items + with `is_uploaded=false`. Batch planning now partitions those states automatically: only exact + item plans that pass the existing provider, active-use, conflict, and allocation checks remain + actionable; excluded indices and bounded reasons are fingerprint-bound without disclosing paths. +- Docker dangling-image plans now obtain reclaim bytes from a single exact-identity `image inspect` + pass. The human-readable `image ls` size is never converted heuristically; missing, duplicate, or + mismatched numeric size evidence blocks the category instead of overstating the 300 GB target. + +- PR #267 observed head `2d0787537a964c319bd4ee994070268bd77f2284` delivers evidence-bound cleanup for stopped + containers, untagged and unreferenced images, unreferenced volumes, and unused non-default + networks across Docker, Podman, and the Colima Docker context. Execution re-audits the exact + candidate identities and requires the matching approval phrase plus a rationale; runtime + diagnostics remain outside customer-visible and public evidence boundaries. The local frontend + suite passes 35 files / 155 tests after repairing the stale UI ownership assertion and adding + the closed-PR worktree contract. +- The Git worktree authority removes clean, inactive secondary worktree folders whose commits are + strict ancestors of explicitly retained refs. ADR-0013 additionally binds closed-but-unmerged + cleanup to authenticated GitHub evidence: exact `CLOSED` state, same-repository identity, local + branch ref, and exact head OID must all match. The evidence is refreshed immediately before + removal and participates in the approved plan fingerprint; detached and fork PR worktrees remain + preserved for manual review. Branches and commits are never deleted. +- Review-driven runtime hardening verifies Podman network membership through the authoritative + all-container listing instead of assuming Docker-shaped network-inspect fields, follows valid + Homebrew/Docker Desktop CLI symlinks, and collapses unavailable runtimes into one actionable UI + summary rather than rendering repeated connection-failure panels. Runtime command descendants + share a private process group so timeout enforcement also closes inherited output pipes; closed + PR discovery filters merged history before applying its bounded exact-head authority set. +- Current protected-delivery snapshot: `main` is + `79067c1160ddedf7fc962cbf8067ce7e83c4564a`; 40 PRs are open (17 ready, 23 draft) and none has an + exact-head independent approval. PR #267 is ready for review and blocked; CodeRabbit passed while + CodeQL remained queued on the observed head. Combined status alone is not proof of required workflows, + resolved threads, or approval gate, so no protected merge is claimed. + +## 2026-08-28 host container-resource cleanup evidence + +- The host-compatible `docker` command is backed by Podman 5.8.2. The running BuildKit builder and + `accounting-information-platform-test-postgres` container were retained; no running workload was + stopped or restarted. +- `docker image prune -f` removed two dangling images (the runtime reported them as untagged and + unreferenced). After confirming that only the BuildKit builder and the test PostgreSQL container were + running, `docker image prune -a -f` removed 19 additional images that no container referenced; images + needed by those running containers were retained. `docker network prune -f` removed + `pg-erd-cloud-pr-alembic-reconcile_default` after authoritative inspection showed an empty container + map; the built-in `podman` network was retained. +- Three unreferenced zero-byte test-state volumes were removed: + `github-actions-modernize_data_redis`, `naruonprivmail_live-e2e-state`, and + `pg-erd-cloud-pr-alembic-reconcile_pgdata`. Non-empty PostgreSQL volumes were retained because a + zero runtime link count alone does not prove that their data is disposable. PR #267 exposes the same + evidence-bound per-volume review and exact-identity re-audit in the product UI. +- An unregistered local checkout of open draft PR #247 had no active file holders; `cargo clean` removed + its 2.7 GiB Rust target while preserving the source, branch head, and PR worktree content. Registered + open-PR worktrees and active build targets were not removed. The shared uv archive cache was left + untouched because live MCP processes were executing from it; cache eviction must first obtain the same + active-use evidence through the product flow. +- The previously requested macOS Homebrew maintenance command was dry-run first and then executed: + `brew cleanup --prune-prefix` removed 2,169 broken symbolic links and 85 stale Homebrew directories. + No package or application data outside Homebrew's own prefix was targeted. +- After the customer-copy hardening, the local frontend suite completed with 37 files and 163 tests + passing; `npm run check` reported zero errors and zero warnings. The exact PR #267 head for this + evidence is `e14879118377f4716b4bbb5dd5f5dcbc9571fbaf`. + +## 2026-08-28 DiskSage cache cleanup execution evidence + +- The PR #267 Rust headless cache command was built once in an isolated target directory and run + with `--execute`. It moved 15 inactive, identity-bound cache children (1,171,384,438 bytes) to + the user Trash. Active-use or incomplete-evidence blockers rejected the npm `_npx` child and the + uv lock/archive roots; no live MCP cache was forced out. +- With the separate explicit `--purge-proven-cache-trash` approval flag, the command rechecked + structural signatures and permanently removed four DiskSage-owned cache directories from Trash + (npm `_cacache`, pnpm `v11`, uv `simple-v21`, and Edge `Default`, 1,108,914,878 bytes). The + journal records pending and terminal outcomes for every object; no unrelated Trash entry matched + a proven cache signature. The data volume's available space rose from about 6.2 GiB during the + build to 7.2 GiB after purge; APFS accounting may fluctuate while other builds run. +- This is the same fail-closed path exposed by the product UI: inactive regenerable children can be + staged and explicitly purged only after structural re-audit, while active processes and + incomplete provider evidence remain preserved. + +## 2026-08-28 uv archive child-level reclamation + +- The first automatic run correctly skipped the uv `archive-v0` parent when its active-use probe was + incomplete. The implementation was then corrected so both the reviewed and current snapshots use + the same child-expanded set; a focused Rust suite passed 7/7. +- A second run re-audited 865 direct uv archive children independently. 855 inactive children + (4,640,480,301 bytes) were moved to Trash; active or incomplete evidence remained in place. The + explicit journal-backed purge rechecked the original cache parent, source absence, non-symlink + directory type, and bounded byte count before permanently removing 846 children + (4,574,395,074 bytes). Nine entries were no longer purge candidates after re-audit and were + preserved. The available-space reading reached about 3.1 GiB afterward; the remaining archive + content is still in use or was not proven safe. + +## 2026-08-28 stale container-volume follow-up + +- A fresh Podman-backed Docker inventory found no further removable images or networks after the + earlier 21-image and one-network cleanup. Four anonymous 64-character volumes had zero + container links, no Compose labels, and were created by the same day's isolated test runs; + they were removed explicitly. Seven labeled, non-empty database/graph volumes remain retained: + an unlinked data volume is not evidence that its contents are disposable. +- The exact PR #267 head for this follow-up is `bc57679b6d7bc78dec5fa86dc3922b11e5092751`. + Host free-space readings remain volatile while unrelated builds run, so the product records + the resource identities and re-audit result rather than claiming a stable net free-space delta. + +## 2026-08-28 Noema sidecar dependency gap + +- The required Noema review for PR #267 remains non-passing because the central sidecar pins + contextual-orchestrator `c60ec889...`, whose generated catalog is list-shaped while its + `load_agents` implementation expects an `agents` object key (`KeyError: 'agents'`). The + upstream compatibility repair is present on contextual-orchestrator PR #901 at head + `d1bd3626ddb04a7b14e43aebf60827ac50ef8d17`; it is independently protected and not yet merged. +- DiskSage therefore keeps the Noema gate fail-closed and does not bypass it or treat the PR as + merge-ready. Once the upstream repair is normally merged, the central sidecar pin must be + updated and the exact DiskSage head re-reviewed. The pin update is tracked in central + [`.github#1371`](https://github.com/ContextualWisdomLab/.github/pull/1371) at head + `78f5c5642f5a49da6827f7a786b1ad4e79a6d03a`. + +## 2026-08-28 customer-copy boundary + +- PR #267 exact implementation head `cdda4f9fdc4001f588d61ef3a152e5f4f418262e` now applies one + customer-copy contract to cloud transfer, local-copy cleanup, cache and developer-folder cleanup, + Homebrew, duplicate/orphan cleanup, inventory, and container-resource screens. Native diagnostics, + identifiers, provider protocol terms, and command output are no longer reflected in visible + messages; each warning, error, or notice names a bounded next action. +- The contract test covers every existing screen, including the container image/volume/network + cleanup panel, and rejects implementation terms in visible text and attributes. The local checks + passed with `npm run check` (0 errors, 0 warnings) and 38 frontend test files / 165 tests. The + protected PR remains open and blocked until current-head hosted checks and an independent approval + pass; this UI proof does not authorize a merge or a deletion. + +## 2026-08-28 stale anonymous volume follow-up + +- A new local Podman inventory found one additional anonymous volume, + `2b9caeb3e63f84fffcc87c0ad365fed7b9f09812581d7389061488857831ca4c`, created at 12:22 KST. + It had no Compose labels, no container reference (`docker ps -a --filter volume=...` returned no + containers), and a runtime-accounted size of 462.9 MB. The volume was removed only after that + identity and reference check; the seven labeled database/graph volumes and the BuildKit-linked + volume remain retained. +- Runtime accounting changed from 1.268 GB to 804.9 MB of local volumes and host availability moved + from about 8.5 GiB to 8.7 GiB. APFS and concurrent hosted builds make the host delta non-authoritative; + the product records the exact identity and re-audit rather than promising a fixed byte gain. The + same per-volume evidence and re-audit path is available in PR #267 (head `b4105f8e47f165c702fedd4d05f7d4af6d29b603`). + +## 2026-08-28 exact Docker image-size and customer-copy follow-up + +- PR #267 head `e24037bb78a66aeed2ae78bb03ff8503904b0902` now obtains Docker dangling-image + reclaim bytes from one exact-ID `image inspect` response. Docker's human-readable listing size is + not converted; missing, duplicate, non-numeric, or mismatched identity evidence blocks the plan. +- The same head removes internal engine/model names and raw exception text from customer-facing + cleanup, organize, inventory, duplicate, and Homebrew messages. The user is given the next + bounded action while the exact approval, re-audit, and receipt authority remain unchanged. +- Local targeted frontend checks passed (7 tests) and `npm run check` reported zero errors and zero + warnings. Hosted checks and independent review remain required before protected merge. + +## 2026-08-28 runtime-maintenance input boundary follow-up + +- PR #267 head `f06d21015a9881fa3090ab1f1106eee8fea5fc20` rejects control characters in a Podman or + Colima trim rationale before runtime probing or receipt persistence. The same fail-closed input + boundary is now shared by cache, container, Homebrew, worktree, and VM-maintenance actions. + +## 2026-08-28 PR #267 exact-head hosted-gate observation + +- PR #267 current head `c09cc30f137680597ceeef9db9d4e5a29206b389` passed the hosted static-analysis, + dependency, vulnerability, coverage-source, and Windows path checks. The required Strix scan + retried its contextual-orchestrator provider three times and received HTTP 500 each time without + producing a vulnerability artifact; the required gate therefore remained fail-closed as provider + infrastructure unavailable, not as a code finding. +- The required OpenCode gate also remained fail-closed because no authenticated current-head + `opencode-agent` verdict had been posted. A review-only `@opencode-agent` dispatch request was + recorded on the PR; no self-approval, bypass, or merge was attempted. The remaining native build + and test jobs were still running at this observation, so release readiness is not claimed. + +## 2026-08-28 measured emergency reclaim and VM recovery + +The PR #271 Linux test gate exposed a fixture-boundary regression rather than an editor-cleanup +failure: 13 independent move, eviction, clone, and journal tests created mutation fixtures under +the globally protected shared `/tmp` tree. Production protection remains fail-closed for every +shared-temp child, including current-user-owned children. Hosted mutation fixtures now use the +runner's private workspace temp root instead of weakening the shared production guard. + +- VS Code's native `.vscode/extensions/.obsolete` lifecycle document identified 22 still-present + obsolete extension directories totaling 1,283,664 KiB. DiskSage now treats only those exact real + child directories as development artifacts; it does not infer obsolescence from directory age or + version ordering. The filtered headless execution revalidated all 22 identities, moved them to + Trash, journaled them, and purged only those exact Trash entries; APFS available space increased + by 1,291,364 KiB in the bounded before/after sample. The same native lifecycle contract found + and revalidated 15 additional obsolete directories in Cursor, VS Code Insiders, and VS Code + Server; purging only their journal-matched Trash entries increased APFS availability by another + 692,768 KiB. +- A focused physical-allocation audit found about 7.7 GiB in AppMap downloaded tool binaries and + 1.9 GiB in inactive Superset network diagnostics. AppMap uses the existing regenerable-data + cleanup contract. Superset diagnostics remain an explicit-review catalog item because historical + logs cannot be regenerated. Every selected child is still identity-bound, checked for active use + immediately before mutation, journaled, and moved to OS Trash. No application database or + cloud-provider state is included. +- The new headless path moved all three inactive AppMap cache children and four inactive Superset + diagnostic files to Trash. Only those journal-matched regenerable objects were then purged; + APFS available bytes rose from 56,603,525,120 before execution to 66,297,540,608 after purge. + Active npm and uv children were blocked and retained. +- A bounded inventory found about 146 GB under `/private/tmp`, dominated by isolated Cargo and + coverage target roots. DiskSage removed only current-user-owned generated roots after signature, + open-file, and process-reference checks; `/private/tmp` fell to about 20 GB and APFS available + space rose from about 3.6 GiB to 53 GiB after additional inactive dependency/build roots were + removed. Source trees, active worktrees, provider data, and user documents were preserved. +- The running Podman guest initially failed its SSH probe with EOF and its journal reported I/O + errors. A runtime-native stop/start restored the guest connection. DiskSage then removed only a + stopped BuildKit container, its dedicated state volume, and its unreferenced image. Same-day + PostgreSQL containers and every data-bearing named volume were preserved. +- The fixed guest `fstrim` reported 99.5 GiB trimmed. The host sparse image allocation changed from + 43,951,260 KiB to 30,440,160 KiB, while APFS available space rose from about 53 GiB to 65 GiB. + These are separate before/after observations, not a promise that logical trimmed bytes equal + host bytes. No raw image rewrite, truncation, or category-wide prune was used. +- The 300 GB objective is not yet satisfied: the latest observation proves about 65 GiB available. + Cloud eviction remains blocked for items without current provider-upload proof, and non-identical + photos remain blocked pending measured quality evidence and a selected survivor. + +## 2026-08-28 effective macOS cache-root correction + +- Native read-only discovery reported `uv cache dir` as `~/.cache/uv`, npm cache as `~/.npm`, + pnpm store as `~/Library/pnpm/store/v11`, and pip cache as `~/Library/Caches/pip`. The former + macOS catalog pointed uv, pnpm, Codex runtime, Node, PyTorch, Prisma, and GitHub CLI entries at + non-effective directories, so DiskSage could report zero bytes while about 2.8 GB remained under + `~/.cache` alone. +- The catalog now uses an absolute `XDG_CACHE_HOME` when supplied and otherwise `~/.cache`, retains + explicit UV/Hugging Face overrides, and scopes pnpm to its content-addressed store root plus its + separate metadata cache. Node, PyTorch, Prisma, GitHub CLI, and Codex runtime caches remain + manual-review candidates rather than gaining automatic deletion authority from path discovery. + Headless execution still re-lists exact children, rejects changed + identities or incomplete/active-use evidence, moves candidates to OS Trash, and journals each + mutation; the cache root itself is preserved. +- The live Trash contained about 695 MB, including a 368 MB uv `git-v0` cache and collision-renamed + uv build/wheel/source cache directories. The proven-cache purge now recognizes such macOS + collision names only when both a known base name and cache-specific structure match; unrelated + Trash entries and user data remain outside this irreversible path. +- The corrected headless path moved only inactive, identity-matched children from npm, uv, and pnpm + into Trash; active or incomplete-use entries (`uv/.lock`, npm `_npx`, and pnpm v3) remained in + place. Structure-bound purge then permanently removed uv git and pnpm metadata caches totaling + 410,257,515 bytes, followed by npm `_cacache`, uv archive/index, and pnpm v10 caches totaling + 1,896,107,386 bytes. The measured APFS available-space increases were 327,860 KiB and + 1,361,876 KiB respectively; logical bytes are not substituted for those host observations. +- A fresh GitHub/current-HEAD audit of 235 `/private/tmp` repositories found only two clean, + inactive, exact-head candidates: contextual-orchestrator PR #902 (merged) and + accounting-information-platform PR #30 (closed unmerged). Their linked worktrees were removed + through `git worktree remove` without force. Dirty, active-evidence-incomplete, open-PR, and + head-mismatched paths were preserved; the immediate APFS sample fluctuated downward, so no + positive physical gain is attributed to those 32 MiB of logical worktree data. + +## 2026-08-28 exact-head hosted-test repair and additional measured reclaim + +- The hosted container-capacity regression failed before exercising its assertion because its fake + Docker process omitted the mandatory `info` health response. The fixture now implements that + production precondition; the safety behavior remains unchanged. +- Release builds uploaded `release-disksage-windows-2022-1`, while the verification script expected + `windows-latest`. The verifier now uses the pinned matrix identity, and a synthetic exact 17-file + artifact set passes the checksum, path, type, and count contract. +- Three clean inactive temporary Git checkouts were removed only after a fresh fetch proved their + exact local commits remained reachable from remote branches; generated Rust output was removed + separately after ignore and active-use checks. APFS available space increased by 1,283,576 KiB + across those two bounded operations. Provider synchronization paths and locally unique commits + were preserved. + +## 2026-08-28 standalone-clone execution hardening + +- Standalone clone cleanup now rejects redirected or symlinked Git administration directories, + incomplete repository audits, and journals located inside the clone or behind unsafe path types. + Stale-open PR eligibility still requires an explicit operator cutoff; DiskSage does not invent an + age threshold. Focused clone and inherited worktree safety tests pass without mutating user data. + +## 2026-08-28 Superset partition-cache boundary + +- Superset's isolated HTTP cache measured 1,237,960 KiB and its compiled-code cache measured + 48,200 KiB. DiskSage now catalogs only those two regenerable roots; cookies, local/session + storage, IndexedDB, preferences, and historical network diagnostics remain excluded. +- The live execution moved `No_Vary_Search`, JavaScript, and WebAssembly cache children after + identity and active-use checks. The large `Cache_Data` child remained fail-closed because the + recursive native open-file observation exceeded its evidence timeout; process inactivity alone + was not promoted to deletion authority. The same bounded run and proven-cache purge increased + APFS availability by 170,104 KiB without claiming the blocked 1.2 GiB. + +## 2026-08-28 native Trash and development-artifact execution boundary + +- The default macOS Trash backend delegated to Finder and timed out with AppleEvent `-1712` while + provider work was active. Both ordinary and identity-bound DiskSage Trash mutations now reuse + the installed trash library's native `NSFileManager` method, avoiding Finder automation without + killing or pausing Finder or File Provider processes. +- A bounded scan of one development workspace found 78 marker-validated dependency artifacts + totaling 4,551,103,622 logical bytes. One inactive project execution moved three identity-matched + `node_modules` roots totaling 189,125,777 logical bytes to Trash. This is reversible logical + cleanup only: DiskSage does not claim physical recovery until an exact DiskSage-attributed Trash + entry can be purged without touching unrelated user Trash. +- A later `/private/tmp/opencode` audit found five ignored dependency environments in three dirty + but inactive BandScope worktrees: two `node_modules` roots and three Python `.venv` roots totaling + 3,378,668 KiB before removal. Git status was preserved, every path was confirmed ignored, and + recursive `lsof` plus process-command evidence found no active user. The immediate APFS sample + rose by only 500,316 KiB while background provider/build activity continued, so the larger + logical total is not reported as physical recovery. DiskSage now exposes this path only through + the explicit headless `--execute --permanent` disposition: it re-scans the bounded manifest, + rejects active or changed roots, rechecks filesystem identity, and journals the irreversible + deletion without emptying unrelated user Trash. +- The same evidence contract was applied to 15 additional ignored Rust `target` trees under + `/private/tmp`: each root was Git-ignored, had complete recursive open-file evidence, and had no + process-command reference. The first seven removals increased APFS availability by 10,537,012 + KiB and the next eight by 1,567,780 KiB. Source, dirty changes, Git heads, and cloud paths were + untouched. Two clean inactive ScopeWeave worktrees whose exact heads matched closed PR #626 and + merged PR #628 were then removed through ordinary `git worktree remove`; dirty closed PR #622 + was retained. +- Claude Code's native launcher symlink identified `2.1.234` as the installed executable. Three + non-target version binaries (`2.1.202`, `2.1.201`, and `2.1.177`) had no open-file or process + references; removing only those binaries increased APFS availability by 683,868 KiB and the + launcher still reported version `2.1.234`. DiskSage does not yet encode this symlink-target + lifecycle authority, so stale self-updating tool versions remain a measured product Gap rather + than a generic age-based cache rule. + +## 2026-08-29 exact-head reclaim and stacked-PR baseline + +- The session opened with 74,082,400 KiB available on the APFS Data volume. Bounded DiskSage + executions removed only marker-validated generated artifacts and one clean, inactive worktree + whose exact head was proven closed and retained. Availability reached 97,770,624 KiB before + concurrent builds consumed new space; no logical-size total is substituted for that physical + observation. +- A focused current-HEAD test proved the File Provider Git-metadata blocker, and the test-only + helper is now excluded from production builds. The resulting 2,386,748,792-byte Rust `target` + tree was then permanently removed through the same manifest, active-use, identity-recheck, and + immutable-journal path that DiskSage exposes to operators. +- A fresh audit of 97 BandScope worktrees found no candidate satisfying all containment, + cleanliness, inactivity, and closed-PR requirements. All 97 remain preserved; worktree names or + age alone did not grant removal authority. +- VS Code's native obsolete-extension evidence had previously identified 22 directories totaling + 1,314,471,936 allocated bytes. A fresh exact-path recheck now finds none of those directories, + so DiskSage neither repeats a mutation nor attributes additional physical recovery. +- PRs #273, #275, and #276 are ready for review at exact heads `03585345`, `020b2e19`, and + `10fcbdeb`. The first two inherited the same Windows release-artifact identity repair and are + undergoing new hosted checks. Merge remains blocked until every current-head required check is + terminal-success and repository review policy is satisfied. +- The 300 GB physical-recovery objective remains open. Provider-local eviction still requires + native uploaded/current evidence, non-identical photo selection still requires measured quality + evidence and explicit survivor confirmation, and active Podman/Colima resources remain outside + prune authority. + +## 2026-08-29 OneDrive native local-eviction boundary + +- The selected OneDrive root is a registered macOS File Provider domain. A bounded native status + probe reported the root uploaded, current, unpaused, untrashed, and eligible for the provider's + unpin action; its allocated subtree remains roughly 32 GiB. Presence in `CloudStorage` alone is + not used as upload or eviction authority. +- DiskSage now reuses its exact-path, provider-status, active-use, fingerprint approval, immutable + receipt, and post-allocation verification contract for individual OneDrive files. Execution + uses exact item evidence, gracefully stops the verified OneDrive app, + uses Microsoft's `/unpin` Files On-Demand command, and restarts the client. Exact File Provider + item evidence replaces the optional `/getpin` query. It does + not require OAuth and never deletes the visible cloud item. +- The same evidence contract now supports bounded OneDrive batches through the provider-neutral + `disksage-cloud-local-eviction-batch` CLI. Sync-incomplete items are excluded by index, every + selected item is replanned before the first mutation, and execution stops after the first failed + or incompletely verified item. +- The live provider-wide probe currently shows active upload/download, indexing, and reconciliation + work. Local-only eviction is governed by exact item evidence instead, but the desktop app did not + complete its bounded graceful quit, so no item was evicted. A physical-space receipt from the + live provider remains open; the 300 GB goal is not + claimed complete. + +## 2026-08-29 temporary-workspace generated-cache recovery + +- Project-local Python 3.14 `.venv314` environments now share the same manifest, active-use, journal, and permanent-reclaim checks as `.venv`. + +### Podman external-container image authority + +- Live deletion exposed Buildah storage containers hidden from ordinary `podman container ps --all`; their images rejected deletion despite appearing dangling. +- Podman image membership now includes native `--external` evidence before issuing an approval phrase. The same live store now produces zero image candidates rather than unsafe partial execution. + +- The live `/private/tmp` inventory exposed repeated Rust, Node, Python environment, type-check, + test, lint, and CodeGraph outputs inside review worktrees. DiskSage's identity-bound permanent + generated-artifact action removed these outputs without removing a worktree, branch, source + file, or untracked source change; each mutation was recorded in the private operation journal. +- The reusable artifact catalog now includes `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.tox`, + and `.nox`. Discovery tests prove the new cache names are admitted, while the existing rescan, + filesystem-object identity, active-use, and bounded traversal gates remain unchanged. +- OneDrive continued a large download while cleanup ran, so physical APFS availability fluctuated + independently of the bytes removed. Provider transfer cancellation and a fresh capacity snapshot + remain required before the 300 GB outcome can be claimed. + +## 2026-08-29 merged-worktree and isolated-project-cache execution + +### Exact PR commit membership boundary + +- The worktree authority now verifies each registered HEAD against the exact same-repository + GitHub pull-request commit list. This closes the squash/rebase ancestry gap and safely recognizes + detached intermediate commits without inferring identity from a directory or branch name. +- A SHA may occur in more than one PR. Verified membership in any open PR is therefore a mandatory + preserve veto, even if another PR containing the same SHA is already closed or merged. Search + caps, pagination/output bounds, repository mismatch, authentication failure, and timeout remain + evidence gaps rather than cleanup authority. + +- A live Naruon audit exposed a second squash-merge boundary: PR #1370's clean, inactive worktree + has the exact merged pull-request head, but that head is not an ancestor of the retained branch. + DiskSage now obtains closed-unmerged heads separately and scopes merged queries to branches in + the registered worktree set, then accepts only an exact same-repository branch-and-head match. + Repository-wide merged history therefore cannot exhaust the evidence bound. PR #1454 remains preserved because + its detached intermediate commit is also part of open PR #1466; open work always vetoes reclaim. + The fingerprint-bound native removal path then re-audited and removed only PR #1370's worktree; + path and Git registration absence were verified, the branch was retained, and the fresh audit + reports 28 preserved worktrees, zero candidates, complete evidence, and zero gaps. Its + 253,673,472-byte allocated upper bound is not presented as physical APFS recovery. +- The exact-duplicate collector now prunes `.photoslibrary` and `.photolibrary` packages and rejects + either package as a scan root. A regression test proves identical bytes inside a Photos package + cannot form a deletion cluster with an external file. The 44 external Pictures images currently + have unique exact-content digests; perceptual comparison and measured quality-survivor selection + remain an open product Gap and no non-identical photo was deleted. + +- A fresh Naruon audit proved exactly one removable worktree: PR #1429 was merged, its detached + head was retained by current `origin/develop`, the checkout was clean and inactive, and no open + PR stack retained it. DiskSage removed only `/Users/seonghobae/naruon-wt/pr1429` through its + fingerprint-bound approval path without force, branch deletion, or Git pruning. Path and Git + registration absence were both verified; the post-audit reports 29 retained worktrees, zero + candidates, complete evidence, and zero gaps. Its 253,587,456-byte allocated upper bound is not + presented as APFS recovery because concurrent provider writes reduced free space during removal. +- DiskSage then permanently removed 543 identity-matched, inactive generated artifacts from + Superset's isolated project copies: Python environments and caches, `node_modules`, and CodeGraph + indexes. Both executions completed without a failed candidate, were journaled, and re-audited to + zero candidates. The second bounded execution increased APFS availability by 820,188 KiB; logical + candidate totals are kept separate from that physical observation. +- Every `.venv314` discovery path now requires a bounded regular `pyvenv.cfg` whose version is + Python 3.14, rather than treating a Git or project marker as sufficient deletion evidence. The UI + names each newly supported Python cache and test environment so the operator can decide what to + review next without seeing internal implementation labels. +- A subsequent `/private/tmp` execution revalidated 752 generated candidates and permanently + removed 740. It preserved nine active candidates, two whose manifests changed, and one whose + active-use evidence was incomplete. APFS availability increased by 4,043,844 KiB between the + bounded before/after observations; the remaining generated candidates are not counted as + reclaimable while their safety evidence is incomplete or a process still uses them. + After the focused Rust verification finished, native `cargo clean` removed its regenerated + 2.3 GiB test target and increased APFS availability by another 2,285,228 KiB. + A final fresh `/private/tmp` pass removed 29 newly safe candidates, preserved eight active and + two changed candidates, and increased APFS availability by a further 1,335,132 KiB. + On the next continuation, 11 more candidates became safe and added 365,312 KiB; eight active + candidates and one changed candidate again remained untouched. + A later exact-identity pass removed 66 of 77 candidates representing 3,294,878,422 logical + bytes. Ten active candidates and one changed or incomplete manifest remained untouched. The + private journal is `/private/tmp/disksage-dev-permanent-1787954077.jsonl`; concurrent provider + and build writes mean this logical total is not presented as an APFS free-space increase. +- A later exact-identity `/private/tmp/opencode` pass preserved all 128 registered review + worktrees, then removed only 146 generated Rust, Python, Node, and analysis-cache roots within + them. All candidates passed manifest, active-use, and current-object checks; the bounded APFS + observation increased by 6,487,040 KiB. Source checkouts and Git registrations were untouched. +- The current Podman machine has one running and four stopped PostgreSQL test containers. Its + native orphan audit proves 74 unreferenced images (about 42.9 GB by record-size sum), but + `podman system df` and exact stopped-container removal fail because the store contains damaged + overlay layers, including a missing required lower directory. `podman system check --quick` + independently confirms the storage inconsistency. DiskSage therefore records no prune or + physical gain until an explicit native storage-repair plan preserves running-container and + data-volume dependencies, rechecks integrity, and regenerates the orphan fingerprint. +## 2026-09-08 content-evidence gate for organization plans + +- Metadata-aware organization planning now refuses the extension/name-only fallback when no + explicit rule or content-aware picker decision exists. The legacy metadata-free planner keeps + its prior extension fallback for compatibility. +- `organize::tests` passes 23/23, including `metadata_aware_plan_skips_name_only_fallback`. + This closes the specific authority gap for the iCloud organization path; it does not establish + semantic classification accuracy or authorize moving shared/app-managed content. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15cc5b18c..ff7cb9e88 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,11 @@ name = "disksage-icloud-local-eviction-batch" path = "src/bin/disksage-icloud-local-eviction-batch.rs" required-features = ["cloud-cli"] +[[bin]] +name = "disksage-cloud-local-eviction-batch" +path = "src/bin/disksage-icloud-local-eviction-batch.rs" +required-features = ["cloud-cli"] + [[bin]] name = "disksage-multipart-archive-audit" path = "src/bin/disksage-multipart-archive-audit.rs" @@ -82,6 +87,14 @@ required-features = ["cloud-cli"] name = "disksage-git-worktree-audit" path = "src/bin/disksage-git-worktree-audit.rs" +[[bin]] +name = "disksage-git-worktree-metadata-prune" +path = "src/bin/disksage-git-worktree-metadata-prune.rs" + +[[bin]] +name = "disksage-git-clone-reclaim" +path = "src/bin/disksage-git-clone-reclaim.rs" + [[bin]] name = "disksage-git-worktree-remove" path = "src/bin/disksage-git-worktree-remove.rs" @@ -92,6 +105,11 @@ name = "disksage-icloud-sync-health" path = "src/bin/disksage-icloud-sync-health.rs" required-features = ["cloud-cli"] +[[bin]] +name = "disksage-icloud-provider-recovery" +path = "src/bin/disksage-icloud-provider-recovery.rs" +required-features = ["cloud-cli"] + [[bin]] name = "disksage-archive-tree" path = "src/bin/disksage-archive-tree.rs" @@ -123,6 +141,10 @@ required-features = ["cloud-cli"] name = "disksage-podman-reclaim-plan" path = "src/bin/disksage-podman-reclaim-plan.rs" +[[bin]] +name = "disksage-container-orphan-plan" +path = "src/bin/disksage-container-orphan-plan.rs" + [[bin]] name = "disksage-provider-oauth" path = "src/bin/disksage-provider-oauth.rs" diff --git a/src-tauri/resources/ontology/default.ttl b/src-tauri/resources/ontology/default.ttl index 67cafe3f2..bb4dc6ef4 100644 --- a/src-tauri/resources/ontology/default.ttl +++ b/src-tauri/resources/ontology/default.ttl @@ -33,3 +33,15 @@ dm:Code a owl:Class ; dm:Dataset a owl:Class ; rdfs:label "데이터셋"@ko , "Dataset"@en ; dm:targetFolder "~/Datasets" . + +dm:BusinessData a owl:Class ; + rdfs:label "업무 데이터"@ko , "Business data"@en ; + dm:deletionPolicy "retain" . + +dm:CustomerRelationshipManagementData a owl:Class ; + rdfs:subClassOf dm:BusinessData ; + rdfs:label "고객 관계 관리 데이터"@ko , "Customer relationship management data"@en . + +dm:VirtualMachinePackage a owl:Class ; + rdfs:label "가상 머신 패키지"@ko , "Virtual machine package"@en ; + dm:deletionPolicy "retain" . diff --git a/src-tauri/src/bin/disksage-cache-cleanup.rs b/src-tauri/src/bin/disksage-cache-cleanup.rs index 82194a62a..a49a4be05 100644 --- a/src-tauri/src/bin/disksage-cache-cleanup.rs +++ b/src-tauri/src/bin/disksage-cache-cleanup.rs @@ -4,20 +4,25 @@ //! identity-bound children of the npm, pnpm, Adobe, Edge, uv, and Trivy cache roots to OS Trash. use disksage_lib::cache_cleanup::{ - clean_regenerable_caches_headless, proven_cache_trash_candidates, purge_proven_cache_trash, + clean_catalog_cache_headless, clean_regenerable_caches_headless, plan_catalog_cache_headless, + proven_cache_trash_candidates, prune_uv_cache_headless, purge_proven_cache_trash, }; use std::ffi::OsString; use std::path::PathBuf; -const USAGE: &str = "Usage: disksage-cache-cleanup [--execute] [--purge-proven-cache-trash] [--journal-path PATH]\n\ +const USAGE: &str = "Usage: disksage-cache-cleanup [--execute] [--cache-id CATALOG_ID [--target-object-id OBJECT_ID] | --purge-proven-cache-trash | --prune-uv-cache] [--journal-path PATH]\n\ Without --execute it reports the command is a no-op. With --execute it moves only observed,\n\ inactive regenerable cache children to OS Trash. --purge-proven-cache-trash permanently removes\n\ -only structurally proven cache directories already in OS Trash."; +only structurally proven cache directories already in OS Trash. --prune-uv-cache runs uv's native\n\ +in-use-aware dangling cache prune without force. --cache-id plans or cleans one fixed catalog root."; #[derive(Debug, PartialEq, Eq)] struct Args { execute: bool, purge_proven_cache_trash: bool, + prune_uv_cache: bool, + cache_id: Option, + target_object_id: Option, journal_path: PathBuf, } @@ -68,12 +73,36 @@ fn parse_args(raw_args: impl IntoIterator) -> Result execute = true, Some("--purge-proven-cache-trash") => purge_proven_cache_trash = true, + Some("--prune-uv-cache") => prune_uv_cache = true, + Some("--cache-id") => { + let value = args + .next() + .ok_or_else(|| "--cache-id requires CATALOG_ID".to_string())? + .into_string() + .map_err(|_| "--cache-id requires UTF-8 CATALOG_ID".to_string())?; + if cache_id.replace(value).is_some() { + return Err("--cache-id may be supplied once".into()); + } + } + Some("--target-object-id") => { + let value = args + .next() + .ok_or_else(|| "--target-object-id requires OBJECT_ID".to_string())? + .into_string() + .map_err(|_| "--target-object-id requires UTF-8 OBJECT_ID".to_string())?; + if target_object_id.replace(value).is_some() { + return Err("--target-object-id may be supplied once".into()); + } + } Some("--journal-path") => { journal_path = PathBuf::from( args.next() @@ -88,9 +117,22 @@ fn parse_args(raw_args: impl IntoIterator) -> Result return Err(format!("invalid UTF-8 option\n{USAGE}")), } } + if usize::from(purge_proven_cache_trash) + + usize::from(prune_uv_cache) + + usize::from(cache_id.is_some()) + > 1 + { + return Err("cache cleanup actions are mutually exclusive".into()); + } + if target_object_id.is_some() && cache_id.is_none() { + return Err("--target-object-id requires --cache-id".into()); + } Ok(Some(Args { execute, purge_proven_cache_trash, + prune_uv_cache, + cache_id, + target_object_id, journal_path, })) } @@ -108,6 +150,10 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str return Ok(()); }; if !args.execute { + if let Some(cache_id) = args.cache_id.as_deref() { + println!("{}", plan_catalog_cache_headless(cache_id)?); + return Ok(()); + } let cache_trash = if args.purge_proven_cache_trash { serde_json::to_value(proven_cache_trash_candidates(&home_directory()?)) .map_err(|error| error.to_string())? @@ -142,6 +188,37 @@ fn run_with_args(raw_args: impl IntoIterator) -> Result<(), Str ); return Ok(()); } + if args.prune_uv_cache { + let result = prune_uv_cache_headless(&args.journal_path, now_ms())?; + println!( + "{}", + serde_json::json!({ + "executed": true, + "prune_uv_cache": true, + "journal_path": args.journal_path, + "result": result + }) + ); + return Ok(()); + } + if let Some(cache_id) = args.cache_id.as_deref() { + let results = clean_catalog_cache_headless( + cache_id, + args.target_object_id.as_deref(), + &args.journal_path, + now_ms(), + )?; + println!( + "{}", + serde_json::json!({ + "executed": true, + "cache_id": cache_id, + "journal_path": args.journal_path, + "results": results + }) + ); + return Ok(()); + } let evidence = clean_regenerable_caches_headless(&args.journal_path, now_ms())?; println!( "{}", @@ -172,11 +249,8 @@ mod tests { #[test] fn help_must_be_used_alone() { - let error = parse_args([ - OsString::from("--help"), - OsString::from("--execute"), - ]) - .unwrap_err(); + let error = + parse_args([OsString::from("--help"), OsString::from("--execute")]).unwrap_err(); assert!(error.starts_with("--help must be used alone")); } @@ -197,5 +271,50 @@ mod tests { .unwrap(); assert!(!args.execute); assert!(args.purge_proven_cache_trash); + assert!(!args.prune_uv_cache); + assert!(args.cache_id.is_none()); + assert!(args.target_object_id.is_none()); + } + + #[test] + fn uv_prune_is_explicit_and_exclusive() { + let args = parse_args([OsString::from("--prune-uv-cache")]) + .unwrap() + .unwrap(); + assert!(args.prune_uv_cache); + assert!(parse_args([ + OsString::from("--prune-uv-cache"), + OsString::from("--purge-proven-cache-trash"), + ]) + .is_err()); + } + + #[test] + fn catalog_cache_id_is_explicit_and_exclusive() { + let args = parse_args([OsString::from("--cache-id"), OsString::from("os-temp")]) + .unwrap() + .unwrap(); + assert_eq!(args.cache_id.as_deref(), Some("os-temp")); + assert!(args.target_object_id.is_none()); + assert!(parse_args([ + OsString::from("--cache-id"), + OsString::from("os-temp"), + OsString::from("--prune-uv-cache"), + ]) + .is_err()); + assert!(parse_args([ + OsString::from("--target-object-id"), + OsString::from("unix:1:2"), + ]) + .is_err()); + let selected = parse_args([ + OsString::from("--cache-id"), + OsString::from("os-temp"), + OsString::from("--target-object-id"), + OsString::from("unix:1:2"), + ]) + .unwrap() + .unwrap(); + assert_eq!(selected.target_object_id.as_deref(), Some("unix:1:2")); } } diff --git a/src-tauri/src/bin/disksage-container-orphan-plan.rs b/src-tauri/src/bin/disksage-container-orphan-plan.rs new file mode 100644 index 000000000..715ff111b --- /dev/null +++ b/src-tauri/src/bin/disksage-container-orphan-plan.rs @@ -0,0 +1,390 @@ +use disksage_lib::container_orphan_public::{ensure_mutation_category_authority, sanitize_plan}; +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, + ContainerRuntimeTarget, OrphanCategory, +}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const USAGE: &str = "Usage: disksage-container-orphan-plan --runtime --receipt-dir ABSOLUTE_PRIVATE_DIR [--scope NAME] [--bin PATH] [--docker-host HOST] [--pretty] [--execute CATEGORY --confirm EXACT_PHRASE --rationale TEXT]\n\ +Builds orphan evidence for containers, images, volumes, networks, and build cache. Execution re-audits and removes only the exact approved candidate set."; + +fn next_utf8_argument( + args: &mut impl Iterator, + missing_message: &str, + invalid_message: &str, +) -> Result { + args.next() + .ok_or_else(|| missing_message.to_string())? + .into_string() + .map_err(|_| invalid_message.to_string()) +} + +fn parse_category(value: &str) -> Result { + match value { + "container" => Ok(OrphanCategory::Container), + "image" => Ok(OrphanCategory::Image), + "volume" => Ok(OrphanCategory::Volume), + "network" => Ok(OrphanCategory::Network), + "build_cache" => Ok(OrphanCategory::BuildCache), + _ => Err(format!("unsupported category\n{USAGE}")), + } +} + +fn ensure_cli_execution_authority( + runtime: ContainerRuntimeKind, + docker_host: Option<&str>, +) -> Result<(), String> { + match runtime { + ContainerRuntimeKind::DockerNative if docker_host.is_some() => Ok(()), + ContainerRuntimeKind::DockerNative => { + Err("docker-native-cli-execution-requires-authority-binding".into()) + } + ContainerRuntimeKind::DockerColimaContext => { + Err("docker-context-cli-execution-requires-immutable-authority".into()) + } + ContainerRuntimeKind::PodmanMachine => Ok(()), + } +} + +fn docker_host_binding(host: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"disksage.container-orphan-cli-host.v1\0"); + hasher.update(host.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn bind_docker_host_approval(phrase: &str, host: &str) -> String { + format!("{phrase} docker-host {}", docker_host_binding(host)) +} + +fn bind_docker_host_plan( + mut plan: disksage_lib::container_orphan_reclaim::ContainerOrphanPlan, + host: &str, +) -> disksage_lib::container_orphan_reclaim::ContainerOrphanPlan { + for category in &mut plan.categories { + if let Some(phrase) = category.approval_phrase.take() { + category.approval_phrase = Some(bind_docker_host_approval(&phrase, host)); + } + } + plan +} + +fn unbind_docker_host_approval(phrase: &str, host: &str) -> Result { + phrase + .strip_suffix(&format!(" docker-host {}", docker_host_binding(host))) + .map(str::to_string) + .ok_or_else(|| "docker-native-cli-authority-mismatch".to_string()) +} + +fn suppress_unexecutable_docker_plan( + mut plan: disksage_lib::container_orphan_reclaim::ContainerOrphanPlan, + runtime: ContainerRuntimeKind, +) -> disksage_lib::container_orphan_reclaim::ContainerOrphanPlan { + if matches!( + runtime, + ContainerRuntimeKind::DockerNative | ContainerRuntimeKind::DockerColimaContext + ) { + for category in &mut plan.categories { + category.approval_phrase = None; + category.prune_command = None; + } + } + plan +} + +fn resolve_default_docker_from_path() -> Result { + let path = std::env::var_os("PATH").ok_or_else(|| "docker-native-cli-binary-unavailable".to_string())?; + for directory in std::env::split_paths(&path) { + if directory.as_os_str().is_empty() { + continue; + } + #[cfg(windows)] + let names = ["docker.exe", "docker"]; + #[cfg(not(windows))] + let names = ["docker", "docker"]; + for name in names { + let candidate = directory.join(name); + if candidate.is_file() { + return std::fs::canonicalize(candidate) + .map_err(|_| "docker-native-cli-binary-unavailable".to_string()); + } + } + } + Err("docker-native-cli-binary-unavailable".into()) +} + +fn run() -> Result<(), String> { + let raw_args: Vec = std::env::args_os().skip(1).collect(); + let help_count = raw_args + .iter() + .filter(|arg| matches!(arg.to_str(), Some("-h" | "--help"))) + .count(); + if help_count > 0 { + if raw_args.len() == 1 && help_count == 1 { + println!("{USAGE}"); + return Ok(()); + } + return Err(format!("help must be used alone\n{USAGE}")); + } + + let mut runtime: Option = None; + let mut scope: Option = None; + let mut binary_path: Option = None; + let mut docker_host = None; + let mut pretty = false; + let mut execute = None; + let mut confirmation = None; + let mut rationale = None; + let mut receipt_dir: Option = None; + let mut args = raw_args.into_iter(); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--runtime") => { + if runtime.is_some() { + return Err(format!("--runtime may be supplied once\n{USAGE}")); + } + let value = next_utf8_argument( + &mut args, + "--runtime requires a kind", + "--runtime requires a UTF-8 kind", + )?; + runtime = Some(match value.as_str() { + "docker-native" => ContainerRuntimeKind::DockerNative, + "docker-colima-context" => ContainerRuntimeKind::DockerColimaContext, + "podman-machine" => ContainerRuntimeKind::PodmanMachine, + _ => return Err(format!("unsupported runtime kind\n{USAGE}")), + }); + } + Some("--scope") => { + if scope.is_some() { + return Err(format!("--scope may be supplied once\n{USAGE}")); + } + scope = Some(next_utf8_argument( + &mut args, + "--scope requires a name", + "--scope requires a UTF-8 name", + )?); + } + Some("--bin") => { + if binary_path.is_some() { + return Err(format!("--bin may be supplied once\n{USAGE}")); + } + binary_path = Some(PathBuf::from( + args.next() + .ok_or_else(|| "--bin requires a path".to_string())?, + )); + } + Some("--docker-host") if docker_host.is_none() => { + docker_host = Some(next_utf8_argument( + &mut args, + "--docker-host requires a host", + "--docker-host requires a UTF-8 host", + )?) + } + Some("--pretty") => { + if pretty { + return Err(format!("--pretty may be supplied once\n{USAGE}")); + } + pretty = true; + } + Some("--execute") => { + if execute.is_some() { + return Err(format!("--execute may be supplied once\n{USAGE}")); + } + execute = Some(parse_category(&next_utf8_argument( + &mut args, + "--execute requires a category", + "--execute requires a UTF-8 category", + )?)?); + } + Some("--confirm") if confirmation.is_none() => { + confirmation = Some(next_utf8_argument( + &mut args, + "--confirm requires the exact phrase", + "--confirm requires a UTF-8 phrase", + )?) + } + Some("--rationale") if rationale.is_none() => { + rationale = Some(next_utf8_argument( + &mut args, + "--rationale requires text", + "--rationale requires UTF-8 text", + )?) + } + Some("--receipt-dir") if receipt_dir.is_none() => { + receipt_dir = + Some(PathBuf::from(args.next().ok_or_else(|| { + "--receipt-dir requires a path".to_string() + })?)) + } + Some(_) => return Err(format!("unknown option\n{USAGE}")), + None => return Err(format!("non-UTF-8 argument\n{USAGE}")), + } + } + let runtime = runtime.ok_or_else(|| format!("--runtime is required\n{USAGE}"))?; + match runtime { + ContainerRuntimeKind::DockerNative if scope.is_some() => { + return Err(format!("--scope is not valid for docker-native\n{USAGE}")); + } + ContainerRuntimeKind::DockerColimaContext if scope.is_none() => { + return Err(format!( + "--scope is required for docker-colima-context\n{USAGE}" + )); + } + ContainerRuntimeKind::PodmanMachine if scope.is_none() => { + return Err(format!("--scope is required for podman-machine\n{USAGE}")); + } + _ => {} + } + if docker_host.is_some() && runtime != ContainerRuntimeKind::DockerNative { + return Err(format!("--docker-host requires docker-native\n{USAGE}")); + } + if let Some(category) = execute { + ensure_cli_execution_authority(runtime, docker_host.as_deref())?; + ensure_mutation_category_authority(category)?; + } + let binary_path_was_explicit = binary_path.is_some(); + let binary_path = binary_path.unwrap_or_else(|| { + PathBuf::from(match runtime { + ContainerRuntimeKind::PodmanMachine => "podman", + ContainerRuntimeKind::DockerNative | ContainerRuntimeKind::DockerColimaContext => { + "docker" + } + }) + }); + let binary_path = if docker_host.is_some() { + if binary_path.is_absolute() { + std::fs::canonicalize(binary_path) + .map_err(|_| "docker-native-cli-binary-unavailable".to_string())? + } else if !binary_path_was_explicit && binary_path == Path::new("docker") { + resolve_default_docker_from_path()? + } else { + return Err("docker-native-cli-authority-requires-absolute-binary".into()); + } + } else { + binary_path + }; + let target = match docker_host.as_ref() { + Some(host) => ContainerRuntimeTarget::docker_native_host(binary_path, host.clone())?, + None => ContainerRuntimeTarget::new(runtime, binary_path, scope)?, + }; + if let Some(category) = execute { + let receipt_dir = + receipt_dir.ok_or_else(|| format!("--execute requires --receipt-dir\n{USAGE}"))?; + let confirmation = + confirmation.ok_or_else(|| format!("--execute requires --confirm\n{USAGE}"))?; + let rationale = + rationale.ok_or_else(|| format!("--execute requires --rationale\n{USAGE}"))?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "system time is before epoch".to_string())? + .as_millis() as u64; + let confirmation = match docker_host.as_deref() { + Some(host) => unbind_docker_host_approval(&confirmation, host)?, + None => confirmation, + }; + let result = execute_container_orphan_prune( + &target, + category, + &confirmation, + &rationale, + now_ms, + &receipt_dir, + )?; + println!( + "{}", + if pretty { + serde_json::to_string_pretty(&result) + } else { + serde_json::to_string(&result) + } + .map_err(|error| error.to_string())? + ); + return Ok(()); + } + if confirmation.is_some() || rationale.is_some() { + return Err(format!( + "--confirm and --rationale require --execute\n{USAGE}" + )); + } + let plan = sanitize_plan(receipt_dir.as_ref().map_or_else( + || disksage_lib::container_orphan_reclaim::probe_container_orphans(&target), + |dir| probe_container_orphans_with_receipt_dir(&target, dir), + )); + let plan = match docker_host.as_deref() { + Some(host) => bind_docker_host_plan(plan, host), + None => suppress_unexecutable_docker_plan(plan, runtime), + }; + if pretty { + println!( + "{}", + serde_json::to_string_pretty(&plan).map_err(|error| error.to_string())? + ); + } else { + println!( + "{}", + serde_json::to_string(&plan).map_err(|error| error.to_string())? + ); + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_exposes_every_backend_orphan_category() { + assert_eq!( + parse_category("container").unwrap(), + OrphanCategory::Container + ); + assert_eq!(parse_category("image").unwrap(), OrphanCategory::Image); + assert_eq!(parse_category("volume").unwrap(), OrphanCategory::Volume); + assert_eq!(parse_category("network").unwrap(), OrphanCategory::Network); + assert_eq!( + parse_category("build_cache").unwrap(), + OrphanCategory::BuildCache + ); + assert!(parse_category("all").is_err()); + assert!(USAGE.contains("build cache")); + } + + #[test] + fn cli_rejects_mutable_docker_context_execution_before_runtime_access() { + assert_eq!( + ensure_cli_execution_authority(ContainerRuntimeKind::DockerNative, None).unwrap_err(), + "docker-native-cli-execution-requires-authority-binding" + ); + assert_eq!( + ensure_cli_execution_authority(ContainerRuntimeKind::DockerColimaContext, None) + .unwrap_err(), + "docker-context-cli-execution-requires-immutable-authority" + ); + assert!(ensure_cli_execution_authority(ContainerRuntimeKind::PodmanMachine, None).is_ok()); + assert!(ensure_cli_execution_authority( + ContainerRuntimeKind::DockerNative, + Some("unix:///private/runtime.sock") + ) + .is_ok()); + let base = "DiskSage build_cache orphan prune 승인 abc receipt def"; + let phrase = bind_docker_host_approval(base, "unix:///private/runtime.sock"); + assert_eq!( + unbind_docker_host_approval(&phrase, "unix:///private/runtime.sock").unwrap(), + base + ); + assert!(unbind_docker_host_approval(&phrase, "unix:///private/other.sock").is_err()); + } +} diff --git a/src-tauri/src/bin/disksage-dev-artifacts.rs b/src-tauri/src/bin/disksage-dev-artifacts.rs index 63cb27ba2..942938a82 100644 --- a/src-tauri/src/bin/disksage-dev-artifacts.rs +++ b/src-tauri/src/bin/disksage-dev-artifacts.rs @@ -3,18 +3,26 @@ //! The default operation is read-only. `--execute` re-scans every requested artifact and moves it //! to OS Trash only when its path, metadata manifest, and filesystem identity still match. -use disksage_lib::dev_artifacts::{clean_artifacts, find_artifacts, DevArtifactCleanResult}; +use disksage_lib::dev_artifacts::{ + clean_artifacts, find_artifacts, permanently_delete_artifacts, DevArtifact, + DevArtifactCleanResult, +}; use std::path::{Component, Path, PathBuf}; const MAX_AGE_DAYS: u64 = 3_650; -const USAGE: &str = "usage: disksage-dev-artifacts --root ABSOLUTE_PATH [--min-age-days N] [--journal-path ABSOLUTE_PATH] [--execute]"; +const MAX_RATIONALE_CHARS: usize = 1_000; +const USAGE: &str = "usage: disksage-dev-artifacts --root ABSOLUTE_PATH [--kind ARTIFACT_KIND] [--min-age-days N] [--journal-path ABSOLUTE_PATH] [--execute] [--permanent --confirm EXACT_PHRASE --rationale TEXT]"; #[derive(Debug, PartialEq, Eq)] struct Args { root: PathBuf, + kind: Option, min_age_days: u64, journal_path: PathBuf, execute: bool, + permanent: bool, + confirm: Option, + rationale: Option, } fn absolute_without_parent(path: &Path) -> bool { @@ -52,11 +60,22 @@ fn default_journal_path() -> Result { Ok(path) } +fn rationale_valid(value: &str) -> bool { + !value.is_empty() + && value.trim() == value + && value.chars().count() <= MAX_RATIONALE_CHARS + && !value.chars().any(char::is_control) +} + fn parse_args(raw: &[String]) -> Result { let mut root = None; + let mut kind = None; let mut min_age_days = 30; let mut journal_path = default_journal_path()?; let mut execute = false; + let mut permanent = false; + let mut confirm = None; + let mut rationale = None; let mut index = 0usize; while index < raw.len() { match raw[index].as_str() { @@ -79,6 +98,15 @@ fn parse_args(raw: &[String]) -> Result { return Err(format!("--min-age-days는 {MAX_AGE_DAYS} 이하이어야 함")); } } + "--kind" => { + index += 1; + kind = Some( + raw.get(index) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "--kind 값이 필요함".to_string())? + .clone(), + ); + } "--journal-path" => { index += 1; journal_path = PathBuf::from( @@ -86,7 +114,26 @@ fn parse_args(raw: &[String]) -> Result { .ok_or_else(|| "--journal-path 값이 필요함".to_string())?, ); } + "--confirm" => { + index += 1; + confirm = Some( + raw.get(index) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "--confirm 값이 필요함".to_string())? + .clone(), + ); + } + "--rationale" => { + index += 1; + rationale = Some( + raw.get(index) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "--rationale 값이 필요함".to_string())? + .clone(), + ); + } "--execute" => execute = true, + "--permanent" => permanent = true, "--help" | "-h" => return Err(USAGE.into()), flag => return Err(format!("알 수 없는 인자: {flag}")), } @@ -99,14 +146,84 @@ fn parse_args(raw: &[String]) -> Result { if !absolute_without_parent(&journal_path) { return Err("--journal-path는 상위 탐색이 없는 절대 경로여야 함".into()); } + if permanent && !execute { + return Err("--permanent requires --execute".into()); + } + if permanent && (confirm.is_none() || rationale.is_none()) { + return Err("--permanent requires --confirm and --rationale".into()); + } + if rationale.as_deref().is_some_and(|value| !rationale_valid(value)) { + return Err("--rationale must be 1..1000 visible characters without leading/trailing whitespace".into()); + } Ok(Args { root, + kind, min_age_days, journal_path, execute, + permanent, + confirm, + rationale, }) } +fn hash_field(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn permanent_approval_phrase( + root: &Path, + kind: Option<&str>, + min_age_days: u64, + candidates: &[DevArtifact], +) -> Option { + if candidates.is_empty() { + return None; + } + let mut ordered = candidates.iter().collect::>(); + ordered.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.kind.cmp(&right.kind)) + .then_with(|| left.fingerprint.cmp(&right.fingerprint)) + .then_with(|| left.object_id.cmp(&right.object_id)) + }); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage-dev-artifact-permanent-v1\0"); + hash_field(&mut hasher, root.as_os_str().as_encoded_bytes()); + hash_field(&mut hasher, kind.unwrap_or_default().as_bytes()); + hash_field(&mut hasher, &min_age_days.to_le_bytes()); + hash_field(&mut hasher, &(ordered.len() as u64).to_le_bytes()); + let mut total_bytes = 0u64; + for candidate in ordered { + for value in [ + candidate.path.as_bytes(), + candidate.kind.as_bytes(), + candidate.project.as_bytes(), + candidate.fingerprint.as_bytes(), + candidate.object_id.as_bytes(), + ] { + hash_field(&mut hasher, value); + } + for value in [ + candidate.bytes, + candidate.files, + candidate.skipped, + candidate.age_days, + ] { + hash_field(&mut hasher, &value.to_le_bytes()); + } + hash_field(&mut hasher, &[u8::from(candidate.scan_complete)]); + total_bytes = total_bytes.saturating_add(candidate.bytes); + } + Some(format!( + "DiskSage permanent dev cleanup {} {total_bytes} 승인 {}", + candidates.len(), + hasher.finalize().to_hex() + )) +} + fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -116,33 +233,73 @@ fn now_ms() -> u64 { fn run(args: Args) -> Result { let observed_at_ms = now_ms(); - let candidates = find_artifacts(&args.root, args.min_age_days, observed_at_ms); + let candidates = find_artifacts(&args.root, args.min_age_days, observed_at_ms) + .into_iter() + .filter(|candidate| { + args.kind + .as_deref() + .is_none_or(|kind| candidate.kind == kind) + }) + .collect::>(); + let permanent_confirmation_phrase = permanent_approval_phrase( + &args.root, + args.kind.as_deref(), + args.min_age_days, + &candidates, + ); + if args.execute && args.permanent { + let phrase = permanent_confirmation_phrase + .as_deref() + .ok_or_else(|| "development-artifact-permanent-empty-candidate-set".to_string())?; + if args.confirm.as_deref() != Some(phrase) { + return Err("development-artifact-permanent-confirmation-mismatch".into()); + } + } let results: Vec = if args.execute { if let Some(parent) = args.journal_path.parent() { std::fs::create_dir_all(parent) .map_err(|_| "development-artifact-journal-parent-create-failed".to_string())?; } - clean_artifacts( - &candidates, - &args.root, - args.min_age_days, - &args.journal_path, - observed_at_ms, - ) + if args.permanent { + permanently_delete_artifacts( + &candidates, + &args.root, + args.min_age_days, + &args.journal_path, + observed_at_ms, + ) + } else { + clean_artifacts( + &candidates, + &args.root, + args.min_age_days, + &args.journal_path, + observed_at_ms, + ) + } } else { Vec::new() }; + let recorded_rationale = if args.execute && args.permanent { + args.rationale.clone() + } else { + None + }; serde_json::to_value(serde_json::json!({ "schema_version": 1, "schema_kind": "disksage.dev-artifact-cleanup", "root": args.root, + "kind": args.kind, "min_age_days": args.min_age_days, "observed_at_ms": observed_at_ms, "executed": args.execute, + "permanent": args.permanent, "candidate_count": candidates.len(), "candidates": candidates, "results": results, "journal_path": if args.execute { Some(args.journal_path) } else { None:: }, + "permanent_confirmation_phrase": permanent_confirmation_phrase, + "rationale": recorded_rationale, "cloud_write_executed": false, "source_eviction_executed": false, })) @@ -176,7 +333,10 @@ mod tests { let root = std::env::temp_dir(); let parsed = parse_args(&["--root".into(), root.to_string_lossy().into_owned()]).unwrap(); assert_eq!(parsed.min_age_days, 30); + assert_eq!(parsed.kind, None); assert!(!parsed.execute); + assert_eq!(parsed.confirm, None); + assert_eq!(parsed.rationale, None); assert!(parse_args(&["--root".into(), "relative".into()]).is_err()); assert!(parse_args(&[ "--root".into(), @@ -195,16 +355,114 @@ mod tests { root.to_string_lossy().into_owned(), "--min-age-days".into(), "7".into(), + "--kind".into(), + "vscode-obsolete-extension".into(), "--journal-path".into(), "/tmp/disksage-dev-artifacts-journal.jsonl".into(), "--execute".into(), ]) .unwrap(); assert_eq!(parsed.min_age_days, 7); + assert_eq!(parsed.kind.as_deref(), Some("vscode-obsolete-extension")); assert!(parsed.execute); assert_eq!( parsed.journal_path, PathBuf::from("/tmp/disksage-dev-artifacts-journal.jsonl") ); } + + #[test] + fn permanent_deletion_requires_explicit_execute() { + let root = std::env::temp_dir(); + assert_eq!( + parse_args(&[ + "--root".into(), + root.to_string_lossy().into_owned(), + "--permanent".into(), + ]) + .unwrap_err(), + "--permanent requires --execute" + ); + } + + #[test] + fn permanent_deletion_requires_bound_confirmation_and_rationale() { + let root = std::env::temp_dir(); + assert_eq!( + parse_args(&[ + "--root".into(), + root.to_string_lossy().into_owned(), + "--execute".into(), + "--permanent".into(), + ]) + .unwrap_err(), + "--permanent requires --confirm and --rationale" + ); + } + + #[test] + fn permanent_deletion_accepts_complete_operator_authority() { + let root = std::env::temp_dir(); + let parsed = parse_args(&[ + "--root".into(), + root.to_string_lossy().into_owned(), + "--execute".into(), + "--permanent".into(), + "--confirm".into(), + "reviewed phrase".into(), + "--rationale".into(), + "operator reviewed regenerable artifacts".into(), + ]) + .unwrap(); + assert_eq!(parsed.confirm.as_deref(), Some("reviewed phrase")); + assert_eq!( + parsed.rationale.as_deref(), + Some("operator reviewed regenerable artifacts") + ); + } + + #[test] + fn permanent_deletion_rejects_unbounded_or_control_rationale() { + let root = std::env::temp_dir(); + for rationale in [" leading-space", "line\nbreak"] { + assert_eq!( + parse_args(&[ + "--root".into(), + root.to_string_lossy().into_owned(), + "--execute".into(), + "--permanent".into(), + "--confirm".into(), + "reviewed phrase".into(), + "--rationale".into(), + rationale.into(), + ]) + .unwrap_err(), + "--rationale must be 1..1000 visible characters without leading/trailing whitespace" + ); + } + } + + #[test] + fn permanent_approval_phrase_binds_candidate_identity() { + let root = std::env::temp_dir(); + let candidate = DevArtifact { + path: root.join("target").to_string_lossy().into_owned(), + kind: "target".into(), + project: root.to_string_lossy().into_owned(), + bytes: 4096, + files: 8, + skipped: 0, + scan_complete: true, + fingerprint: "manifest-a".into(), + object_id: "object-a".into(), + age_days: 30, + }; + let first = permanent_approval_phrase(&root, Some("target"), 30, &[candidate.clone()]) + .unwrap(); + let mut changed = candidate; + changed.object_id = "object-b".into(); + let second = permanent_approval_phrase(&root, Some("target"), 30, &[changed]).unwrap(); + assert_ne!(first, second); + assert!(permanent_approval_phrase(&root, Some("target"), 30, &[]).is_none()); + } } diff --git a/src-tauri/src/bin/disksage-duplicate-audit.rs b/src-tauri/src/bin/disksage-duplicate-audit.rs index ea9727408..283d3946b 100644 --- a/src-tauri/src/bin/disksage-duplicate-audit.rs +++ b/src-tauri/src/bin/disksage-duplicate-audit.rs @@ -1,6 +1,7 @@ use disksage_lib::duplicate_audit::{ collect_exact_duplicate_audit, exact_duplicate_audit_integrity_valid, - summarize_exact_duplicate_audit, DEFAULT_MAX_ENTRIES, DEFAULT_MIN_BYTES, MAX_ENTRIES, + execute_exact_duplicate_reclaim_from_report, summarize_exact_duplicate_audit, + ExactDuplicateAuditReport, DEFAULT_MAX_ENTRIES, DEFAULT_MIN_BYTES, MAX_ENTRIES, }; use disksage_lib::private_evidence::write_private_json_create_new; use std::ffi::OsString; @@ -12,13 +13,19 @@ struct Args { min_bytes: u64, max_entries: usize, private_output: Option, + approved_private_report: Option, + execute: bool, + approved_audit_fingerprint: Option, + confirmation: Option, + rationale: Option, } fn usage() -> String { format!( "usage: disksage-duplicate-audit --root ABSOLUTE_PATH \ [--min-bytes POSITIVE_INTEGER] [--max-entries 1..={MAX_ENTRIES}] \ - [--private-output ABSOLUTE_NEW_FILE.json]" + [--private-output ABSOLUTE_NEW_FILE.json] \ + [--execute --approved-private-report ABSOLUTE_FILE.json --approved-audit-fingerprint HEX64 --confirm EXACT_PHRASE --rationale TEXT]" ) } @@ -49,6 +56,11 @@ fn parse_args_os(raw: &[OsString]) -> Result { let mut max_entries = DEFAULT_MAX_ENTRIES; let mut max_entries_seen = false; let mut private_output = None; + let mut approved_private_report = None; + let mut execute = false; + let mut approved_audit_fingerprint = None; + let mut confirmation = None; + let mut rationale = None; let mut index = 0usize; while index < raw.len() { match raw[index].to_str() { @@ -94,6 +106,24 @@ fn parse_args_os(raw: &[OsString]) -> Result { "--private-output", )?)); } + Some("--execute") if !execute => execute = true, + Some("--approved-private-report") if approved_private_report.is_none() => { + approved_private_report = Some(PathBuf::from(native_value( + raw, + &mut index, + "--approved-private-report", + )?)); + } + Some("--approved-audit-fingerprint") if approved_audit_fingerprint.is_none() => { + approved_audit_fingerprint = + Some(text_value(raw, &mut index, "--approved-audit-fingerprint")?); + } + Some("--confirm") if confirmation.is_none() => { + confirmation = Some(text_value(raw, &mut index, "--confirm")?); + } + Some("--rationale") if rationale.is_none() => { + rationale = Some(text_value(raw, &mut index, "--rationale")?); + } Some("--help" | "-h") => return Err(usage()), Some(_) => return Err("알 수 없는 인자".into()), None => return Err("duplicate-audit-argument-invalid".into()), @@ -109,11 +139,38 @@ fn parse_args_os(raw: &[OsString]) -> Result { return Err("--private-output은 상위 탐색이 없는 절대 경로여야 함".into()); } } + if approved_private_report + .as_deref() + .is_some_and(|path| !absolute_without_parent(path)) + { + return Err("--approved-private-report는 상위 탐색이 없는 절대 경로여야 함".into()); + } + if execute + != (approved_private_report.is_some() + && approved_audit_fingerprint.is_some() + && confirmation.is_some() + && rationale.is_some()) + { + return Err("duplicate-reclaim-execution-arguments-incomplete".into()); + } + if execute && private_output.is_some() { + return Err("duplicate-reclaim-private-output-not-supported".into()); + } + if approved_audit_fingerprint.as_deref().is_some_and(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err("--approved-audit-fingerprint은 HEX64여야 함".into()); + } Ok(Args { root, min_bytes, max_entries, private_output, + approved_private_report, + execute, + approved_audit_fingerprint, + confirmation, + rationale, }) } @@ -137,6 +194,36 @@ fn run() -> Result<(), String> { return Ok(()); } let args = parse_args_os(&raw)?; + if args.execute { + let report_path = args.approved_private_report.as_deref().unwrap(); + let metadata = std::fs::symlink_metadata(report_path) + .map_err(|_| "duplicate-reclaim-private-report-unavailable".to_string())?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > 64 * 1024 * 1024 + { + return Err("duplicate-reclaim-private-report-unsafe".into()); + } + let encoded = std::fs::read(report_path) + .map_err(|_| "duplicate-reclaim-private-report-read-failed".to_string())?; + let report: ExactDuplicateAuditReport = serde_json::from_slice(&encoded) + .map_err(|_| "duplicate-reclaim-private-report-invalid".to_string())?; + let execution = execute_exact_duplicate_reclaim_from_report( + &args.root, + &report, + args.approved_audit_fingerprint + .as_deref() + .unwrap_or_default(), + args.confirmation.as_deref().unwrap_or_default(), + args.rationale.as_deref().unwrap_or_default(), + system_now_ms(), + )?; + println!( + "{}", + serde_json::to_string_pretty(&execution).map_err(|error| error.to_string())? + ); + return Ok(()); + } let report = collect_exact_duplicate_audit( &args.root, system_now_ms(), @@ -196,6 +283,36 @@ mod tests { args.private_output, Some(PathBuf::from("/private/duplicates.json")) ); + assert!(!args.execute); + } + + #[test] + fn execution_requires_the_complete_exact_approval_boundary() { + let fingerprint = "a".repeat(64); + let args = parse_args(&[ + "--root".into(), + "/source".into(), + "--execute".into(), + "--approved-private-report".into(), + "/private/approved.json".into(), + "--approved-audit-fingerprint".into(), + fingerprint.clone(), + "--confirm".into(), + "DiskSage exact duplicate reclaim approval".into(), + "--rationale".into(), + "reviewed exact copies".into(), + ]) + .unwrap(); + assert!(args.execute); + assert_eq!( + args.approved_private_report, + Some(PathBuf::from("/private/approved.json")) + ); + assert_eq!( + args.approved_audit_fingerprint.as_deref(), + Some(fingerprint.as_str()) + ); + assert!(parse_args(&["--root".into(), "/source".into(), "--execute".into(),]).is_err()); } #[test] diff --git a/src-tauri/src/bin/disksage-git-clone-reclaim.rs b/src-tauri/src/bin/disksage-git-clone-reclaim.rs new file mode 100644 index 000000000..312b19a97 --- /dev/null +++ b/src-tauri/src/bin/disksage-git-clone-reclaim.rs @@ -0,0 +1,227 @@ +//! Headless exact-evidence planning and OS Trash execution for stale PR clones. + +use disksage_lib::git_clone_reclaim::{ + approve_git_clone_reclaim, execute_git_clone_reclaim, plan_git_clone_reclaim, +}; +use disksage_lib::git_worktree::{ + validate_reference, GitWorktreeAuditOptions, MAX_LOCAL_COMMAND_TIMEOUT_MS, +}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const USAGE: &str = "usage: disksage-git-clone-reclaim --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] [--include-closed-pull-requests] [--stale-open-pull-request-cutoff-ms N] [--execute --plan-fingerprint HEX64 --confirm EXACT_PHRASE --approved-by HUMAN_ID --rationale TEXT --journal-path ABSOLUTE_PATH]"; + +#[derive(Debug, PartialEq, Eq)] +struct Args { + repository_root: PathBuf, + retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_cutoff_ms: Option, + execution: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct ExecutionArgs { + plan_fingerprint: String, + confirmation: String, + approved_by: String, + rationale: String, + journal_path: PathBuf, +} + +fn value(raw: &[OsString], index: &mut usize, flag: &str) -> Result { + *index += 1; + raw.get(*index) + .cloned() + .ok_or_else(|| format!("{flag}-value-missing")) +} + +fn text(raw: &[OsString], index: &mut usize, flag: &str) -> Result { + value(raw, index, flag)? + .into_string() + .map_err(|_| "git-clone-reclaim-invalid-argument-encoding".into()) +} + +fn set_once(slot: &mut Option, value: T) -> Result<(), String> { + if slot.replace(value).is_some() { + Err("git-clone-reclaim-duplicate-option".into()) + } else { + Ok(()) + } +} + +fn parse_args(raw: &[OsString]) -> Result, String> { + if raw.len() == 1 && matches!(raw[0].to_str(), Some("--help") | Some("-h")) { + return Ok(None); + } + let mut root = None; + let mut references = Vec::new(); + let mut include_closed_pull_requests = false; + let mut cutoff = None; + let mut execute = false; + let mut fingerprint = None; + let mut confirmation = None; + let mut approved_by = None; + let mut rationale = None; + let mut journal = None; + let mut index = 0; + while index < raw.len() { + let flag = raw[index] + .to_str() + .ok_or_else(|| "git-clone-reclaim-invalid-argument-encoding".to_string())?; + match flag { + "--repository-root" => { + set_once(&mut root, PathBuf::from(value(raw, &mut index, flag)?))? + } + "--reference-ref" => { + let reference = text(raw, &mut index, flag)?; + validate_reference(&reference)?; + references.push(reference); + } + "--include-closed-pull-requests" if !include_closed_pull_requests => { + include_closed_pull_requests = true; + } + "--include-closed-pull-requests" => { + return Err("git-clone-reclaim-duplicate-option".into()) + } + "--stale-open-pull-request-cutoff-ms" => set_once( + &mut cutoff, + text(raw, &mut index, flag)? + .parse() + .map_err(|_| "git-clone-reclaim-cutoff-invalid".to_string())?, + )?, + "--execute" if !execute => execute = true, + "--execute" => return Err("git-clone-reclaim-duplicate-option".into()), + "--plan-fingerprint" => set_once(&mut fingerprint, text(raw, &mut index, flag)?)?, + "--confirm" => set_once(&mut confirmation, text(raw, &mut index, flag)?)?, + "--approved-by" => set_once(&mut approved_by, text(raw, &mut index, flag)?)?, + "--rationale" => set_once(&mut rationale, text(raw, &mut index, flag)?)?, + "--journal-path" => { + set_once(&mut journal, PathBuf::from(value(raw, &mut index, flag)?))? + } + "--help" | "-h" => return Err("git-clone-reclaim-help-must-be-used-alone".into()), + _ => return Err("git-clone-reclaim-unknown-argument".into()), + } + index += 1; + } + let repository_root = root.ok_or_else(|| "git-clone-reclaim-root-missing".to_string())?; + if !repository_root.is_absolute() || references.is_empty() { + return Err("git-clone-reclaim-plan-input-invalid".into()); + } + let execution_values_present = fingerprint.is_some() + || confirmation.is_some() + || approved_by.is_some() + || rationale.is_some() + || journal.is_some(); + let execution = if execute { + let journal_path = + journal.ok_or_else(|| "git-clone-reclaim-execution-input-missing".to_string())?; + if !journal_path.is_absolute() { + return Err("git-clone-reclaim-journal-path-invalid".into()); + } + Some(ExecutionArgs { + plan_fingerprint: fingerprint + .ok_or_else(|| "git-clone-reclaim-execution-input-missing".to_string())?, + confirmation: confirmation + .ok_or_else(|| "git-clone-reclaim-execution-input-missing".to_string())?, + approved_by: approved_by + .ok_or_else(|| "git-clone-reclaim-execution-input-missing".to_string())?, + rationale: rationale + .ok_or_else(|| "git-clone-reclaim-execution-input-missing".to_string())?, + journal_path, + }) + } else if execution_values_present { + return Err("git-clone-reclaim-execution-flag-missing".into()); + } else { + None + }; + Ok(Some(Args { + repository_root, + retention_references: references, + include_closed_pull_requests, + stale_open_cutoff_ms: cutoff, + execution, + })) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .unwrap_or(0) +} + +fn run(args: Args) -> Result { + let options = GitWorktreeAuditOptions { + command_timeout_ms: MAX_LOCAL_COMMAND_TIMEOUT_MS, + ..GitWorktreeAuditOptions::default() + }; + let plan = plan_git_clone_reclaim( + &args.repository_root, + &args.retention_references, + args.include_closed_pull_requests, + args.stale_open_cutoff_ms, + options, + now_ms(), + )?; + let Some(execution) = args.execution else { + return serde_json::to_value(plan).map_err(|error| error.to_string()); + }; + if plan.plan_fingerprint != execution.plan_fingerprint { + return Err("git-clone-reclaim-plan-fingerprint-mismatch".into()); + } + let approval = approve_git_clone_reclaim( + &plan, + &execution.confirmation, + now_ms(), + &execution.approved_by, + &execution.rationale, + )?; + let result = execute_git_clone_reclaim( + &plan, + &approval, + &args.retention_references, + args.include_closed_pull_requests, + args.stale_open_cutoff_ms, + options, + &execution.journal_path, + now_ms(), + )?; + serde_json::to_value(result).map_err(|error| error.to_string()) +} + +fn main() { + let raw = std::env::args_os().skip(1).collect::>(); + match parse_args(&raw).and_then(|parsed| parsed.map(run).transpose()) { + Ok(None) => println!("{USAGE}"), + Ok(Some(output)) => println!("{}", serde_json::to_string_pretty(&output).unwrap()), + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_authority_is_complete_or_rejected() { + let plan_only = vec![ + "--repository-root".into(), + "/tmp/clone".into(), + "--reference-ref".into(), + "refs/heads/main".into(), + ]; + assert!(parse_args(&plan_only).unwrap().unwrap().execution.is_none()); + let mut incomplete = plan_only; + incomplete.push("--execute".into()); + assert_eq!( + parse_args(&incomplete).unwrap_err(), + "git-clone-reclaim-execution-input-missing" + ); + } +} diff --git a/src-tauri/src/bin/disksage-git-worktree-audit.rs b/src-tauri/src/bin/disksage-git-worktree-audit.rs index b7a8da1f2..5d3eece6f 100644 --- a/src-tauri/src/bin/disksage-git-worktree-audit.rs +++ b/src-tauri/src/bin/disksage-git-worktree-audit.rs @@ -6,15 +6,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(test)] use disksage_lib::git_worktree::MAX_REFERENCE_BYTES; -use disksage_lib::git_worktree::{ - audit_git_worktrees, public_summary, validate_reference, GitWorktreeAuditOptions, -}; +use disksage_lib::git_worktree::{public_summary, validate_reference, GitWorktreeAuditOptions}; use disksage_lib::private_evidence::{write_private_json_create_new, PrivateEvidenceReceipt}; #[derive(Debug, Clone, PartialEq, Eq)] struct Args { repository_root: PathBuf, retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, private_output: Option, options: GitWorktreeAuditOptions, } @@ -26,7 +26,7 @@ enum ParseOutcome { } fn usage() -> &'static str { - "usage: disksage-git-worktree-audit --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] [--private-output NEW_ABSOLUTE_JSON_PATH] [--command-timeout-ms N] [--size-scan-timeout-ms N] [--max-worktrees N] [--max-entries-per-worktree N] [--max-active-pids N]" + "usage: disksage-git-worktree-audit --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] [--include-closed-pull-requests] [--stale-open-pull-request-cutoff-ms N] [--private-output NEW_ABSOLUTE_JSON_PATH] [--command-timeout-ms N] [--size-scan-timeout-ms N] [--max-worktrees N] [--max-entries-per-worktree N] [--max-active-pids N]" } fn value(args: &[OsString], index: &mut usize, flag: &str) -> Result { @@ -67,6 +67,8 @@ fn parse_args(args: &[OsString]) -> Result { let mut repository_root = None; let mut retention_references = Vec::new(); let mut private_output = None; + let mut include_closed_pull_requests = false; + let mut stale_open_pull_request_cutoff_ms = None; let mut options = GitWorktreeAuditOptions::default(); let mut seen_repository_root = false; let mut seen_private_output = false; @@ -83,24 +85,31 @@ fn parse_args(args: &[OsString]) -> Result { match flag { "--repository-root" => { mark_singleton(&mut seen_repository_root)?; - repository_root = Some(PathBuf::from(value( - args, - &mut index, - "--repository-root", - )?)); + repository_root = + Some(PathBuf::from(value(args, &mut index, "--repository-root")?)); } "--reference-ref" => { let reference = utf8_value(args, &mut index, "--reference-ref")?; validate_reference(&reference)?; retention_references.push(reference); } - "--private-output" => { - mark_singleton(&mut seen_private_output)?; - private_output = Some(PathBuf::from(value( + "--include-closed-pull-requests" if !include_closed_pull_requests => { + include_closed_pull_requests = true; + } + "--include-closed-pull-requests" => return Err("duplicate-option".into()), + "--stale-open-pull-request-cutoff-ms" => { + if stale_open_pull_request_cutoff_ms.is_some() { + return Err("duplicate-option".into()); + } + stale_open_pull_request_cutoff_ms = Some(parse_number( args, &mut index, - "--private-output", - )?)); + "--stale-open-pull-request-cutoff-ms", + )?); + } + "--private-output" => { + mark_singleton(&mut seen_private_output)?; + private_output = Some(PathBuf::from(value(args, &mut index, "--private-output")?)); } "--command-timeout-ms" => { mark_singleton(&mut seen_command_timeout)?; @@ -147,6 +156,8 @@ fn parse_args(args: &[OsString]) -> Result { Ok(ParseOutcome::Run(Args { repository_root, retention_references, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, private_output, options, })) @@ -196,9 +207,19 @@ fn write_private_report( } fn run_with_args(args: Args, observed_at_ms: u64) -> Result { - let report = audit_git_worktrees( + let evidence = disksage_lib::git_worktree_github_evidence::collect( + &args.repository_root, + args.include_closed_pull_requests, + args.stale_open_pull_request_cutoff_ms, + args.options, + )?; + let report = disksage_lib::git_worktree::audit_git_worktrees_with_pull_request_membership( &args.repository_root, &args.retention_references, + &evidence.closed_heads, + &evidence.stale_open_heads, + &evidence.pull_request_commits, + args.stale_open_pull_request_cutoff_ms, args.options, observed_at_ms, )?; @@ -254,10 +275,7 @@ mod tests { use super::*; fn run_args(values: &[&str]) -> Args { - let raw: Vec = values - .iter() - .map(|value| OsString::from(*value)) - .collect(); + let raw: Vec = values.iter().map(|value| OsString::from(*value)).collect(); match parse_args(&raw).unwrap() { ParseOutcome::Run(args) => args, ParseOutcome::Help => panic!("runtime arguments must not parse as help"), diff --git a/src-tauri/src/bin/disksage-git-worktree-metadata-prune.rs b/src-tauri/src/bin/disksage-git-worktree-metadata-prune.rs new file mode 100644 index 000000000..13a136c64 --- /dev/null +++ b/src-tauri/src/bin/disksage-git-worktree-metadata-prune.rs @@ -0,0 +1,356 @@ +//! Remove only Git worktree registrations whose paths Git proves no longer exist. + +use disksage_lib::private_evidence::write_private_json_create_new; +use sha2::{Digest, Sha256}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const USAGE: &str = "usage: disksage-git-worktree-metadata-prune --repository-root ABSOLUTE_PATH [--execute --confirm EXACT_PHRASE --rationale TEXT --record-path ABSOLUTE_PATH]"; +const PREFIX: &str = "Removing worktrees/"; +const SUFFIX: &str = ": gitdir file points to non-existent location"; + +#[derive(Debug, PartialEq, Eq)] +struct Args { + repository_root: PathBuf, + execute: bool, + confirm: Option, + rationale: Option, + record_path: Option, +} + +#[derive(Debug, serde::Serialize)] +struct Plan { + schema_kind: &'static str, + schema_version: u32, + repository_fingerprint: String, + candidate_count: usize, + plan_fingerprint: String, + exact_approval_phrase: Option, + evidence_complete: bool, + filesystem_path_delete_executed: bool, + branch_delete_executed: bool, + git_object_delete_executed: bool, +} + +#[derive(Debug, serde::Serialize)] +struct Receipt { + schema_kind: &'static str, + schema_version: u32, + plan_fingerprint: String, + candidate_count: usize, + remaining_candidate_count: usize, + metadata_prune_executed: bool, + verification_complete: bool, + rationale: String, + filesystem_path_delete_executed: bool, + branch_delete_executed: bool, + git_object_delete_executed: bool, +} + +fn value(args: &[OsString], index: &mut usize, option: &str) -> Result { + *index += 1; + args.get(*index) + .cloned() + .ok_or_else(|| format!("{option} requires a value")) +} + +fn parse_args(args: &[OsString]) -> Result { + let mut repository_root = None; + let mut execute = false; + let mut confirm = None; + let mut rationale = None; + let mut record_path = None; + let mut index = 0; + while index < args.len() { + match args[index].to_str() { + Some("--repository-root") if repository_root.is_none() => { + repository_root = + Some(PathBuf::from(value(args, &mut index, "--repository-root")?)); + } + Some("--execute") if !execute => execute = true, + Some("--confirm") if confirm.is_none() => { + confirm = Some( + value(args, &mut index, "--confirm")? + .into_string() + .map_err(|_| "--confirm requires UTF-8")?, + ); + } + Some("--rationale") if rationale.is_none() => { + rationale = Some( + value(args, &mut index, "--rationale")? + .into_string() + .map_err(|_| "--rationale requires UTF-8")?, + ); + } + Some("--record-path") if record_path.is_none() => { + record_path = Some(PathBuf::from(value(args, &mut index, "--record-path")?)); + } + Some("--help" | "-h") => return Err(USAGE.into()), + Some(_) => return Err(format!("invalid or duplicate option\n{USAGE}")), + None => return Err("option must be valid UTF-8".into()), + } + index += 1; + } + let repository_root = repository_root.ok_or_else(|| USAGE.to_string())?; + if !repository_root.is_absolute() { + return Err("--repository-root must be absolute".into()); + } + if execute { + if confirm.is_none() || rationale.is_none() || record_path.is_none() { + return Err("--execute requires --confirm, --rationale, and --record-path".into()); + } + if !record_path.as_ref().is_some_and(|path| path.is_absolute()) { + return Err("--record-path must be absolute".into()); + } + } else if confirm.is_some() || rationale.is_some() || record_path.is_some() { + return Err("mutation arguments require --execute".into()); + } + Ok(Args { + repository_root, + execute, + confirm, + rationale, + record_path, + }) +} + +fn git(repository_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repository_root) + .env("LC_ALL", "C") + .env("LANG", "C") + .output() + .map_err(|_| "git-worktree-metadata-command-unavailable".to_string())?; + if !output.status.success() || (!output.stdout.is_empty() && !output.stderr.is_empty()) { + return Err("git-worktree-metadata-command-failed".into()); + } + String::from_utf8(if output.stdout.is_empty() { + output.stderr + } else { + output.stdout + }) + .map_err(|_| "git-worktree-metadata-output-invalid".into()) +} + +fn fingerprint(fields: impl IntoIterator>) -> String { + let mut hasher = Sha256::new(); + for field in fields { + let field = field.as_ref(); + hasher.update((field.len() as u64).to_le_bytes()); + hasher.update(field); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn plan(repository_root: &Path) -> Result<(PathBuf, Plan), String> { + let repository_root = std::fs::canonicalize(repository_root) + .map_err(|_| "git-worktree-metadata-repository-unavailable".to_string())?; + let common = git( + &repository_root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )?; + let common = std::fs::canonicalize(common.trim()) + .map_err(|_| "git-worktree-metadata-common-dir-unavailable".to_string())?; + let dry_run = git( + &repository_root, + &[ + "worktree", + "prune", + "--dry-run", + "--verbose", + "--expire", + "now", + ], + )?; + let mut candidates = dry_run + .lines() + .filter(|line| !line.is_empty()) + .collect::>(); + if candidates.iter().any(|line| { + let Some(name) = line + .strip_prefix(PREFIX) + .and_then(|line| line.strip_suffix(SUFFIX)) + else { + return true; + }; + name.is_empty() + || name.len() > 255 + || name.contains('/') + || name.chars().any(char::is_control) + }) { + return Err("git-worktree-metadata-dry-run-evidence-invalid".into()); + } + candidates.sort_unstable(); + candidates.dedup(); + let repository_fingerprint = fingerprint([common.as_os_str().as_encoded_bytes()]); + let plan_fingerprint = fingerprint( + std::iter::once(b"disksage.git-worktree-metadata-prune.v1".as_slice()) + .chain(std::iter::once(repository_fingerprint.as_bytes())) + .chain(candidates.iter().map(|line| line.as_bytes())), + ); + let exact_approval_phrase = (!candidates.is_empty()).then(|| { + format!( + "DiskSage stale worktree metadata {} 승인 {plan_fingerprint}", + candidates.len() + ) + }); + Ok(( + repository_root, + Plan { + schema_kind: "disksage.git-worktree-metadata-prune-plan", + schema_version: 1, + repository_fingerprint, + candidate_count: candidates.len(), + plan_fingerprint, + exact_approval_phrase, + evidence_complete: true, + filesystem_path_delete_executed: false, + branch_delete_executed: false, + git_object_delete_executed: false, + }, + )) +} + +fn run(args: Args) -> Result { + let (repository_root, current_plan) = plan(&args.repository_root)?; + if !args.execute { + return serde_json::to_value(current_plan) + .map_err(|_| "git-worktree-metadata-json-failed".into()); + } + let phrase = current_plan + .exact_approval_phrase + .as_deref() + .ok_or("git-worktree-metadata-candidate-set-empty")?; + if args.confirm.as_deref() != Some(phrase) { + return Err("git-worktree-metadata-confirmation-mismatch".into()); + } + let rationale = args.rationale.unwrap(); + if rationale.trim() != rationale + || rationale.is_empty() + || rationale.len() > 1_000 + || rationale.chars().any(char::is_control) + { + return Err("git-worktree-metadata-rationale-invalid".into()); + } + git( + &repository_root, + &["worktree", "prune", "--verbose", "--expire", "now"], + )?; + let (_, after) = plan(&repository_root)?; + let receipt = Receipt { + schema_kind: "disksage.git-worktree-metadata-prune-receipt", + schema_version: 1, + plan_fingerprint: current_plan.plan_fingerprint, + candidate_count: current_plan.candidate_count, + remaining_candidate_count: after.candidate_count, + metadata_prune_executed: true, + verification_complete: after.candidate_count == 0, + rationale, + filesystem_path_delete_executed: false, + branch_delete_executed: false, + git_object_delete_executed: false, + }; + let record_path = args.record_path.unwrap(); + write_private_json_create_new(&repository_root, &record_path, &receipt) + .map_err(|_| "git-worktree-metadata-record-write-failed".to_string())?; + serde_json::to_value(receipt).map_err(|_| "git-worktree-metadata-json-failed".into()) +} + +fn main() { + let result = parse_args(&std::env::args_os().skip(1).collect::>()).and_then(run); + match result { + Ok(value) => println!("{}", serde_json::to_string_pretty(&value).unwrap()), + Err(error) if error == USAGE => println!("{USAGE}"), + Err(error) => { + eprintln!("disksage-git-worktree-metadata-prune: {error}"); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_keeps_planning_read_only_and_bounds_execution() { + let root = std::env::temp_dir(); + let plan = parse_args(&["--repository-root".into(), root.clone().into()]).unwrap(); + assert!(!plan.execute); + assert!( + parse_args(&["--repository-root".into(), root.into(), "--execute".into()]).is_err() + ); + assert!(parse_args(&["--repository-root".into(), "relative".into()]).is_err()); + } + + #[cfg(unix)] + #[test] + fn execution_prunes_only_missing_registration_and_records_verification() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + let linked = temp.path().join("linked"); + std::fs::create_dir(&repository).unwrap(); + for args in [ + vec!["init", "-q", "-b", "main"], + vec!["config", "user.name", "DiskSage Test"], + vec!["config", "user.email", "disksage@example.invalid"], + ] { + assert!(Command::new("git") + .args(args) + .current_dir(&repository) + .status() + .unwrap() + .success()); + } + std::fs::write(repository.join("tracked"), b"safe\n").unwrap(); + assert!(Command::new("git") + .args(["add", "tracked"]) + .current_dir(&repository) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["commit", "-q", "-m", "fixture"]) + .current_dir(&repository) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args([ + "worktree", + "add", + "-q", + linked.to_str().unwrap(), + "-b", + "stale" + ]) + .current_dir(&repository) + .status() + .unwrap() + .success()); + std::fs::remove_dir_all(&linked).unwrap(); + + let (_, planned) = plan(&repository).unwrap(); + assert_eq!(planned.candidate_count, 1); + let record_path = temp.path().join("receipt.json"); + let output = run(Args { + repository_root: repository.clone(), + execute: true, + confirm: planned.exact_approval_phrase, + rationale: Some("Missing worktree registration reviewed".into()), + record_path: Some(record_path.clone()), + }) + .unwrap(); + + assert_eq!(output["remaining_candidate_count"], 0); + assert_eq!(output["filesystem_path_delete_executed"], false); + assert!(record_path.is_file()); + assert_eq!(plan(&repository).unwrap().1.candidate_count, 0); + } +} diff --git a/src-tauri/src/bin/disksage-git-worktree-remove.rs b/src-tauri/src/bin/disksage-git-worktree-remove.rs index 7858176f2..33027c028 100644 --- a/src-tauri/src/bin/disksage-git-worktree-remove.rs +++ b/src-tauri/src/bin/disksage-git-worktree-remove.rs @@ -3,12 +3,15 @@ //! The command re-audits immediately before mutation, requires the exact audit phrase, records //! immutable approval/result evidence, and never deletes branches or runs `git worktree prune`. -use disksage_lib::{cloud, git_worktree}; +use disksage_lib::{cloud, git_worktree, git_worktree_github_evidence}; use std::ffi::OsString; use std::path::PathBuf; const USAGE: &str = "usage: disksage-git-worktree-remove \ --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] \ +[--include-closed-pull-requests] [--stale-open-pull-request-cutoff-ms N] \ +[--command-timeout-ms N] [--size-scan-timeout-ms N] \ +[--max-worktrees N] [--max-entries-per-worktree N] [--max-active-pids N] \ --approved-removal-plan-fingerprint HEX64 \ --confirmation-exact-approval-phrase PHRASE --reviewed-by human:ID --rationale TEXT \ --record-root ABSOLUTE_PATH"; @@ -17,6 +20,13 @@ const USAGE: &str = "usage: disksage-git-worktree-remove \ struct Args { repository_root: PathBuf, retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + command_timeout_ms: u64, + size_scan_timeout_ms: u64, + max_worktrees: usize, + max_entries_per_worktree: u64, + max_active_pids: usize, plan_fingerprint: String, confirmation_phrase: String, reviewed_by: String, @@ -47,6 +57,13 @@ fn parse_args(raw_args: impl IntoIterator) -> Result) -> Result { retention_references.push(next_utf8(&mut args, "--reference-ref")?) } + Some("--include-closed-pull-requests") if !include_closed_pull_requests => { + include_closed_pull_requests = true + } + Some("--include-closed-pull-requests") => return Err("duplicate option".into()), + Some("--stale-open-pull-request-cutoff-ms") + if stale_open_pull_request_cutoff_ms.is_none() => + { + stale_open_pull_request_cutoff_ms = Some( + next_utf8(&mut args, "--stale-open-pull-request-cutoff-ms")? + .parse() + .map_err(|_| "--stale-open-pull-request-cutoff-ms must be an integer")?, + ) + } + Some("--stale-open-pull-request-cutoff-ms") => return Err("duplicate option".into()), + Some("--command-timeout-ms") if command_timeout_ms.is_none() => { + command_timeout_ms = Some( + next_utf8(&mut args, "--command-timeout-ms")? + .parse() + .map_err(|_| "--command-timeout-ms must be an integer")?, + ) + } + Some("--command-timeout-ms") => return Err("duplicate option".into()), + Some("--size-scan-timeout-ms") if size_scan_timeout_ms.is_none() => { + size_scan_timeout_ms = Some( + next_utf8(&mut args, "--size-scan-timeout-ms")? + .parse() + .map_err(|_| "--size-scan-timeout-ms must be an integer")?, + ) + } + Some("--size-scan-timeout-ms") => return Err("duplicate option".into()), + Some("--max-worktrees") if max_worktrees.is_none() => { + max_worktrees = Some( + next_utf8(&mut args, "--max-worktrees")? + .parse() + .map_err(|_| "--max-worktrees must be an integer")?, + ) + } + Some("--max-worktrees") => return Err("duplicate option".into()), + Some("--max-entries-per-worktree") if max_entries_per_worktree.is_none() => { + max_entries_per_worktree = Some( + next_utf8(&mut args, "--max-entries-per-worktree")? + .parse() + .map_err(|_| "--max-entries-per-worktree must be an integer")?, + ) + } + Some("--max-entries-per-worktree") => return Err("duplicate option".into()), + Some("--max-active-pids") if max_active_pids.is_none() => { + max_active_pids = Some( + next_utf8(&mut args, "--max-active-pids")? + .parse() + .map_err(|_| "--max-active-pids must be an integer")?, + ) + } + Some("--max-active-pids") => return Err("duplicate option".into()), Some("--approved-removal-plan-fingerprint") => { plan_fingerprint = Some(next_utf8(&mut args, "--approved-removal-plan-fingerprint")?) @@ -105,10 +176,19 @@ fn parse_args(raw_args: impl IntoIterator) -> Result Result { - let options = git_worktree::GitWorktreeAuditOptions::default(); + let options = git_worktree::GitWorktreeAuditOptions { + command_timeout_ms: args.command_timeout_ms, + size_scan_timeout_ms: args.size_scan_timeout_ms, + max_worktrees: args.max_worktrees, + max_entries_per_worktree: args.max_entries_per_worktree, + max_active_pids: args.max_active_pids, + }; let audited_at_ms = cloud::system_now_ms(); - let report = git_worktree::audit_git_worktrees( + let evidence = git_worktree_github_evidence::collect( + &args.repository_root, + args.include_closed_pull_requests, + args.stale_open_pull_request_cutoff_ms, + options, + )?; + let report = git_worktree::audit_git_worktrees_with_pull_request_membership( &args.repository_root, &args.retention_references, + &evidence.closed_heads, + &evidence.stale_open_heads, + &evidence.pull_request_commits, + args.stale_open_pull_request_cutoff_ms, options, audited_at_ms, )?; @@ -157,10 +253,12 @@ fn execute(args: Args) -> Result { &format!("{}.approval.json", approval.approval_id), &approval, )?; - let result = git_worktree::execute_stale_worktree_removal( + let result = git_worktree::execute_stale_worktree_removal_with_github_pull_requests( &report, &approval, &args.confirmation_phrase, + args.include_closed_pull_requests, + args.stale_open_pull_request_cutoff_ms, options, cloud::system_now_ms(), )?; @@ -255,4 +353,26 @@ mod tests { args[5] = "bad".into(); assert!(parse_args(args).is_err()); } + + #[test] + fn parser_preserves_custom_audit_resource_limits() { + let mut args = valid_args(); + args.splice( + 4..4, + [ + OsString::from("--max-worktrees"), + OsString::from("17"), + OsString::from("--max-entries-per-worktree"), + OsString::from("2345"), + OsString::from("--max-active-pids"), + OsString::from("9"), + ], + ); + let ParseResult::Run(parsed) = parse_args(args).unwrap() else { + panic!("runtime arguments must parse as a removal request"); + }; + assert_eq!(parsed.max_worktrees, 17); + assert_eq!(parsed.max_entries_per_worktree, 2345); + assert_eq!(parsed.max_active_pids, 9); + } } diff --git a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs index f2d6182dc..08a279408 100644 --- a/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs +++ b/src-tauri/src/bin/disksage-icloud-local-eviction-batch.rs @@ -1,4 +1,4 @@ -//! Headless, redacted, evidence-bound iCloud local-copy batch eviction. +//! Headless, redacted, evidence-bound cloud local-copy batch eviction. //! //! Planning is read-only. Execution requires an exact batch fingerprint twice, attributed human //! approval, a rationale, and a local immutable-record directory outside cloud storage. @@ -30,11 +30,13 @@ struct Args { record_dir: Option, } -fn usage() -> &'static str { - "usage: disksage-icloud-local-eviction-batch --cloud-root ABSOLUTE_PATH \ - --manifest ABSOLUTE_JSON [--execute --approved-batch-fingerprint HEX64 \ - --confirm-batch-fingerprint HEX64 --approved-by human:IDENTITY \ - --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]" +fn usage() -> String { + format!( + "usage: {} --cloud-root ABSOLUTE_PATH --manifest ABSOLUTE_JSON \ + [--execute --approved-batch-fingerprint HEX64 --confirm-batch-fingerprint HEX64 \ + --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", + env!("CARGO_BIN_NAME") + ) } fn native_value(args: &[OsString], index: &mut usize, flag: &str) -> Result { @@ -218,8 +220,15 @@ fn select_root<'a>(roots: &'a [CloudRoot], requested: &Path) -> Result<&'a Cloud .collect(); match matches.as_slice() { [] => Err("요청한 경로가 현재 탐지된 클라우드 루트와 일치하지 않음".into()), - [only] if only.provider == CloudProvider::Icloud => Ok(*only), - [_] => Err("iCloud root가 필요함".into()), + [only] + if matches!( + only.provider, + CloudProvider::Icloud | CloudProvider::Onedrive + ) => + { + Ok(*only) + } + [_] => Err("로컬 보관 해제를 지원하는 클라우드 루트가 필요함".into()), _ => Err("요청한 경로와 일치하는 클라우드 루트가 여러 개임".into()), } } diff --git a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs new file mode 100644 index 000000000..b6e90c630 --- /dev/null +++ b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs @@ -0,0 +1,163 @@ +//! Plan or execute one evidence-bound graceful iCloud File Provider daemon restart. + +use disksage_lib::icloud_provider_recovery::{ + execute_icloud_file_provider_recovery, observe_icloud_file_provider_daemon, + plan_icloud_file_provider_recovery, IcloudFileProviderRecoveryPlan, +}; +use disksage_lib::icloud_sync_health::{ + default_cloud_docs_db_dir, health_evidence_snapshot_from_report, probe_icloud_sync_health, +}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, PartialEq, Eq)] +struct Args { + db_dir: PathBuf, + output: Option, + execute_plan: Option, + confirmation: Option, + rationale: Option, +} + +fn parse_args(raw: &[String], home: &Path) -> Result { + let mut args = Args { + db_dir: default_cloud_docs_db_dir(home), + output: None, + execute_plan: None, + confirmation: None, + rationale: None, + }; + let mut index = 0; + while index < raw.len() { + let flag = &raw[index]; + index += 1; + let value = raw + .get(index) + .ok_or_else(|| format!("{flag} requires a value"))?; + match flag.as_str() { + "--db-dir" if args.db_dir == default_cloud_docs_db_dir(home) => { + args.db_dir = PathBuf::from(value) + } + "--execute-plan" if args.execute_plan.is_none() => { + args.execute_plan = Some(PathBuf::from(value)) + } + "--output" if args.output.is_none() => args.output = Some(PathBuf::from(value)), + "--confirm" if args.confirmation.is_none() => args.confirmation = Some(value.clone()), + "--rationale" if args.rationale.is_none() => args.rationale = Some(value.clone()), + _ => return Err("icloud-recovery-argument-invalid".into()), + } + index += 1; + } + if !args.db_dir.is_absolute() + || args + .execute_plan + .as_ref() + .is_some_and(|path| !path.is_absolute()) + || args.output.as_ref().is_some_and(|path| !path.is_absolute()) + || (args.execute_plan.is_some() && args.output.is_some()) + || (args.execute_plan.is_some() + != (args.confirmation.is_some() && args.rationale.is_some())) + { + return Err("icloud-recovery-argument-invalid".into()); + } + Ok(args) +} + +fn now_ms() -> Result { + let value = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| "system-clock-before-unix-epoch".to_string())? + .as_millis(); + u64::try_from(value).map_err(|_| "system-time-overflow".into()) +} + +fn read_plan(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "icloud-recovery-plan-unavailable".to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 64 * 1024 { + return Err("icloud-recovery-plan-unsafe".into()); + } + serde_json::from_slice( + &std::fs::read(path).map_err(|_| "icloud-recovery-plan-read-failed".to_string())?, + ) + .map_err(|_| "icloud-recovery-plan-json-invalid".into()) +} + +fn run() -> Result<(), String> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME is unavailable".to_string())?; + let args = parse_args(&std::env::args().skip(1).collect::>(), &home)?; + let now = now_ms()?; + let health = + health_evidence_snapshot_from_report(&probe_icloud_sync_health(&args.db_dir, now)?)?; + let daemon = observe_icloud_file_provider_daemon()?; + let output = if let Some(path) = args.execute_plan.as_deref() { + serde_json::to_value(execute_icloud_file_provider_recovery( + &read_plan(path)?, + &health, + now, + args.confirmation.as_deref().unwrap_or_default(), + args.rationale.as_deref().unwrap_or_default(), + )?) + } else { + serde_json::to_value(plan_icloud_file_provider_recovery( + &health, + daemon, + unsafe { libc::getuid() }, + now, + )) + } + .map_err(|_| "icloud-recovery-output-json-invalid".to_string())?; + let encoded = serde_json::to_vec_pretty(&output) + .map_err(|_| "icloud-recovery-output-json-invalid".to_string())?; + if let Some(path) = args.output.as_deref() { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + use std::io::Write; + let mut file = options + .open(path) + .map_err(|_| "icloud-recovery-output-create-failed".to_string())?; + file.write_all(&encoded) + .and_then(|_| file.sync_all()) + .map_err(|_| "icloud-recovery-output-write-failed".to_string())?; + } + println!("{}", String::from_utf8_lossy(&encoded)); + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("DiskSage iCloud provider recovery: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_requires_absolute_plan_confirmation_and_rationale_together() { + let home = Path::new("/home/test"); + assert!(parse_args(&[], home).is_ok()); + assert!(parse_args(&["--execute-plan".into(), "relative".into()], home).is_err()); + assert!(parse_args( + &[ + "--execute-plan".into(), + "/tmp/plan.json".into(), + "--confirm".into(), + "phrase".into(), + "--rationale".into(), + "reason".into(), + ], + home + ) + .is_ok()); + } +} diff --git a/src-tauri/src/bin/disksage-protect-path.rs b/src-tauri/src/bin/disksage-protect-path.rs new file mode 100644 index 000000000..156f2aae1 --- /dev/null +++ b/src-tauri/src/bin/disksage-protect-path.rs @@ -0,0 +1,146 @@ +use disksage_lib::{bind_retained_ontology_class, filesystem_object_id}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +const USAGE: &str = "Usage: disksage-protect-path --path ABSOLUTE_PATH --class RETAINED_CLASS_IRI"; + +fn bind_current_object( + path: &Path, + class_id: &str, + before_bind: impl FnOnce(), +) -> Result { + let object_id = filesystem_object_id(path) + .map_err(|_| "ontology-protection-target-unavailable".to_string())?; + let current_object_id = filesystem_object_id(path) + .map_err(|_| "ontology-protection-target-changed".to_string())?; + if current_object_id != object_id { + return Err("ontology-protection-target-changed".into()); + } + before_bind(); + let pre_bind_object_id = filesystem_object_id(path) + .map_err(|_| "ontology-protection-target-changed".to_string())?; + if pre_bind_object_id != object_id { + return Err("ontology-protection-target-changed".into()); + } + bind_retained_ontology_class(path, class_id)?; + let bound_object_id = filesystem_object_id(path) + .map_err(|_| "ontology-protection-target-changed".to_string())?; + if bound_object_id != object_id { + return Err("ontology-protection-target-changed".into()); + } + Ok(object_id) +} + +fn run(args: impl IntoIterator) -> Result { + let args = args.into_iter().collect::>(); + if args.len() == 1 && matches!(args[0].to_str(), Some("-h" | "--help")) { + return Ok(serde_json::json!({"help": USAGE})); + } + let mut path = None; + let mut class_id = None; + let mut index = 0; + while index < args.len() { + let value = args + .get(index + 1) + .cloned() + .ok_or_else(|| "ontology-protection-missing-value".to_string())?; + match args[index].to_str() { + Some("--path") if path.is_none() => path = Some(PathBuf::from(value)), + Some("--class") if class_id.is_none() => { + class_id = Some( + value + .into_string() + .map_err(|_| "ontology-protection-class-invalid".to_string())?, + ) + } + _ => return Err("ontology-protection-invalid-argument".into()), + } + index += 2; + } + let path = path.ok_or_else(|| "ontology-protection-path-required".to_string())?; + let class_id = class_id.ok_or_else(|| "ontology-protection-class-required".to_string())?; + let object_id = bind_current_object(&path, &class_id, || {})?; + Ok(serde_json::json!({ + "schema_kind": "disksage.ontology-protection-binding/v1", + "class_id": class_id, + "target_object_id": object_id, + "path_redacted": true, + "binding_written": true + })) +} + +fn main() { + let args = std::env::args_os().skip(1).collect::>(); + if args.len() == 1 && matches!(args[0].to_str(), Some("-h" | "--help")) { + println!("{USAGE}"); + return; + } + match run(args) { + Ok(report) => println!( + "{}", + serde_json::to_string_pretty(&report).unwrap_or_default() + ), + Err(error) => { + eprintln!("disksage-protect-path: {error}"); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RETAINED_CLASS: &str = + "https://disksage.app/ontology#CustomerRelationshipManagementData"; + + #[test] + fn retained_binding_protects_exact_file_without_exposing_its_path() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("business.db"); + std::fs::write(&file, b"business").unwrap(); + let report = run([ + OsString::from("--path"), + file.clone().into_os_string(), + OsString::from("--class"), + OsString::from(RETAINED_CLASS), + ]) + .unwrap(); + + assert_eq!(report["path_redacted"], true); + assert!(disksage_lib::is_protected(&file)); + assert!(!report.to_string().contains(file.to_str().unwrap())); + + let installer = temp.path().join("installer.dmg"); + std::fs::write(&installer, b"installer").unwrap(); + assert_eq!( + bind_retained_ontology_class(&installer, "https://disksage.app/ontology#Installer") + .unwrap_err(), + "ontology-protection-class-not-retained" + ); + assert!(!disksage_lib::is_protected(&installer)); + } + + #[cfg(unix)] + #[test] + fn replacement_between_identity_and_binding_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("business.db"); + let replacement = temp.path().join("replacement.db"); + std::fs::write(&file, b"original").unwrap(); + std::fs::write(&replacement, b"replacement").unwrap(); + let original_id = filesystem_object_id(&file).unwrap(); + + let error = bind_current_object(&file, RETAINED_CLASS, || { + std::fs::rename(&replacement, &file).unwrap(); + }) + .unwrap_err(); + + assert_eq!(error, "ontology-protection-target-changed"); + assert_ne!(filesystem_object_id(&file).unwrap(), original_id); + assert!( + !disksage_lib::is_protected(&file), + "a replacement that was never reviewed must not inherit the failed protection request" + ); + } +} diff --git a/src-tauri/src/bin/disksage-runtime-storage.rs b/src-tauri/src/bin/disksage-runtime-storage.rs new file mode 100644 index 000000000..c28aab909 --- /dev/null +++ b/src-tauri/src/bin/disksage-runtime-storage.rs @@ -0,0 +1,122 @@ +use disksage_lib::runtime_storage::{self, RuntimeStorageKind}; +use std::ffi::OsString; + +const USAGE: &str = "Usage: disksage-runtime-storage --runtime [--execute --confirm EXACT_PHRASE --rationale TEXT]"; + +fn runtime(value: &str) -> Result { + match value { + "colima" => Ok(RuntimeStorageKind::Colima), + "podman-machine" => Ok(RuntimeStorageKind::PodmanMachine), + _ => Err(format!("unsupported runtime\n{USAGE}")), + } +} + +fn utf8_args(raw: impl IntoIterator) -> Result, String> { + raw.into_iter() + .map(|value| { + value + .into_string() + .map_err(|_| "invalid argument encoding".into()) + }) + .collect() +} + +fn run() -> Result<(), String> { + let raw = utf8_args(std::env::args_os().skip(1))?; + if raw.as_slice() == ["--help"] || raw.as_slice() == ["-h"] { + println!("{USAGE}"); + return Ok(()); + } + let mut selected = None; + let mut execute = false; + let mut confirm = None; + let mut rationale = None; + let mut index = 0; + while index < raw.len() { + let value = |index: &mut usize, flag: &str| -> Result { + *index += 1; + raw.get(*index) + .cloned() + .ok_or_else(|| format!("{flag} requires a value\n{USAGE}")) + }; + match raw[index].as_str() { + "--runtime" if selected.is_none() => { + selected = Some(runtime(&value(&mut index, "--runtime")?)?) + } + "--execute" if !execute => execute = true, + "--confirm" if confirm.is_none() => confirm = Some(value(&mut index, "--confirm")?), + "--rationale" if rationale.is_none() => { + rationale = Some(value(&mut index, "--rationale")?) + } + _ => return Err(format!("invalid argument\n{USAGE}")), + } + index += 1; + } + let selected = selected.ok_or_else(|| format!("--runtime is required\n{USAGE}"))?; + if execute { + let output = runtime_storage::execute_trim( + selected, + confirm + .as_deref() + .ok_or_else(|| format!("--execute requires --confirm\n{USAGE}"))?, + rationale + .as_deref() + .ok_or_else(|| format!("--execute requires --rationale\n{USAGE}"))?, + )?; + println!( + "{}", + serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? + ); + if !output.executed || output.status_code != 0 { + return Err("runtime-storage-trim-failed".into()); + } + } else { + if confirm.is_some() || rationale.is_some() { + return Err(format!( + "--confirm and --rationale require --execute\n{USAGE}" + )); + } + let plan = runtime_storage::inspect() + .into_iter() + .find(|plan| plan.runtime == selected) + .ok_or_else(|| "runtime-storage-plan-unavailable".to_string())?; + println!( + "{}", + serde_json::to_string_pretty(&plan).map_err(|error| error.to_string())? + ); + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_names_are_exact() { + assert_eq!(runtime("colima").unwrap(), RuntimeStorageKind::Colima); + assert_eq!( + runtime("podman-machine").unwrap(), + RuntimeStorageKind::PodmanMachine + ); + assert!(runtime("docker").is_err()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_arguments_fail_without_panicking() { + use std::os::unix::ffi::OsStringExt; + + assert_eq!( + utf8_args([OsString::from_vec(vec![0xff])]).unwrap_err(), + "invalid argument encoding" + ); + } +} diff --git a/src-tauri/src/cache_cleanup.rs b/src-tauri/src/cache_cleanup.rs index 673ae3d7b..a06476611 100644 --- a/src-tauri/src/cache_cleanup.rs +++ b/src-tauri/src/cache_cleanup.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use crate::{commands::CleanResult, rules, safety}; @@ -10,27 +11,48 @@ fn sort_targets(targets: &mut Vec) { /// Local caches observed during the current low-disk incident and safe to regenerate. /// npm's content-addressed cache is rebuilt by npm on demand; it is included only after the same /// per-child identity and active-use checks as the other caches. -pub const AUTO_REGENERABLE_CACHE_IDS: [&str; 6] = [ +pub const AUTO_REGENERABLE_CACHE_IDS: [&str; 11] = [ "npm-cache", "pnpm-cache", "adobe-cache", "edge-cache", + "edge-code-sign-clones", "uv-cache", "trivy-cache", + "appmap-download-cache", + "superset-http-cache", + "superset-code-cache", + "playwright-cache", ]; -const PROVEN_CACHE_TRASH_NAMES: [&str; 9] = [ +const PROVEN_CACHE_TRASH_NAMES: [&str; 15] = [ "_cacache", "v11", "Default", "simple-v21", + "simple-v22", + "simple-v24", "typequest", "wheels-v6", "sdists-v9", "builds-v0", + "git-v0", + "archive-v0", "db", + "com.apple.CloudDocs.iCloudDriveFileProvider", + "fileprovider-fpck", ]; const MAX_CACHE_TRASH_ENTRIES: usize = 1_000_000; +// Large package caches need longer than the interactive worktree probe while retaining the same +// recursive open-handle evidence and fail-closed timeout behavior. +const CACHE_ACTIVE_USE_PROBE_TIMEOUT_MS: u64 = 30_000; + +fn remaining_probe_timeout_ms(elapsed: Duration) -> Option { + Duration::from_millis(CACHE_ACTIVE_USE_PROBE_TIMEOUT_MS) + .checked_sub(elapsed) + .and_then(|remaining| u64::try_from(remaining.as_millis()).ok()) + .filter(|remaining| *remaining > 0) +} /// A cache directory already in OS Trash whose structure is still recognizable without reading /// user file contents. Permanent removal is intentionally limited to these signatures. @@ -54,6 +76,194 @@ pub struct CacheTrashPurgeResult { pub error: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UvCachePruneResult { + pub cache_path: String, + pub bytes_before: u64, + pub bytes_after: u64, + pub observed_reduction_bytes: u64, + pub status_code: i32, + pub executed: bool, +} + +#[cfg(target_os = "macos")] +fn fixed_uv_path() -> Result { + use std::os::unix::fs::PermissionsExt; + + for link in ["/opt/homebrew/bin/uv", "/usr/local/bin/uv"] { + let Ok(path) = std::fs::canonicalize(link) else { + continue; + }; + let allowed = path.starts_with("/opt/homebrew/Cellar/uv/") + || path.starts_with("/usr/local/Cellar/uv/"); + let metadata = std::fs::symlink_metadata(&path).ok(); + if allowed + && metadata.is_some_and(|metadata| { + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.permissions().mode() & 0o111 != 0 + }) + { + return Ok(path); + } + } + Err("uv-cache-prune-executable-unavailable".into()) +} + +#[cfg(target_os = "macos")] +fn private_uv_copy(source_path: &Path) -> Result { + use std::io::{Seek, SeekFrom}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let mut source = std::fs::File::open(source_path) + .map_err(|_| "uv-cache-prune-executable-unavailable".to_string())?; + let opened = source + .metadata() + .map_err(|_| "uv-cache-prune-executable-unavailable".to_string())?; + let current = std::fs::symlink_metadata(source_path) + .map_err(|_| "uv-cache-prune-executable-unavailable".to_string())?; + if !opened.is_file() + || !current.is_file() + || current.file_type().is_symlink() + || opened.dev() != current.dev() + || opened.ino() != current.ino() + { + return Err("uv-cache-prune-executable-identity-changed".into()); + } + source + .seek(SeekFrom::Start(0)) + .map_err(|_| "uv-cache-prune-executable-copy-failed".to_string())?; + let directory = tempfile::Builder::new() + .prefix("disksage-uv-") + .tempdir() + .map_err(|_| "uv-cache-prune-private-copy-unavailable".to_string())?; + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "uv-cache-prune-private-copy-unavailable".to_string())?; + let destination = directory.path().join("uv"); + let mut copy = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .map_err(|_| "uv-cache-prune-private-copy-unavailable".to_string())?; + std::io::copy(&mut source, &mut copy) + .map_err(|_| "uv-cache-prune-executable-copy-failed".to_string())?; + copy.sync_all() + .map_err(|_| "uv-cache-prune-executable-copy-failed".to_string())?; + std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "uv-cache-prune-private-copy-unavailable".to_string())?; + Ok(directory) +} + +#[cfg(target_os = "macos")] +fn run_private_uv_prune(executable: &Path, cache: &Path) -> Result { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + use std::thread; + + let mut command = Command::new(executable); + command + .args(["cache", "prune", "--cache-dir"]) + .arg(cache) + .args(["--no-config", "--no-progress"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .map_err(|_| "uv-cache-prune-spawn-failed".to_string())?; + let deadline = Instant::now() + Duration::from_secs(300); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status.code().unwrap_or(-1)), + Ok(None) if Instant::now() >= deadline => { + unsafe { + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + return Err("uv-cache-prune-timeout".into()); + } + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(_) => { + unsafe { + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + return Err("uv-cache-prune-wait-failed".into()); + } + } + } +} + +/// Run uv's native dangling-entry prune without `--force`, from a private copy of the verified +/// Homebrew executable. uv retains environments that are still in use. +pub fn prune_uv_cache_headless( + journal_path: &Path, + now_ms: u64, +) -> Result { + #[cfg(not(target_os = "macos"))] + { + let _ = (journal_path, now_ms); + return Err("uv-cache-prune-unsupported-platform".into()); + } + #[cfg(target_os = "macos")] + { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let cache = rules::cache_candidate(&bases, "uv-cache") + .filter(|candidate| candidate.exists) + .map(|candidate| PathBuf::from(candidate.path)) + .ok_or("uv-cache-prune-cache-unavailable")?; + let mut entries = 0; + let bytes_before = bounded_tree_size(&cache, &mut entries, true)?; + let source = fixed_uv_path()?; + let private = private_uv_copy(&source)?; + let mut journal = safety::JournalEntry { + ts_ms: now_ms, + op: "uv_cache_prune".into(), + path: cache.to_string_lossy().into_owned(), + bytes: bytes_before, + outcome: "pending".into(), + }; + safety::journal_append(journal_path, &journal).map_err(|error| error.to_string())?; + let status_code = match run_private_uv_prune(&private.path().join("uv"), &cache) { + Ok(status_code) => status_code, + Err(error) => { + journal.outcome = format!("error:{error}"); + safety::journal_append(journal_path, &journal) + .map_err(|journal_error| journal_error.to_string())?; + return Err(error); + } + }; + let mut entries = 0; + let bytes_after = bounded_tree_size(&cache, &mut entries, true)?; + journal.outcome = if status_code == 0 { + "ok" + } else { + "error:uv-exit-nonzero" + } + .into(); + safety::journal_append(journal_path, &journal).map_err(|error| error.to_string())?; + Ok(UvCachePruneResult { + cache_path: cache.to_string_lossy().into_owned(), + bytes_before, + bytes_after, + observed_reduction_bytes: bytes_before.saturating_sub(bytes_after), + status_code, + executed: true, + }) + } +} + fn direct_child_is_dir(path: &Path, name: &str) -> bool { let child = path.join(name); std::fs::symlink_metadata(child) @@ -66,8 +276,140 @@ fn direct_child_is_file(path: &Path, name: &str) -> bool { .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) } +fn native_trash_collision_suffix(suffix: &str) -> bool { + !suffix.is_empty() + && (suffix.bytes().all(|byte| byte.is_ascii_digit()) + || suffix.split('-').map(str::len).eq([2, 2, 2, 3]) + && suffix + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-')) +} + +fn proven_cache_base_name(name: &str) -> Option<&str> { + PROVEN_CACHE_TRASH_NAMES.iter().copied().find(|base| { + name == *base + || name + .strip_prefix(base) + .and_then(|suffix| suffix.strip_prefix(' ')) + .is_some_and(native_trash_collision_suffix) + }) +} + +fn edge_code_sign_clone_name(name: &str) -> bool { + let (base, collision) = name + .split_once(' ') + .map_or((name, None), |(base, suffix)| (base, Some(suffix))); + let Some(suffix) = base.strip_prefix("code_sign_clone.") else { + return false; + }; + suffix.len() == 6 + && suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) + && collision.is_none_or(native_trash_collision_suffix) +} + +fn looks_like_uv_archive_cache(path: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(path) else { + return false; + }; + let mut seen = false; + for entry in entries { + let Ok(entry) = entry else { + return false; + }; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Ok(metadata) = entry.path().symlink_metadata() else { + return false; + }; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || name.len() != 16 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return false; + } + seen = true; + } + seen +} + +fn is_uuid_name(name: &str) -> bool { + name.len() == 36 + && name.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn fileprovider_sqlite_triplet(path: &Path, prefix: &str) -> bool { + let Ok(entries) = std::fs::read_dir(path) else { + return false; + }; + let mut names = entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + let Some(database) = names.iter().find(|name| { + name.starts_with(prefix) && !name.ends_with("-wal") && !name.ends_with("-shm") + }) else { + return false; + }; + let suffix = database.strip_prefix(prefix).unwrap_or(database); + let valid_name = if prefix.is_empty() { + suffix.get(0..36).is_some_and(is_uuid_name) && suffix.as_bytes().get(36) == Some(&b'-') + } else { + suffix.strip_suffix(".db").is_some_and(|stem| { + !stem.is_empty() + && stem + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-') + }) + }; + valid_name + && names.len() == 3 + && direct_child_is_file(path, database) + && direct_child_is_file(path, &format!("{database}-wal")) + && direct_child_is_file(path, &format!("{database}-shm")) +} + +fn looks_like_fileprovider_temporary_item(path: &Path, cloud_docs: bool) -> bool { + let Ok(mut entries) = std::fs::read_dir(path) else { + return false; + }; + let Some(Ok(account)) = entries.next() else { + return false; + }; + if entries.next().is_some() + || !is_uuid_name(&account.file_name().to_string_lossy()) + || !account + .path() + .symlink_metadata() + .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink()) + { + return false; + } + fileprovider_sqlite_triplet(&account.path(), if cloud_docs { "database-" } else { "" }) +} + fn looks_like_proven_cache_trash(path: &Path, name: &str) -> Option<&'static str> { - let signature = match name { + if edge_code_sign_clone_name(name) { + let bundle = path.join("Microsoft Edge.app.bundle"); + let contents = bundle.join("Contents"); + return (direct_child_is_dir(path, "Microsoft Edge.app.bundle") + && direct_child_is_dir(&bundle, "Contents") + && direct_child_is_dir(&contents, "MacOS") + && direct_child_is_dir(&contents, "_CodeSignature") + && direct_child_is_file(&contents, "Info.plist")) + .then_some("edge-code-sign-clone"); + } + let base_name = proven_cache_base_name(name)?; + let signature = match base_name { "_cacache" if direct_child_is_dir(path, "content-v2") && direct_child_is_dir(path, "tmp") => { @@ -84,7 +426,9 @@ fn looks_like_proven_cache_trash(path: &Path, name: &str) -> Option<&'static str { "edge-profile-cache" } - "simple-v21" if direct_child_is_dir(path, "pypi") => "uv-simple-index-cache", + "simple-v21" | "simple-v22" | "simple-v24" if direct_child_is_dir(path, "pypi") => { + "uv-simple-index-cache" + } "typequest" if direct_child_is_dir(path, "common") && direct_child_is_dir(path, ".2") => { "uv-typequest-cache" } @@ -108,17 +452,37 @@ fn looks_like_proven_cache_trash(path: &Path, name: &str) -> Option<&'static str }); has_build.then_some("uv-build-cache")? } + "git-v0" + if direct_child_is_dir(path, "locks") + && direct_child_is_dir(path, "checkouts") + && direct_child_is_dir(path, "db") => + { + "uv-git-cache" + } + "archive-v0" if looks_like_uv_archive_cache(path) => "uv-archive-cache", "db" if direct_child_is_file(path, "trivy.db") && direct_child_is_file(path, "metadata.json") => { "trivy-database-cache" } + "com.apple.CloudDocs.iCloudDriveFileProvider" + if looks_like_fileprovider_temporary_item(path, true) => + { + "fileprovider-cloud-docs-temporary-sqlite" + } + "fileprovider-fpck" if looks_like_fileprovider_temporary_item(path, false) => { + "fileprovider-fpck-temporary-sqlite" + } _ => return None, }; Some(signature) } -fn bounded_tree_size(path: &Path, entries: &mut usize) -> Result { +fn bounded_tree_size( + path: &Path, + entries: &mut usize, + allow_unfollowed_symlinks: bool, +) -> Result { *entries = entries.saturating_add(1); if *entries > MAX_CACHE_TRASH_ENTRIES { return Err("cache-trash-entry-limit-exceeded".into()); @@ -126,7 +490,9 @@ fn bounded_tree_size(path: &Path, entries: &mut usize) -> Result { let metadata = std::fs::symlink_metadata(path).map_err(|_| "cache-trash-stat-failed".to_string())?; if metadata.file_type().is_symlink() { - return Err("cache-trash-symlink-rejected".into()); + return allow_unfollowed_symlinks + .then_some(0) + .ok_or_else(|| "cache-trash-symlink-rejected".into()); } if metadata.is_file() { return Ok(metadata.len()); @@ -137,7 +503,11 @@ fn bounded_tree_size(path: &Path, entries: &mut usize) -> Result { let mut total = 0u64; for entry in std::fs::read_dir(path).map_err(|_| "cache-trash-read-dir-failed".to_string())? { let entry = entry.map_err(|_| "cache-trash-read-entry-failed".to_string())?; - total = total.saturating_add(bounded_tree_size(&entry.path(), entries)?); + total = total.saturating_add(bounded_tree_size( + &entry.path(), + entries, + allow_unfollowed_symlinks, + )?); } Ok(total) } @@ -152,7 +522,7 @@ pub fn proven_cache_trash_candidates(home: &Path) -> Vec { let mut candidates = Vec::new(); for entry in entries.filter_map(Result::ok) { let name = entry.file_name().to_string_lossy().into_owned(); - if !PROVEN_CACHE_TRASH_NAMES.contains(&name.as_str()) { + if proven_cache_base_name(&name).is_none() && !edge_code_sign_clone_name(&name) { continue; } let path = entry.path(); @@ -160,7 +530,11 @@ pub fn proven_cache_trash_candidates(home: &Path) -> Vec { continue; }; let mut count = 0; - let Ok(bytes) = bounded_tree_size(&path, &mut count) else { + let Ok(bytes) = bounded_tree_size( + &path, + &mut count, + matches!(signature, "edge-code-sign-clone" | "uv-archive-cache"), + ) else { continue; }; candidates.push(CacheTrashCandidate { @@ -242,25 +616,42 @@ pub(crate) fn clean_cache_contents_inner( if !rules::is_catalog_path(bases, dir) { return Err("cache-root-not-current-or-safe".into()); } + if dir == bases.temp || dir == rules::shared_temp_root() { + return Err("temp-cleanup-requires-purpose-specific-audit".into()); + } let mut expected = requested_targets.to_vec(); sort_targets(&mut expected); let mut current = rules::cache_targets(dir)?; sort_targets(&mut current); - if current != expected { + if expected.iter().any(|target| !current.contains(target)) { return Err("cache-cleanup-targets-stale".into()); } + expected.sort_by(|left, right| { + right + .bytes + .cmp(&left.bytes) + .then_with(|| left.path.cmp(&right.path)) + }); + let probe_started = Instant::now(); Ok(expected .into_iter() .map(|target| { + let Some(probe_timeout_ms) = remaining_probe_timeout_ms(probe_started.elapsed()) else { + return CleanResult { + path: target.path, + ok: false, + error: "cache-target-active-use-evidence-incomplete".into(), + }; + }; // Probe each reviewed child independently: a live MCP/uv process must not prevent - // reclaiming unrelated, inactive cache archives in the same catalog root. + // reclaiming unrelated archives, within one bounded operation-wide evidence budget. let recursive = std::fs::symlink_metadata(&target.path) .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink()) .unwrap_or(false); let active_use = crate::git_worktree::active_use_evidence( Path::new(&target.path), - crate::reclaim::ACTIVE_USE_PROBE_TIMEOUT_MS, + probe_timeout_ms, crate::reclaim::ACTIVE_USE_PROBE_MAX_PIDS, recursive, ); @@ -271,8 +662,9 @@ pub(crate) fn clean_cache_contents_inner( error: error.into(), }; } - match safety::trash_delete_if_identity( + match safety::trash_delete_if_identity_in_catalog_root( Path::new(&target.path), + dir, &target.object_id, target.bytes, journal_path, @@ -337,6 +729,48 @@ pub fn clean_regenerable_caches_headless( .map_err(|error| error.to_string()) } +/// Return a fresh exact-child snapshot for one fixed catalog cache ID. +pub fn plan_catalog_cache_headless(cache_id: &str) -> Result { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let candidate = rules::cache_candidate(&bases, cache_id) + .ok_or("cache-catalog-id-unknown")?; + let targets = if candidate.exists { + rules::cache_targets(Path::new(&candidate.path))? + } else { + Vec::new() + }; + Ok(serde_json::json!({ "candidate": candidate, "targets": targets })) +} + +/// Re-audit and move only one fixed catalog cache's unchanged, inactive children to OS Trash. +pub fn clean_catalog_cache_headless( + cache_id: &str, + target_object_id: Option<&str>, + journal_path: &Path, + now_ms: u64, +) -> Result { + let bases = rules::BaseDirs::from_env().ok_or("cache-base-directories-unavailable")?; + let candidate = rules::cache_candidate(&bases, cache_id) + .filter(|candidate| candidate.exists) + .ok_or("cache-catalog-target-unavailable")?; + let path = PathBuf::from(&candidate.path); + let mut targets = rules::cache_targets(&path)?; + if let Some(object_id) = target_object_id { + targets.retain(|target| target.object_id == object_id); + if targets.len() != 1 { + return Err("cache-catalog-target-object-id-unavailable".into()); + } + } + serde_json::to_value(clean_cache_contents_inner( + &bases, + &path, + &targets, + journal_path, + now_ms, + )?) + .map_err(|error| error.to_string()) +} + /// Read the exact cache children that may be included in a later identity-bound Trash request. #[cfg(not(coverage))] #[tauri::command] @@ -380,6 +814,16 @@ mod tests { } } + #[test] + fn active_use_probe_budget_is_operation_wide() { + assert_eq!(remaining_probe_timeout_ms(Duration::ZERO), Some(30_000)); + assert_eq!( + remaining_probe_timeout_ms(Duration::from_secs(29)), + Some(1_000) + ); + assert_eq!(remaining_probe_timeout_ms(Duration::from_secs(30)), None); + } + #[test] fn cleanup_rejects_non_catalog_root() { let tmp = tempfile::tempdir().unwrap(); @@ -394,18 +838,51 @@ mod tests { assert_eq!(error, "cache-root-not-current-or-safe"); } + #[cfg(unix)] #[test] - fn cleanup_rejects_stale_target_snapshot_without_mutation() { + fn cleanup_rejects_broad_shared_temp_mutation() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let journal = tmp.path().join("journal.jsonl"); + #[cfg(target_os = "macos")] + let shared_temp = Path::new("/private/tmp"); + #[cfg(not(target_os = "macos"))] + let shared_temp = Path::new("/tmp"); + + let error = clean_cache_contents_inner(&bases, shared_temp, &[], &journal, 1) + .err() + .expect("shared temp requires a purpose-specific audit"); + + assert_eq!(error, "temp-cleanup-requires-purpose-specific-audit"); + } + + #[test] + fn cleanup_rejects_broad_user_temp_mutation() { let tmp = tempfile::tempdir().unwrap(); let bases = fake_bases(tmp.path()); fs::create_dir(&bases.temp).unwrap(); - let victim = bases.temp.join("keep.bin"); + let journal = tmp.path().join("journal.jsonl"); + + let error = clean_cache_contents_inner(&bases, &bases.temp, &[], &journal, 1) + .err() + .expect("user temp requires a purpose-specific audit"); + + assert_eq!(error, "temp-cleanup-requires-purpose-specific-audit"); + } + + #[test] + fn cleanup_rejects_stale_target_snapshot_without_mutation() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let cache = bases.home.join(".npm"); + fs::create_dir_all(&cache).unwrap(); + let victim = cache.join("keep.bin"); fs::write(&victim, b"keep").unwrap(); let journal = tmp.path().join("journal.jsonl"); - let mut targets = rules::cache_targets(&bases.temp).unwrap(); + let mut targets = rules::cache_targets(&cache).unwrap(); targets[0].bytes += 1; - let error = clean_cache_contents_inner(&bases, &bases.temp, &targets, &journal, 1) + let error = clean_cache_contents_inner(&bases, &cache, &targets, &journal, 1) .err() .expect("stale target snapshot must be rejected"); @@ -472,6 +949,156 @@ mod tests { assert!(journal_text.contains("\"outcome\":\"ok\"")); } + #[test] + fn proven_fileprovider_temporary_sqlite_requires_exact_triplet() { + let tmp = tempfile::tempdir().unwrap(); + let trash = tmp.path().join(".Trash"); + let cloud = trash.join("com.apple.CloudDocs.iCloudDriveFileProvider"); + let account = cloud.join("75876723-DC8F-4F53-9282-AE20BDB9034C"); + fs::create_dir_all(&account).unwrap(); + for suffix in ["", "-wal", "-shm"] { + fs::write( + account.join(format!("database-1788164997-554161.db{suffix}")), + b"x", + ) + .unwrap(); + } + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].signature, + "fileprovider-cloud-docs-temporary-sqlite" + ); + + fs::write(account.join("business.db"), b"keep").unwrap(); + assert!(proven_cache_trash_candidates(tmp.path()).is_empty()); + } + + #[test] + fn proven_uv_git_cache_requires_all_native_directories() { + let tmp = tempfile::tempdir().unwrap(); + let trash = tmp.path().join(".Trash"); + let git = trash.join("git-v0"); + fs::create_dir_all(git.join("locks")).unwrap(); + fs::create_dir(git.join("checkouts")).unwrap(); + assert!(proven_cache_trash_candidates(tmp.path()).is_empty()); + + fs::create_dir(git.join("db")).unwrap(); + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].signature, "uv-git-cache"); + } + + #[cfg(unix)] + #[test] + fn proven_uv_archive_cache_requires_native_keys_and_never_follows_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join(".Trash/archive-v0"); + let entry = archive.join("Ab12_-cdEF34ghIJ"); + fs::create_dir_all(&entry).unwrap(); + let outside = tmp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep"), b"keep").unwrap(); + std::os::unix::fs::symlink(&outside, entry.join("linked-package")).unwrap(); + + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].signature, "uv-archive-cache"); + assert_eq!(candidates[0].bytes, 0); + + let results = + purge_proven_cache_trash(tmp.path(), &tmp.path().join("journal.jsonl"), 9).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].purged); + assert_eq!(fs::read(outside.join("keep")).unwrap(), b"keep"); + + let invalid = tmp.path().join(".Trash/archive-v0"); + fs::create_dir_all(invalid.join("not-a-native-key!")).unwrap(); + assert!(proven_cache_trash_candidates(tmp.path()).is_empty()); + } + + #[cfg(target_os = "macos")] + #[test] + fn private_uv_prune_uses_only_fixed_non_force_arguments() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let executable = tmp.path().join("uv"); + fs::write( + &executable, + b"#!/bin/sh\n[ \"$1\" = cache ] && [ \"$2\" = prune ] && [ \"$3\" = --cache-dir ] && [ \"$5\" = --no-config ] && [ \"$6\" = --no-progress ]\n", + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let cache = tmp.path().join("cache"); + fs::create_dir(&cache).unwrap(); + + assert_eq!(run_private_uv_prune(&executable, &cache).unwrap(), 0); + } + + #[test] + fn proven_cache_accepts_only_native_trash_collision_names() { + let tmp = tempfile::tempdir().unwrap(); + let trash = tmp.path().join(".Trash"); + for name in [ + "git-v0 2", + "git-v0 14-56-42-563", + "git-v0-old", + "git-v0 2 old", + "git-v01", + ] { + let git = trash.join(name); + fs::create_dir_all(git.join("locks")).unwrap(); + fs::create_dir(git.join("checkouts")).unwrap(); + fs::create_dir(git.join("db")).unwrap(); + } + + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 2); + assert!(candidates + .iter() + .all(|candidate| candidate.signature == "uv-git-cache")); + + let wheels = trash.join("wheels-v6 14-56-42-563"); + fs::create_dir_all(wheels.join("pypi")).unwrap(); + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 3); + assert!(candidates.iter().any(|candidate| { + candidate.name == "wheels-v6 14-56-42-563" && candidate.signature == "uv-wheel-cache" + })); + } + + #[cfg(unix)] + #[test] + fn edge_code_sign_clone_signature_purges_without_following_bundle_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let trash = tmp.path().join(".Trash"); + let clone = trash.join("code_sign_clone.Ab12zZ"); + let contents = clone.join("Microsoft Edge.app.bundle/Contents"); + fs::create_dir_all(contents.join("MacOS")).unwrap(); + fs::create_dir(contents.join("_CodeSignature")).unwrap(); + fs::write(contents.join("Info.plist"), b"plist").unwrap(); + let outside = tmp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep"), b"keep").unwrap(); + std::os::unix::fs::symlink(&outside, contents.join("Frameworks")).unwrap(); + + for invalid in ["code_sign_clone.short", "code_sign_clone.Ab12zZ.old"] { + fs::create_dir_all(trash.join(invalid)).unwrap(); + } + + let candidates = proven_cache_trash_candidates(tmp.path()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].signature, "edge-code-sign-clone"); + + let results = + purge_proven_cache_trash(tmp.path(), &tmp.path().join("journal.jsonl"), 8).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].purged); + assert!(!clone.exists()); + assert_eq!(fs::read(outside.join("keep")).unwrap(), b"keep"); + } + #[cfg(unix)] #[test] fn cleanup_rejects_symlinked_catalog_root_without_touching_outside_data() { diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 8eb339654..24eedf578 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -1192,6 +1192,9 @@ pub fn collect_archive_files_bounded( return true; } let path = entry.path(); + if crate::safety::is_explicitly_protected(path) { + return false; + } if excluded.iter().any(|cloud| path.starts_with(cloud)) { return false; } diff --git a/src-tauri/src/cloud_adr.rs b/src-tauri/src/cloud_adr.rs index 13371fef8..23e22df01 100644 --- a/src-tauri/src/cloud_adr.rs +++ b/src-tauri/src/cloud_adr.rs @@ -8,6 +8,7 @@ use crate::provider_evidence::ProviderSyncEvidenceRecord; use std::collections::BTreeMap; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -25,6 +26,7 @@ const MAX_PROJECTION_BYTES: u64 = 256 * 1024; // ponytail: one process-wide lock keeps low-volume projections ordered; the receipt lock below // closes the cross-process race without adding a lock manager or database. static PROJECTION_WRITE_LOCK: OnceLock> = OnceLock::new(); +static PROJECTION_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); const INTERPROCESS_LOCK_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -378,6 +380,17 @@ fn write_latest_json( .lock() .map_err(|_| "cloud-projection-write-lock-poisoned".to_string())?; let _interprocess_guard = acquire_interprocess_projection_lock(directory, receipt_id)?; + write_latest_json_unlocked(directory, receipt_id, updated_at_ms, encoded, kind) +} + +fn write_latest_json_unlocked( + directory: &Path, + receipt_id: &str, + updated_at_ms: u64, + encoded: &[u8], + kind: &str, +) -> Result { + secure_directory(directory)?; let path = directory.join(format!("{receipt_id}-latest.json")); let incoming = projection_state(encoded, kind)?; if let Ok(metadata) = std::fs::symlink_metadata(&path) { @@ -393,8 +406,9 @@ fn write_latest_json( return Err(format!("cloud-{kind}-state-regression")); } } + let sequence = PROJECTION_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); let temporary = directory.join(format!( - ".{receipt_id}-{updated_at_ms}-{}-{kind}.tmp", + ".{receipt_id}-{updated_at_ms}-{}-{sequence}-{kind}.tmp", std::process::id() )); let mut file = std::fs::OpenOptions::new() @@ -429,6 +443,21 @@ pub fn write_latest_snapshot( ) } +fn write_latest_snapshot_unlocked( + directory: &Path, + snapshot: &CloudOffloadAdrSnapshot, +) -> Result { + let encoded = + serde_json::to_vec_pretty(snapshot).map_err(|_| "cloud-adr-json-invalid".to_string())?; + write_latest_json_unlocked( + directory, + &snapshot.receipt_id, + snapshot.updated_at_ms, + &encoded, + "adr", + ) +} + pub fn write_latest_goal_snapshot( directory: &Path, snapshot: &CloudOffloadGoalSnapshot, @@ -444,6 +473,21 @@ pub fn write_latest_goal_snapshot( ) } +fn write_latest_goal_snapshot_unlocked( + directory: &Path, + snapshot: &CloudOffloadGoalSnapshot, +) -> Result { + let encoded = + serde_json::to_vec_pretty(snapshot).map_err(|_| "cloud-goal-json-invalid".to_string())?; + write_latest_json_unlocked( + directory, + &snapshot.receipt_id, + snapshot.updated_at_ms, + &encoded, + "goal", + ) +} + /// Persist replaceable ADR/Goal projections without turning an authoritative receipt/evidence /// result into a failed operation. Immutable records remain the source of truth. #[derive(Debug)] @@ -485,7 +529,7 @@ fn write_projection_pair_unlocked( goal: &CloudOffloadGoalSnapshot, ) -> (Option, Option, Vec) { let mut warnings = Vec::new(); - let adr_path = match write_latest_snapshot(adr_dir, adr) { + let adr_path = match write_latest_snapshot_unlocked(adr_dir, adr) { Ok(path) => Some(path), Err(error) => { // The projection writer only returns bounded, path-free error codes. Preserve the @@ -494,7 +538,7 @@ fn write_projection_pair_unlocked( None } }; - let goal_path = match write_latest_goal_snapshot(goal_dir, goal) { + let goal_path = match write_latest_goal_snapshot_unlocked(goal_dir, goal) { Ok(path) => Some(path), Err(error) => { warnings.push(format!("goal-projection-write-failed:{error}")); diff --git a/src-tauri/src/cloud_local_eviction.rs b/src-tauri/src/cloud_local_eviction.rs index 870b42a61..453e954d7 100644 --- a/src-tauri/src/cloud_local_eviction.rs +++ b/src-tauri/src/cloud_local_eviction.rs @@ -1,4 +1,4 @@ -//! Evidence-bound removal of local iCloud bytes while retaining the cloud object. +//! Evidence-bound removal of local cloud bytes while retaining the cloud object. //! //! Planning is read-only and never opens file content. Execution is macOS-only, requires a //! fingerprint-bound human approval, revalidates native iCloud state and active handles, calls @@ -171,8 +171,11 @@ fn allocated_bytes(metadata: &Metadata) -> u64 { } fn observe_local_file(root: &CloudRoot, path: &Path) -> Result { - if root.provider != CloudProvider::Icloud { - return Err("icloud-local-eviction-requires-icloud-root".into()); + if !matches!( + root.provider, + CloudProvider::Icloud | CloudProvider::Onedrive + ) { + return Err("file-provider-local-eviction-root-required".into()); } let root_path = Path::new(&root.path); if !absolute_without_parent(root_path) || !absolute_without_parent(path) { @@ -243,7 +246,7 @@ fn hash_optional_u64(hasher: &mut blake3::Hasher, value: Option) { hasher.update(&[1]); hasher.update(&value.to_le_bytes()); } - }; + } } fn hash_optional_string(hasher: &mut blake3::Hasher, value: Option<&str>) { @@ -456,12 +459,8 @@ fn observe_lsof_active_use(path: &Path, deadline: Instant) -> ActiveUseEvidence let mut command = Command::new("lsof"); command.arg("-F").arg("p"); if path.is_dir() { - // `lsof PATH` only proves the directory itself is referenced. Recursive +D is required - // for a cache directory whose open files live below the directory entry. command.arg("+D"); } - // A bounded lsof probe may be a shell wrapper in tests or on user systems. Kill its process - // group on timeout so descendants cannot keep the stdout pipe alive and starve the ps probe. unsafe { use std::os::unix::process::CommandExt; command.pre_exec(|| { @@ -632,10 +631,6 @@ fn parse_process_command_references(output: &[u8], path: &Path, own_pid: u32) -> records.push((pid, parent_pid, command.trim_start())); } - // A watchdog or timeout wrapper commonly includes the full child command (and therefore the - // target path) in its own argv. It supervises the planner but does not itself use the file. - // Exclude the planner and its complete ancestor chain while retaining unrelated processes that - // independently reference the same target. let parent_by_pid: BTreeMap = records .iter() .map(|(pid, parent_pid, _)| (*pid, *parent_pid)) @@ -733,18 +728,12 @@ fn observe_process_command_use(path: &Path, deadline: Instant) -> ActiveUseEvide fn observe_active_use_until(path: &Path, deadline: Instant) -> ActiveUseEvidence { let started = Instant::now(); let remaining = deadline.saturating_duration_since(started); - let per_probe_budget = std::cmp::min( - remaining / 2, - Duration::from_millis(ACTIVE_USE_TIMEOUT_MS), - ); - // Reserve an independent bounded slice for each source. A recursive `lsof +D` may consume its - // entire allocation; it must not starve the process-command probe and hide an active PID. + let per_probe_budget = + std::cmp::min(remaining / 2, Duration::from_millis(ACTIVE_USE_TIMEOUT_MS)); let lsof = observe_lsof_active_use(path, started + per_probe_budget); let ps_started = Instant::now(); - let process_commands = observe_process_command_use( - path, - std::cmp::min(deadline, ps_started + per_probe_budget), - ); + let process_commands = + observe_process_command_use(path, std::cmp::min(deadline, ps_started + per_probe_budget)); let mut pids = lsof.observed_pids; pids.extend(process_commands.observed_pids); pids.sort_unstable(); @@ -783,19 +772,13 @@ fn observe_active_use_until(_path: &Path, _deadline: Instant) -> ActiveUseEviden } } -/// Reuse the same bounded open-handle and process-command evidence for any source whose local -/// bytes may be released. Unsupported or incomplete platforms remain explicit and fail closed at -/// the caller. pub fn observe_path_active_use(path: &Path) -> ActiveUseEvidence { observe_active_use_until( path, - Instant::now() + Duration::from_millis( - ACTIVE_USE_TIMEOUT_MS.saturating_mul(2), - ), + Instant::now() + Duration::from_millis(ACTIVE_USE_TIMEOUT_MS.saturating_mul(2)), ) } -/// Observe open handles without allowing the probe to outlive an enclosing plan deadline. pub fn observe_path_active_use_until(path: &Path, deadline: Instant) -> ActiveUseEvidence { observe_active_use_until(path, deadline) } @@ -809,7 +792,6 @@ fn foundation_bool_resource( use objc2_foundation::NSNumber; let mut value: Option> = None; - // SAFETY: Every caller passes a Foundation NSURL key documented as NSNumber-valued. unsafe { url.getResourceValue_forKey_error(&mut value, key) } .map_err(|error| error.localizedDescription().to_string())?; value @@ -828,7 +810,6 @@ fn foundation_string_resource( use objc2_foundation::NSString; let mut value: Option> = None; - // SAFETY: The downloading-status resource key is documented as NSString-valued. unsafe { url.getResourceValue_forKey_error(&mut value, key) } .map_err(|error| error.localizedDescription().to_string())?; value @@ -852,7 +833,6 @@ fn observe_foundation_icloud_state(path: &Path) -> Result Result { @@ -921,17 +902,32 @@ fn observe_file_provider_icloud_state( let url = NSURL::fileURLWithPath(&NSString::from_str(path)); Ok::(NSFileManager::defaultManager().isUbiquitousItemAtURL(&url)) })?; - if !is_ubiquitous { + if root.provider == CloudProvider::GoogleDrive { + return Err("cloud-local-eviction-provider-unsupported".into()); + } + if root.provider == CloudProvider::Icloud && !is_ubiquitous { return Err("icloud-item-not-ubiquitous".into()); } let output = crate::provider_sync::file_providerctl_status(path)?; let status = crate::provider_sync::parse_file_providerctl_item_status(&output, observed_bytes)?; - Ok(file_provider_icloud_state(is_ubiquitous, &status)) + let mut state = file_provider_icloud_state( + is_ubiquitous || root.provider == CloudProvider::Onedrive, + &status, + ); + if root.provider == CloudProvider::Onedrive { + state.allows_eviction = + Some(crate::provider_recovery::onedrive_files_on_demand_available()); + } + Ok(state) } #[cfg(all(target_os = "macos", not(coverage)))] -fn observe_icloud_state(path: &Path, observed_bytes: u64) -> Result { - match observe_file_provider_icloud_state(path, observed_bytes) { +fn observe_icloud_state( + root: &CloudRoot, + path: &Path, + observed_bytes: u64, +) -> Result { + match observe_file_provider_icloud_state(root, path, observed_bytes) { Ok(state) => Ok(state), Err(error) if file_provider_command_allows_foundation_fallback(&error) => { observe_foundation_icloud_state(path) @@ -952,11 +948,14 @@ fn file_provider_command_allows_foundation_fallback(error: &str) -> bool { } #[cfg(any(not(target_os = "macos"), coverage))] -fn observe_icloud_state(_path: &Path, _observed_bytes: u64) -> Result { +fn observe_icloud_state( + _root: &CloudRoot, + _path: &Path, + _observed_bytes: u64, +) -> Result { Err("icloud-local-eviction-unsupported-platform".into()) } -/// Build a read-only, exact-path local eviction plan. File content is never opened. #[cfg(not(coverage))] pub fn plan_icloud_local_eviction( root: &CloudRoot, @@ -964,7 +963,7 @@ pub fn plan_icloud_local_eviction( observed_at_ms: u64, ) -> Result { let file = observe_local_file(root, path)?; - let state = observe_icloud_state(path, file.logical_bytes)?; + let state = observe_icloud_state(root, path, file.logical_bytes)?; let active_use = observe_path_active_use(path); Ok(build_plan( root, @@ -996,7 +995,6 @@ fn approval_id_for( hasher.finalize().to_hex().to_string() } -/// Bind a human decision to one exact eligible plan. This function performs no eviction. pub fn approve_icloud_local_eviction( plan: &IcloudLocalEvictionPlan, approved_plan_fingerprint: &str, @@ -1067,7 +1065,14 @@ fn validate_approval( } #[cfg(all(target_os = "macos", not(coverage)))] -fn request_native_icloud_eviction(path: &Path) -> Result<(), String> { +fn request_native_icloud_eviction(root: &CloudRoot, path: &Path) -> Result, String> { + if root.provider == CloudProvider::Onedrive { + return crate::provider_recovery::unpin_onedrive_local_copy(path) + .map(|outcome| outcome.restart_blockers); + } + if root.provider != CloudProvider::Icloud { + return Err("cloud-local-eviction-provider-unsupported".into()); + } use objc2::rc::autoreleasepool; use objc2_foundation::{NSFileManager, NSString, NSURL}; @@ -1079,15 +1084,16 @@ fn request_native_icloud_eviction(path: &Path) -> Result<(), String> { NSFileManager::defaultManager() .evictUbiquitousItemAtURL_error(&url) .map_err(|error| error.localizedDescription().to_string()) - }) + })?; + Ok(Vec::new()) } #[cfg(any(not(target_os = "macos"), coverage))] -fn request_native_icloud_eviction(_path: &Path) -> Result<(), String> { +fn request_native_icloud_eviction(_root: &CloudRoot, _path: &Path) -> Result, String> { Err("icloud-local-eviction-unsupported-platform".into()) } -fn observe_post_eviction(path: &Path) -> PostEvictionObservation { +fn observe_post_eviction(root: &CloudRoot, path: &Path) -> PostEvictionObservation { let Ok(metadata) = std::fs::symlink_metadata(path) else { return PostEvictionObservation { path_retained: false, @@ -1095,7 +1101,7 @@ fn observe_post_eviction(path: &Path) -> PostEvictionObservation { allocated_bytes: 0, }; }; - let is_ubiquitous = observe_icloud_state(path, metadata.len()) + let is_ubiquitous = observe_icloud_state(root, path, metadata.len()) .map(|state| state.is_ubiquitous) .unwrap_or(false); PostEvictionObservation { @@ -1145,10 +1151,11 @@ fn build_result( approval: &IcloudLocalEvictionApproval, requested_at_ms: u64, post: PostEvictionObservation, + request_blockers: Vec, ) -> IcloudLocalEvictionResult { let reduction = plan.allocated_bytes.saturating_sub(post.allocated_bytes); let reduced = post.allocated_bytes < plan.allocated_bytes; - let mut blockers = Vec::new(); + let mut blockers = request_blockers; if !post.path_retained { blockers.push("icloud-cloud-item-path-not-retained".into()); } @@ -1184,10 +1191,6 @@ fn build_result( result } -/// Remove only the local iCloud copy after revalidating the exact approved plan. -/// -/// This never calls the regular file deletion APIs. A successful Foundation request is reported -/// separately from the observed local-allocation reduction. #[cfg(not(coverage))] pub fn execute_icloud_local_eviction( root: &CloudRoot, @@ -1204,11 +1207,11 @@ pub fn execute_icloud_local_eviction( { return Err("icloud-local-eviction-live-plan-changed".into()); } - request_native_icloud_eviction(path)?; + let request_blockers = request_native_icloud_eviction(root, path)?; let started = Instant::now(); let post = loop { - let observed = observe_post_eviction(path); + let observed = observe_post_eviction(root, path); if !observed.path_retained || !observed.is_ubiquitous || observed.allocated_bytes < approved_plan.allocated_bytes @@ -1219,11 +1222,15 @@ pub fn execute_icloud_local_eviction( } std::thread::sleep(Duration::from_millis(100)); }; - Ok(build_result(approved_plan, approval, requested_at_ms, post)) + Ok(build_result( + approved_plan, + approval, + requested_at_ms, + post, + request_blockers, + )) } -/// Prepare the private approval/result directory without allowing an app-data symlink or a -/// not-yet-created app-data path to redirect the first directory creation into the cloud root. pub fn prepare_immutable_record_directory( app_data_dir: &Path, cloud_root: &Path, @@ -1291,7 +1298,6 @@ pub fn prepare_immutable_record_directory( Ok(record_dir) } -/// Persist an approval or result as a create-new, read-only JSON record. pub fn write_immutable_record( record_dir: &Path, filename: &str, @@ -1347,9 +1353,13 @@ mod tests { use super::*; fn root(path: &Path) -> CloudRoot { + root_for(path, CloudProvider::Icloud) + } + + fn root_for(path: &Path, provider: CloudProvider) -> CloudRoot { CloudRoot { - id: "icloud:test".into(), - provider: CloudProvider::Icloud, + id: format!("{}:test", provider.as_str()), + provider, account_scope: CloudAccountScope::Personal, label: "iCloud".into(), path: path.to_string_lossy().into_owned(), @@ -1487,6 +1497,22 @@ mod tests { .contains(&"cloud-object-must-remain-present".into())); } + #[test] + fn synced_idle_onedrive_item_uses_the_same_fail_closed_plan_contract() { + let temp = tempfile::tempdir().unwrap(); + let plan = build_plan( + &root_for(temp.path(), CloudProvider::Onedrive), + &temp.path().join("file.bin"), + file(), + file_provider_state(), + idle(), + 20, + ); + assert_eq!(plan.provider, CloudProvider::Onedrive); + assert!(plan.eligible_after_human_approval); + assert_eq!(plan.blockers, ["human-local-eviction-approval-required"]); + } + #[test] fn sync_conflict_and_active_use_fail_closed() { let temp = tempfile::tempdir().unwrap(); @@ -1530,46 +1556,11 @@ mod tests { assert!(eligible.eligible_after_human_approval); for (state, blocker) in [ - ( - { - let mut state = file_provider_state(); - state.is_sync_paused = Some(true); - state - }, - "icloud-file-provider-sync-paused-or-unconfirmed", - ), - ( - { - let mut state = file_provider_state(); - state.is_trashed = Some(true); - state - }, - "icloud-file-provider-item-trashed-or-unconfirmed", - ), - ( - { - let mut state = file_provider_state(); - state.allows_eviction = Some(false); - state - }, - "icloud-file-provider-eviction-capability-unconfirmed", - ), - ( - { - let mut state = file_provider_state(); - state.provider_reported_bytes = Some(99); - state - }, - "icloud-file-provider-document-size-mismatch", - ), - ( - { - let mut state = file_provider_state(); - state.item_identifier_fingerprint = None; - state - }, - "icloud-file-provider-item-identity-unconfirmed", - ), + ({ let mut state = file_provider_state(); state.is_sync_paused = Some(true); state }, "icloud-file-provider-sync-paused-or-unconfirmed"), + ({ let mut state = file_provider_state(); state.is_trashed = Some(true); state }, "icloud-file-provider-item-trashed-or-unconfirmed"), + ({ let mut state = file_provider_state(); state.allows_eviction = Some(false); state }, "icloud-file-provider-eviction-capability-unconfirmed"), + ({ let mut state = file_provider_state(); state.provider_reported_bytes = Some(99); state }, "icloud-file-provider-document-size-mismatch"), + ({ let mut state = file_provider_state(); state.item_identifier_fingerprint = None; state }, "icloud-file-provider-item-identity-unconfirmed"), ] { let plan = build_plan( &root(temp.path()), @@ -1616,7 +1607,10 @@ mod tests { b"lsof: WARNING: can't stat() /Users/test/Library/Caches/example/nested\n", target, )); - assert!(!lsof_stderr_is_benign(b"lsof: error: permission denied\n", target)); + assert!(!lsof_stderr_is_benign( + b"lsof: error: permission denied\n", + target + )); } #[cfg(all(unix, not(coverage)))] @@ -1744,6 +1738,7 @@ mod tests { is_ubiquitous: true, allocated_bytes: 512, }, + Vec::new(), ); assert!(result.verification_complete); assert_eq!(result.observed_allocation_reduction_bytes, 3584); @@ -1753,6 +1748,38 @@ mod tests { assert!(valid_hex64(&result.result_id)); } + #[test] + fn provider_restart_failure_is_retained_without_erasing_successful_eviction() { + let temp = tempfile::tempdir().unwrap(); + let plan = plan(temp.path()); + let approval = approve_icloud_local_eviction( + &plan, + &plan.plan_fingerprint, + 21, + "human:test", + "reviewed", + ) + .unwrap(); + let result = build_result( + &plan, + &approval, + 22, + PostEvictionObservation { + path_retained: true, + is_ubiquitous: true, + allocated_bytes: 512, + }, + vec!["provider-client-runtime-not-observed-after-restart".into()], + ); + assert!(result.eviction_request_succeeded); + assert!(result.local_allocation_reduction_verified); + assert!(!result.verification_complete); + assert_eq!( + result.verification_blockers, + vec!["provider-client-runtime-not-observed-after-restart"] + ); + } + #[test] fn missing_cloud_path_or_unchanged_allocation_remains_unverified() { let temp = tempfile::tempdir().unwrap(); @@ -1774,6 +1801,7 @@ mod tests { is_ubiquitous: false, allocated_bytes: 4096, }, + Vec::new(), ); assert!(!result.verification_complete); assert_eq!(result.observed_allocation_reduction_bytes, 0); diff --git a/src-tauri/src/cloud_local_eviction_batch.rs b/src-tauri/src/cloud_local_eviction_batch.rs index bc3e23c01..1d6fd17c8 100644 --- a/src-tauri/src/cloud_local_eviction_batch.rs +++ b/src-tauri/src/cloud_local_eviction_batch.rs @@ -1,4 +1,4 @@ -//! Evidence-bound batch coordination for iCloud local-copy eviction. +//! Evidence-bound batch coordination for iCloud and OneDrive local-copy eviction. //! //! The coordinator deliberately separates three phases: //! 1. plan every input path without opening file content, @@ -61,7 +61,7 @@ pub struct IcloudLocalEvictionBatchUnavailable { pub struct IcloudLocalEvictionBatchPlan { /// Serialized schema version used for fail-closed compatibility checks. pub version: u32, - /// Cloud provider that owns every planned item; this must be iCloud. + /// Cloud provider that owns every planned item; iCloud and OneDrive are supported. pub provider: CloudProvider, /// Account boundary within which every planned item was discovered. pub account_scope: CloudAccountScope, @@ -300,7 +300,7 @@ fn bounded_error_code(error: &str) -> String { fn item_plan_is_safe(plan: &IcloudLocalEvictionPlan) -> bool { plan.version == crate::cloud_local_eviction::ICLOUD_LOCAL_EVICTION_VERSION - && plan.provider == CloudProvider::Icloud + && matches!(plan.provider, CloudProvider::Icloud | CloudProvider::Onedrive) && valid_hex64(&plan.plan_fingerprint) && plan.logical_bytes > 0 && plan.allocated_bytes > 0 @@ -332,7 +332,8 @@ fn item_plan_is_safe(plan: &IcloudLocalEvictionPlan) -> bool { .is_some_and(valid_hex64) } crate::cloud_local_eviction::IcloudStateObservationMethod::FoundationUbiquitousResourceValues => { - plan.icloud_state.is_sync_paused.is_none() + plan.provider == CloudProvider::Icloud + && plan.icloud_state.is_sync_paused.is_none() && plan.icloud_state.is_trashed.is_none() && plan.icloud_state.allows_eviction.is_none() && plan.icloud_state.provider_reported_bytes.is_none() @@ -353,7 +354,7 @@ fn validate_batch_plan( plan: &IcloudLocalEvictionBatchPlan, ) -> Result<(), String> { if plan.version != ICLOUD_LOCAL_EVICTION_BATCH_VERSION - || plan.provider != CloudProvider::Icloud + || !matches!(plan.provider, CloudProvider::Icloud | CloudProvider::Onedrive) || plan.provider != root.provider || plan.account_scope != root.account_scope || plan.cloud_root != root.path @@ -446,7 +447,7 @@ fn build_batch_plan( blockers == ["human-local-eviction-batch-approval-required"]; let mut plan = IcloudLocalEvictionBatchPlan { version: ICLOUD_LOCAL_EVICTION_BATCH_VERSION, - provider: CloudProvider::Icloud, + provider: root.provider, account_scope: root.account_scope, cloud_root: root.path.clone(), observed_at_ms, @@ -479,8 +480,8 @@ fn plan_batch_with( where F: FnMut(&CloudRoot, &Path, u64) -> Result, { - if root.provider != CloudProvider::Icloud { - return Err("icloud-local-eviction-batch-requires-icloud-root".into()); + if !matches!(root.provider, CloudProvider::Icloud | CloudProvider::Onedrive) { + return Err("cloud-local-eviction-batch-provider-unsupported".into()); } if paths.is_empty() || paths.len() > MAX_BATCH_ITEMS { return Err("icloud-local-eviction-batch-input-count-invalid".into()); @@ -496,7 +497,13 @@ where let input_index = u32::try_from(index) .map_err(|_| "icloud-local-eviction-batch-input-index-overflow".to_string())?; match planner(root, path, observed_at_ms) { - Ok(plan) => items.push(IcloudLocalEvictionBatchItem { input_index, plan }), + Ok(plan) if item_plan_is_safe(&plan) => { + items.push(IcloudLocalEvictionBatchItem { input_index, plan }); + } + Ok(_) => unavailable.push(IcloudLocalEvictionBatchUnavailable { + input_index, + error_code: "icloud-local-eviction-batch-item-not-eligible".into(), + }), Err(error) => unavailable.push(IcloudLocalEvictionBatchUnavailable { input_index, error_code: bounded_error_code(&error), @@ -506,8 +513,8 @@ where build_batch_plan(root, paths.len(), items, unavailable, observed_at_ms) } -/// Build a bounded read-only batch plan. Unavailable paths are represented by index and a bounded, -/// path-free error code. No file content is opened and no local allocation is changed. +/// Build a bounded read-only batch plan. Unsafe or unavailable paths are excluded by index with a +/// bounded, path-free error code. No file content is opened and no local allocation is changed. #[cfg(not(coverage))] pub fn plan_icloud_local_eviction_batch( root: &CloudRoot, @@ -976,6 +983,51 @@ mod tests { validate_batch_plan(&root(), &plan).unwrap(); } + #[test] + fn batch_plan_excludes_sync_incomplete_items_without_blocking_safe_items() { + let paths = vec![path(0), path(1)]; + let plan = plan_batch_with(&root(), &paths, 20, |_, path, _| { + if path.ends_with("file-0.bin") { + Ok(safe_plan(0)) + } else { + let mut incomplete = safe_plan(1); + incomplete.icloud_state.is_uploaded = false; + incomplete.eligible_after_human_approval = false; + incomplete.blockers = vec!["provider-sync-incomplete".into()]; + Ok(incomplete) + } + }) + .unwrap(); + + assert_eq!(plan.planned_count, 1); + assert_eq!(plan.unavailable_count, 1); + assert_eq!( + plan.unavailable[0].error_code, + "icloud-local-eviction-batch-item-not-eligible" + ); + assert!(plan.eligible_after_human_approval); + validate_batch_plan(&root(), &plan).unwrap(); + } + + #[test] + fn onedrive_batch_reuses_the_same_native_file_provider_safety_contract() { + let mut onedrive_root = root(); + onedrive_root.id = "onedrive:test".into(); + onedrive_root.provider = CloudProvider::Onedrive; + onedrive_root.label = "OneDrive test".into(); + let plan = plan_batch_with(&onedrive_root, &[path(0)], 20, |_, _, _| { + let mut plan = safe_plan(0); + plan.provider = CloudProvider::Onedrive; + Ok(plan) + }) + .unwrap(); + + assert_eq!(plan.provider, CloudProvider::Onedrive); + assert_eq!(plan.planned_count, 1); + assert!(plan.eligible_after_human_approval); + validate_batch_plan(&onedrive_root, &plan).unwrap(); + } + #[test] fn batch_plan_rejects_duplicate_input_paths_and_tampering() { let duplicate = vec![path(0), path(0)]; @@ -1017,6 +1069,17 @@ mod tests { let mut unsafe_plan = safe; unsafe_plan.icloud_state.item_identifier_fingerprint = None; assert!(!item_plan_is_safe(&unsafe_plan)); + + let mut unsafe_plan = safe_plan(0); + unsafe_plan.provider = CloudProvider::Onedrive; + unsafe_plan.icloud_state.observation_method = + crate::cloud_local_eviction::IcloudStateObservationMethod::FoundationUbiquitousResourceValues; + unsafe_plan.icloud_state.is_sync_paused = None; + unsafe_plan.icloud_state.is_trashed = None; + unsafe_plan.icloud_state.allows_eviction = None; + unsafe_plan.icloud_state.provider_reported_bytes = None; + unsafe_plan.icloud_state.item_identifier_fingerprint = None; + assert!(!item_plan_is_safe(&unsafe_plan)); } #[test] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..295521b04 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,8 +20,9 @@ use crate::dev_artifacts; #[cfg(not(coverage))] use crate::{ brew_cleanup, cloud, cloud_adr, cloud_eviction, cloud_local_eviction, cloud_plan_view, - cloud_review, cloud_transfer, dupes, git_worktree, icloud_sync_health, - organization_lineage, + cloud_review, cloud_transfer, dupes, git_clone_reclaim, git_worktree, + git_worktree_github_evidence, + icloud_sync_health, organization_lineage, podman_reclaim, provider_api_client, provider_api_write, provider_capacity, provider_client_runtime, provider_evidence, provider_global_sync, provider_oauth, provider_recovery, provider_sync, rules, orphan, @@ -482,8 +483,7 @@ fn podman_binary() -> PathBuf { .into_iter() .map(PathBuf::from) .find(|path| { - std::fs::symlink_metadata(path) - .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) + std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) }) .unwrap_or_else(|| PathBuf::from("podman")) } @@ -518,6 +518,46 @@ pub fn execute_podman_dangling_image_prune( ) } +/// Reclaims guest filesystem extents without rewriting a VM image or deleting user data. +#[cfg(not(coverage))] +#[tauri::command(async)] +pub async fn execute_runtime_storage_trim( + runtime: String, + confirmation_phrase: String, + rationale: String, +) -> Result { + let kind = match runtime.as_str() { + "podman-machine" => crate::runtime_storage::RuntimeStorageKind::PodmanMachine, + "colima" => crate::runtime_storage::RuntimeStorageKind::Colima, + _ => return Err("runtime-storage-unknown-runtime".into()), + }; + tauri::async_runtime::spawn_blocking(move || { + crate::runtime_storage::execute_trim(kind, &confirmation_phrase, &rationale) + }) + .await + .map_err(|_| "runtime-storage-trim-task-failed".to_string())? +} + +/// Restarts a runtime that reports running but cannot serve guest commands. +#[cfg(not(coverage))] +#[tauri::command(async)] +pub async fn execute_runtime_storage_recovery( + runtime: String, + confirmation_phrase: String, + rationale: String, +) -> Result { + let kind = match runtime.as_str() { + "podman-machine" => crate::runtime_storage::RuntimeStorageKind::PodmanMachine, + "colima" => crate::runtime_storage::RuntimeStorageKind::Colima, + _ => return Err("runtime-storage-unknown-runtime".into()), + }; + tauri::async_runtime::spawn_blocking(move || { + crate::runtime_storage::execute_recovery(kind, &confirmation_phrase, &rationale) + }) + .await + .map_err(|_| "runtime-storage-recovery-task-failed".to_string())? +} + #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] @@ -836,8 +876,11 @@ pub async fn plan_icloud_local_copy_eviction( app: AppHandle, ) -> Result { let selected = selected_cloud_root(&app, &cloud_root)?; - if selected.provider != cloud::CloudProvider::Icloud { - return Err("icloud-local-eviction-root-required".into()); + if !matches!( + selected.provider, + cloud::CloudProvider::Icloud | cloud::CloudProvider::Onedrive + ) { + return Err("file-provider-local-eviction-root-required".into()); } cloud::validate_cloud_root_readable(&selected)?; let path = PathBuf::from(path); @@ -874,8 +917,11 @@ pub async fn evict_icloud_local_copy( return Err("icloud-local-eviction-double-confirmation-mismatch".into()); } let selected = selected_cloud_root(&app, &cloud_root)?; - if selected.provider != cloud::CloudProvider::Icloud { - return Err("icloud-local-eviction-root-required".into()); + if !matches!( + selected.provider, + cloud::CloudProvider::Icloud | cloud::CloudProvider::Onedrive + ) { + return Err("file-provider-local-eviction-root-required".into()); } cloud::validate_cloud_root_readable(&selected)?; let path = PathBuf::from(path); @@ -884,7 +930,7 @@ pub async fn evict_icloud_local_copy( .path() .app_data_dir() .map_err(|_| "app-data-directory-unavailable".to_string())?; - let record_dir = app_data_dir.join("icloud-local-evictions"); + let record_dir = app_data_dir.join("cloud-local-evictions"); if record_dir.starts_with(Path::new(&selected.path)) || path.starts_with(&record_dir) { return Err("icloud-local-eviction-record-dir-overlaps-cloud-data".into()); } @@ -893,7 +939,7 @@ pub async fn evict_icloud_local_copy( let record_dir = cloud_local_eviction::prepare_immutable_record_directory( &app_data_dir, Path::new(&selected.path), - "icloud-local-evictions", + "cloud-local-evictions", )?; let plan = cloud_local_eviction::plan_icloud_local_eviction( &selected, @@ -929,7 +975,7 @@ pub async fn evict_icloud_local_copy( Err(error) => (None, Some(error)), }; Ok(IcloudLocalCopyEvictionOutput { - action: "evict-icloud-local-copy", + action: "evict-cloud-local-copy", plan, approval, approval_path: approval_path.to_string_lossy().into_owned(), @@ -947,12 +993,25 @@ pub async fn evict_icloud_local_copy( pub async fn plan_stale_git_worktrees( repository_root: String, retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_worktree::audit_git_worktrees( + let options = git_worktree::GitWorktreeAuditOptions::default(); + let evidence = git_worktree_github_evidence::collect( + Path::new(&repository_root), + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + )?; + git_worktree::audit_git_worktrees_with_pull_request_membership( Path::new(&repository_root), &retention_references, - git_worktree::GitWorktreeAuditOptions::default(), + &evidence.closed_heads, + &evidence.stale_open_heads, + &evidence.pull_request_commits, + stale_open_pull_request_cutoff_ms, + options, cloud::system_now_ms(), ) }) @@ -977,6 +1036,8 @@ pub struct StaleGitWorktreeRemovalOutput { pub async fn remove_stale_git_worktrees( repository_root: String, retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, approved_removal_plan_fingerprint: String, confirmation_exact_approval_phrase: String, rationale: String, @@ -990,9 +1051,19 @@ pub async fn remove_stale_git_worktrees( let approved_by = local_human_reviewer(); tauri::async_runtime::spawn_blocking(move || { let options = git_worktree::GitWorktreeAuditOptions::default(); - let report = git_worktree::audit_git_worktrees( + let evidence = git_worktree_github_evidence::collect( + Path::new(&repository_root), + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + )?; + let report = git_worktree::audit_git_worktrees_with_pull_request_membership( Path::new(&repository_root), &retention_references, + &evidence.closed_heads, + &evidence.stale_open_heads, + &evidence.pull_request_commits, + stale_open_pull_request_cutoff_ms, options, cloud::system_now_ms(), )?; @@ -1016,10 +1087,12 @@ pub async fn remove_stale_git_worktrees( &format!("{}.approval.json", approval.approval_id), &approval, )?; - let result = git_worktree::execute_stale_worktree_removal( + let result = git_worktree::execute_stale_worktree_removal_with_github_pull_requests( &report, &approval, &confirmation_exact_approval_phrase, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, options, cloud::system_now_ms(), )?; @@ -1046,6 +1119,106 @@ pub async fn remove_stale_git_worktrees( .map_err(|_| "git-worktree-removal-task-failed".to_string())? } +#[cfg(not(coverage))] +#[tauri::command(async)] +pub async fn plan_stale_git_clone( + repository_root: String, + retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_clone_reclaim::plan_git_clone_reclaim( + Path::new(&repository_root), + &retention_references, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + git_worktree::GitWorktreeAuditOptions::default(), + cloud::system_now_ms(), + ) + }) + .await + .map_err(|_| "git-clone-reclaim-plan-task-failed".to_string())? +} + +#[cfg(not(coverage))] +#[derive(serde::Serialize)] +pub struct StaleGitCloneRemovalOutput { + pub action: &'static str, + pub plan: git_clone_reclaim::GitCloneReclaimPlan, + pub approval: git_clone_reclaim::GitCloneReclaimApproval, + pub approval_path: String, + pub result: git_clone_reclaim::GitCloneReclaimResult, +} + +#[cfg(not(coverage))] +#[tauri::command(async)] +pub async fn remove_stale_git_clone( + repository_root: String, + retention_references: Vec, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + approved_plan_fingerprint: String, + confirmation_exact_approval_phrase: String, + rationale: String, + app: AppHandle, +) -> Result { + use tauri::Manager; + let journal_path = journal_file_path(&app)?; + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|_| "app-data-directory-unavailable".to_string())?; + let approved_by = local_human_reviewer(); + tauri::async_runtime::spawn_blocking(move || { + let options = git_worktree::GitWorktreeAuditOptions::default(); + let plan = git_clone_reclaim::plan_git_clone_reclaim( + Path::new(&repository_root), + &retention_references, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + cloud::system_now_ms(), + )?; + if plan.plan_fingerprint != approved_plan_fingerprint { + return Err("git-clone-reclaim-plan-fingerprint-mismatch".into()); + } + let approval = git_clone_reclaim::approve_git_clone_reclaim( + &plan, + &confirmation_exact_approval_phrase, + cloud::system_now_ms(), + &approved_by, + &rationale, + )?; + let approval_path = + app_data_dir.join(format!("{}.git-clone-approval.json", approval.approval_id)); + crate::private_evidence::write_private_json_create_new( + Path::new(&plan.repository_root), + &approval_path, + &approval, + )?; + let result = git_clone_reclaim::execute_git_clone_reclaim( + &plan, + &approval, + &retention_references, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + &journal_path, + cloud::system_now_ms(), + )?; + Ok(StaleGitCloneRemovalOutput { + action: "remove-stale-git-clone", + plan, + approval, + approval_path: approval_path.to_string_lossy().into_owned(), + result, + }) + }) + .await + .map_err(|_| "git-clone-reclaim-task-failed".to_string())? +} + /// Build a bounded, path-free ontology plan for uninstalled macOS application data. #[cfg(not(coverage))] #[tauri::command(async)] @@ -3588,8 +3761,13 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . "pnpm-cache", "adobe-cache", "edge-cache", + "edge-code-sign-clones", "uv-cache", "trivy-cache", + "appmap-download-cache", + "superset-http-cache", + "superset-code-cache", + "playwright-cache", ] ); let tmp = tempfile::tempdir().unwrap(); @@ -3604,15 +3782,22 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . "pnpm-cache" => bases.home.join("Library/Caches/pnpm"), "adobe-cache" => bases.home.join("Library/Caches/Adobe"), "edge-cache" => bases.home.join("Library/Caches/Microsoft Edge"), + "edge-code-sign-clones" => tmp + .path() + .join("X/com.microsoft.edgemac.code_sign_clone"), "uv-cache" => bases.local_data.join("uv"), "trivy-cache" => bases.home.join("Library/Caches/trivy"), + "appmap-download-cache" => bases.home.join(".appmap/lib"), + "superset-http-cache" => bases.home.join("Library/Application Support/Superset/Partitions/superset/Cache"), + "superset-code-cache" => bases.home.join("Library/Application Support/Superset/Partitions/superset/Code Cache"), + "playwright-cache" => bases.home.join("Library/Caches/ms-playwright"), _ => unreachable!(), }; fs::create_dir_all(&path).unwrap(); fs::write(path.join("fixture.bin"), b"regenerable").unwrap(); } let results = clean_regenerable_caches_inner(&bases, &tmp.path().join("journal.jsonl"), 7); - assert_eq!(results.len(), 6); + assert_eq!(results.len(), 11); assert!(results.iter().all(|result| result.ok)); } diff --git a/src-tauri/src/container_orphan_commands.rs b/src-tauri/src/container_orphan_commands.rs new file mode 100644 index 000000000..f06aaf5a8 --- /dev/null +++ b/src-tauri/src/container_orphan_commands.rs @@ -0,0 +1,790 @@ +use crate::{container_orphan_public, container_orphan_reclaim, podman_reclaim}; +use sha2::{Digest, Sha256}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use tauri::Manager; + +fn ensure_container_receipt_dir(dir: &Path) -> Result<(), String> { + if !dir.exists() { + std::fs::create_dir_all(dir) + .map_err(|_| "orphan-receipt-directory-create-failed".to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "orphan-receipt-directory-permission-failed".to_string())?; + } + } + Ok(()) +} + +fn container_receipt_dir(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|_| "orphan-receipt-directory-unavailable".to_string())? + .join("container-orphan-receipts"); + ensure_container_receipt_dir(&dir)?; + Ok(dir) +} + +const MAX_DOCKER_CONFIG_BYTES: usize = 64 * 1024; +const MAX_DOCKER_CONTEXT_BYTES: usize = 128; +const MAX_DOCKER_HOST_BYTES: usize = 2 * 1024; +const DOCKER_AUTHORITY_APPROVAL_DOMAIN: &[u8] = b"disksage.container-orphan-docker-authority.v1"; +const IMMUTABLE_CONTEXT_REQUIRED: &str = "docker-context-authority-not-immutable"; + +fn docker_binary() -> PathBuf { + [ + "/opt/homebrew/bin/docker", + "/usr/local/bin/docker", + "/usr/bin/docker", + ] + .into_iter() + .map(PathBuf::from) + .find(|path| std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())) + .unwrap_or_else(|| PathBuf::from("docker")) +} + +fn podman_binary() -> PathBuf { + [ + "/opt/homebrew/bin/podman", + "/usr/local/bin/podman", + "/usr/bin/podman", + ] + .into_iter() + .map(PathBuf::from) + .find(|path| std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())) + .unwrap_or_else(|| PathBuf::from("podman")) +} + +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 valid_rationale(value: &str) -> bool { + let trimmed = value.trim(); + value == trimmed + && !trimmed.is_empty() + && trimmed.chars().count() <= 1_000 + && !trimmed.chars().any(char::is_control) +} + +fn parse_runtime_kind( + value: &str, +) -> Result { + match value { + "docker-native" => Ok(container_orphan_reclaim::ContainerRuntimeKind::DockerNative), + "docker-colima-context" => { + Ok(container_orphan_reclaim::ContainerRuntimeKind::DockerColimaContext) + } + "podman-machine" => Ok(container_orphan_reclaim::ContainerRuntimeKind::PodmanMachine), + _ => Err("unknown-runtime-kind".into()), + } +} + +fn parse_category(value: &str) -> Result { + match value { + "container" => Ok(container_orphan_reclaim::OrphanCategory::Container), + "image" => Ok(container_orphan_reclaim::OrphanCategory::Image), + "volume" => Ok(container_orphan_reclaim::OrphanCategory::Volume), + "network" => Ok(container_orphan_reclaim::OrphanCategory::Network), + "build_cache" => Ok(container_orphan_reclaim::OrphanCategory::BuildCache), + _ => Err("unknown-orphan-category".into()), + } +} + +fn target_for_kind( + kind: container_orphan_reclaim::ContainerRuntimeKind, +) -> Result { + use container_orphan_reclaim::{ContainerRuntimeKind, ContainerRuntimeTarget}; + match kind { + ContainerRuntimeKind::DockerNative => { + ContainerRuntimeTarget::new(kind, docker_binary(), None) + } + ContainerRuntimeKind::DockerColimaContext => { + ContainerRuntimeTarget::new(kind, docker_binary(), Some("colima".to_string())) + } + ContainerRuntimeKind::PodmanMachine => ContainerRuntimeTarget::new( + kind, + podman_binary(), + Some(podman_reclaim::DEFAULT_PODMAN_MACHINE.to_string()), + ), + } +} + +/// Only an explicit Docker host is an immutable-enough mutation authority at this layer. A named +/// Docker context can be replaced between `context inspect` and a later `--context` mutation; the +/// CLI does not offer a conditional delete tied to the inspected context definition. Contexts are +/// therefore read-only until DiskSage can snapshot the full context/TLS material and execute every +/// command against that private snapshot. +fn pin_docker_authority( + _binary_path: &std::path::Path, + authority: &DockerAmbientAuthority, +) -> Result { + match authority { + DockerAmbientAuthority::Host(host) => Ok(DockerAmbientAuthority::Host(host.clone())), + DockerAmbientAuthority::Context(_) | DockerAmbientAuthority::Default => { + Err(IMMUTABLE_CONTEXT_REQUIRED.into()) + } + } +} + +fn pinned_docker_target( + authority: &DockerAmbientAuthority, +) -> Result { + match authority { + DockerAmbientAuthority::Host(host) => { + container_orphan_reclaim::ContainerRuntimeTarget::docker_native_host( + docker_binary(), + host.clone(), + ) + } + DockerAmbientAuthority::Context(_) | DockerAmbientAuthority::Default => { + Err("docker-authority-not-pinned".into()) + } + } +} + +fn validate_requested_scope( + target: &container_orphan_reclaim::ContainerRuntimeTarget, + requested_scope: &Option, +) -> Result<(), String> { + if &target.scope_name != requested_scope { + return Err("orphan-prune-runtime-scope-mismatch".into()); + } + Ok(()) +} + +fn bounded_docker_context(value: &str) -> Option { + (!value.is_empty() + && value.len() <= MAX_DOCKER_CONTEXT_BYTES + && !value.chars().any(char::is_control)) + .then(|| value.to_string()) +} + +fn bounded_docker_host(value: &str) -> Option { + (!value.is_empty() + && value.len() <= MAX_DOCKER_HOST_BYTES + && !value.chars().any(char::is_control)) + .then(|| value.to_string()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DockerContextEnvironment { + /// The Docker CLI treats an absent or empty override as no override and consults config. + AbsentOrEmpty, + /// A bounded non-empty override takes precedence over Docker's config file and DOCKER_HOST. + Context(String), + /// A present non-empty override that DiskSage cannot represent safely must fail closed. + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DockerHostEnvironment { + AbsentOrEmpty, + Host(String), + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DockerAmbientAuthority { + Default, + Context(String), + Host(String), +} + +fn docker_context_environment(value: Option) -> DockerContextEnvironment { + match value { + None => DockerContextEnvironment::AbsentOrEmpty, + Some(value) if value.is_empty() => DockerContextEnvironment::AbsentOrEmpty, + Some(value) => match value.into_string() { + Ok(context) => bounded_docker_context(&context) + .map(DockerContextEnvironment::Context) + .unwrap_or(DockerContextEnvironment::Invalid), + Err(_) => DockerContextEnvironment::Invalid, + }, + } +} + +fn docker_host_environment(value: Option) -> DockerHostEnvironment { + match value { + None => DockerHostEnvironment::AbsentOrEmpty, + Some(value) if value.is_empty() => DockerHostEnvironment::AbsentOrEmpty, + Some(value) => match value.into_string() { + Ok(host) => bounded_docker_host(&host) + .map(DockerHostEnvironment::Host) + .unwrap_or(DockerHostEnvironment::Invalid), + Err(_) => DockerHostEnvironment::Invalid, + }, + } +} + +fn parse_docker_current_context(bytes: &[u8]) -> Option { + if bytes.len() > MAX_DOCKER_CONFIG_BYTES { + return None; + } + let document: serde_json::Value = serde_json::from_slice(bytes).ok()?; + bounded_docker_context(document.get("currentContext")?.as_str()?) +} + +fn docker_config_path() -> Option { + if let Some(directory) = std::env::var_os("DOCKER_CONFIG").filter(|value| !value.is_empty()) { + return Some(PathBuf::from(directory).join("config.json")); + } + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("USERPROFILE").filter(|value| !value.is_empty())) + .map(PathBuf::from) + .map(|home| home.join(".docker").join("config.json")) +} + +fn docker_config_current_context() -> Option { + let path = docker_config_path()?; + let metadata = std::fs::symlink_metadata(&path).ok()?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > MAX_DOCKER_CONFIG_BYTES as u64 + { + return None; + } + let bytes = std::fs::read(path).ok()?; + parse_docker_current_context(&bytes) +} + +fn resolve_docker_ambient_authority( + context_environment: DockerContextEnvironment, + host_environment: DockerHostEnvironment, + configured_context: Option, +) -> Result { + match context_environment { + // Docker documents DOCKER_CONTEXT as overriding DOCKER_HOST and the configured default. + DockerContextEnvironment::Context(context) => Ok(DockerAmbientAuthority::Context(context)), + DockerContextEnvironment::Invalid => Err("docker-context-invalid".to_string()), + DockerContextEnvironment::AbsentOrEmpty => match host_environment { + // With no explicit context, DOCKER_HOST overrides config.json.currentContext. + DockerHostEnvironment::Host(host) => Ok(DockerAmbientAuthority::Host(host)), + DockerHostEnvironment::Invalid => Err("docker-host-invalid".to_string()), + DockerHostEnvironment::AbsentOrEmpty => Ok(configured_context + .map(DockerAmbientAuthority::Context) + .unwrap_or(DockerAmbientAuthority::Default)), + }, + } +} + +fn docker_ambient_authority() -> Result { + let context_environment = docker_context_environment(std::env::var_os("DOCKER_CONTEXT")); + let host_environment = docker_host_environment(std::env::var_os("DOCKER_HOST")); + let configured_context = match context_environment { + DockerContextEnvironment::AbsentOrEmpty => match host_environment { + DockerHostEnvironment::AbsentOrEmpty => docker_config_current_context(), + DockerHostEnvironment::Host(_) | DockerHostEnvironment::Invalid => None, + }, + DockerContextEnvironment::Context(_) | DockerContextEnvironment::Invalid => None, + }; + resolve_docker_ambient_authority(context_environment, host_environment, configured_context) +} + +fn runtime_kinds_for_docker_authority( + authority: &Result, +) -> Vec { + use container_orphan_reclaim::ContainerRuntimeKind::{ + DockerColimaContext, DockerNative, PodmanMachine, + }; + + let mut kinds = Vec::with_capacity(3); + match authority { + Ok(DockerAmbientAuthority::Host(_)) | Ok(DockerAmbientAuthority::Default) => { + kinds.push(DockerNative); + } + Ok(DockerAmbientAuthority::Context(context)) if context != "colima" => { + kinds.push(DockerNative); + } + Ok(DockerAmbientAuthority::Context(_)) | Err(_) => {} + } + // An ambient default or non-Colima context is useful read-only evidence even when it cannot + // safely authorize deletion. Explicit Colima remains a separate read-only target, and Podman + // remains independent of Docker ambient authority. + kinds.push(DockerColimaContext); + kinds.push(PodmanMachine); + kinds +} + +fn docker_authority_binding(authority: &DockerAmbientAuthority) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut hasher = Sha256::new(); + hasher.update(DOCKER_AUTHORITY_APPROVAL_DOMAIN); + hasher.update([0]); + match authority { + DockerAmbientAuthority::Default => hasher.update(b"default"), + DockerAmbientAuthority::Context(context) => { + hasher.update(b"context"); + hasher.update([0]); + hasher.update(context.as_bytes()); + } + DockerAmbientAuthority::Host(host) => { + hasher.update(b"host"); + hasher.update([0]); + hasher.update(host.as_bytes()); + } + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +fn bind_docker_authority_approval(base_phrase: &str, authority: &DockerAmbientAuthority) -> String { + format!( + "{base_phrase} docker-authority {}", + docker_authority_binding(authority) + ) +} + +fn unbind_docker_authority_approval( + bound_phrase: &str, + authority: &DockerAmbientAuthority, +) -> Result { + let suffix = format!(" docker-authority {}", docker_authority_binding(authority)); + bound_phrase + .strip_suffix(&suffix) + .map(str::to_string) + .ok_or_else(|| "orphan-prune-docker-authority-mismatch".to_string()) +} + +fn bind_docker_authority_plan( + mut plan: container_orphan_reclaim::ContainerOrphanPlan, + authority: &DockerAmbientAuthority, +) -> container_orphan_reclaim::ContainerOrphanPlan { + for category in &mut plan.categories { + if let Some(base_phrase) = category.approval_phrase.take() { + category.approval_phrase = + Some(bind_docker_authority_approval(&base_phrase, authority)); + } + } + plan +} + +fn suppress_context_mutation_authority( + mut plan: container_orphan_reclaim::ContainerOrphanPlan, +) -> container_orphan_reclaim::ContainerOrphanPlan { + for category in &mut plan.categories { + category.approval_phrase = None; + category.prune_command = None; + } + plan +} + +/// Probes every supported container runtime target read-only and audits all orphan categories. +/// An explicit DOCKER_HOST may acquire mutation authority because every later command is pinned to +/// that exact endpoint. Named/default Docker contexts remain visible as read-only native audits; +/// the explicit Colima context remains read-only because mutable context names cannot safely +/// authorize a later delete without a private immutable context/TLS snapshot. +#[tauri::command(async)] +pub fn inspect_container_orphans( + app: tauri::AppHandle, +) -> Vec { + let receipt_dir = container_receipt_dir(&app).ok(); + let docker_authority = docker_ambient_authority(); + let pinned_docker_authority = docker_authority + .as_ref() + .map_err(Clone::clone) + .and_then(|authority| pin_docker_authority(&docker_binary(), authority)); + runtime_kinds_for_docker_authority(&docker_authority) + .into_iter() + .filter_map(|kind| { + let target = if kind == container_orphan_reclaim::ContainerRuntimeKind::DockerNative { + match pinned_docker_authority.as_ref() { + Ok(authority) => pinned_docker_target(authority).ok()?, + Err(_) => target_for_kind(kind).ok()?, + } + } else { + target_for_kind(kind).ok()? + }; + let plan = container_orphan_public::sanitize_plan(receipt_dir.as_ref().map_or_else( + || container_orphan_reclaim::probe_container_orphans(&target), + |dir| { + container_orphan_reclaim::probe_container_orphans_with_receipt_dir(&target, dir) + }, + )); + match kind { + container_orphan_reclaim::ContainerRuntimeKind::DockerNative => { + match pinned_docker_authority.as_ref() { + Ok(authority) => Some(bind_docker_authority_plan(plan, authority)), + Err(_) => Some(suppress_context_mutation_authority(plan)), + } + } + container_orphan_reclaim::ContainerRuntimeKind::DockerColimaContext => { + Some(suppress_context_mutation_authority(plan)) + } + container_orphan_reclaim::ContainerRuntimeKind::PodmanMachine => Some(plan), + } + }) + .collect() +} + +/// Re-audits one runtime/category immediately before exact identity-bound deletion. Docker-native +/// mutation is permitted only for an explicit, bounded DOCKER_HOST that can be reused verbatim for +/// every audit and delete command. Named/default Docker contexts and the Colima named context fail +/// closed because re-resolving a mutable context after approval can redirect deletion to another +/// daemon. +#[tauri::command(async)] +pub fn execute_container_orphan_prune( + app: tauri::AppHandle, + runtime_kind: String, + scope_name: Option, + category: String, + confirmation_phrase: String, + rationale: String, +) -> Result { + if !valid_rationale(&rationale) { + return Err("orphan-prune-rationale-invalid".into()); + } + let kind = parse_runtime_kind(&runtime_kind)?; + if kind == container_orphan_reclaim::ContainerRuntimeKind::DockerColimaContext { + return Err(format!("orphan-prune-{IMMUTABLE_CONTEXT_REQUIRED}")); + } + let category = parse_category(&category)?; + container_orphan_public::ensure_mutation_category_authority(category)?; + let (target, docker_authority) = + if kind == container_orphan_reclaim::ContainerRuntimeKind::DockerNative { + let ambient = + docker_ambient_authority().map_err(|error| format!("orphan-prune-{error}"))?; + let pinned = pin_docker_authority(&docker_binary(), &ambient) + .map_err(|error| format!("orphan-prune-{error}"))?; + (pinned_docker_target(&pinned)?, Some(pinned)) + } else { + (target_for_kind(kind)?, None) + }; + validate_requested_scope(&target, &scope_name)?; + let engine_confirmation = + if kind == container_orphan_reclaim::ContainerRuntimeKind::DockerNative { + unbind_docker_authority_approval( + &confirmation_phrase, + docker_authority + .as_ref() + .ok_or("docker-authority-not-pinned")?, + )? + } else { + confirmation_phrase + }; + let receipt_dir = container_receipt_dir(&app)?; + container_orphan_reclaim::execute_container_orphan_prune( + &target, + category, + &engine_confirmation, + &rationale, + now_ms(), + &receipt_dir, + ) + .map(container_orphan_public::sanitize_execution) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn receipt_directory_creation_handles_missing_app_data_parent() { + let temp = tempfile::tempdir().unwrap(); + let receipt_dir = temp + .path() + .join("not-created-yet") + .join("app-data") + .join("container-orphan-receipts"); + + ensure_container_receipt_dir(&receipt_dir).unwrap(); + + assert!(receipt_dir.is_dir()); + } + + #[test] + fn command_inputs_fail_closed_without_reflecting_untrusted_tokens() { + assert_eq!( + parse_runtime_kind("secret-runtime").unwrap_err(), + "unknown-runtime-kind" + ); + assert_eq!( + parse_category("secret-category").unwrap_err(), + "unknown-orphan-category" + ); + assert!(!valid_rationale("")); + assert!(!valid_rationale(" leading")); + assert!(!valid_rationale("bad\nline")); + assert!(valid_rationale("Reviewed the fresh candidate-bound plan.")); + } + + #[test] + fn execution_targets_use_fixed_server_side_scopes() { + use container_orphan_reclaim::ContainerRuntimeKind; + + let docker = target_for_kind(ContainerRuntimeKind::DockerNative).unwrap(); + let colima = target_for_kind(ContainerRuntimeKind::DockerColimaContext).unwrap(); + let podman = target_for_kind(ContainerRuntimeKind::PodmanMachine).unwrap(); + + assert_eq!(docker.scope_name, None); + assert_eq!(colima.scope_name.as_deref(), Some("colima")); + assert_eq!( + podman.scope_name.as_deref(), + Some(podman_reclaim::DEFAULT_PODMAN_MACHINE), + ); + assert!(validate_requested_scope(&docker, &None).is_ok()); + assert!(validate_requested_scope(&colima, &Some("colima".into())).is_ok()); + assert!(validate_requested_scope( + &podman, + &Some(podman_reclaim::DEFAULT_PODMAN_MACHINE.into()) + ) + .is_ok()); + assert_eq!( + validate_requested_scope(&podman, &Some("attacker-controlled-machine".into())) + .unwrap_err(), + "orphan-prune-runtime-scope-mismatch" + ); + } + + #[test] + fn docker_host_overrides_configured_colima_and_keeps_ambient_target() { + use container_orphan_reclaim::ContainerRuntimeKind::{ + DockerColimaContext, DockerNative, PodmanMachine, + }; + + let authority = resolve_docker_ambient_authority( + DockerContextEnvironment::AbsentOrEmpty, + DockerHostEnvironment::Host("unix:///tmp/customer-docker.sock".to_string()), + Some("colima".to_string()), + ); + assert_eq!( + authority, + Ok(DockerAmbientAuthority::Host( + "unix:///tmp/customer-docker.sock".to_string() + )) + ); + assert_eq!( + runtime_kinds_for_docker_authority(&authority), + vec![DockerNative, DockerColimaContext, PodmanMachine] + ); + } + + #[test] + fn default_and_non_colima_contexts_keep_native_audit_visible_read_only() { + use container_orphan_reclaim::ContainerRuntimeKind::{ + DockerColimaContext, DockerNative, PodmanMachine, + }; + + for authority in [ + Ok(DockerAmbientAuthority::Default), + Ok(DockerAmbientAuthority::Context("desktop-linux".to_string())), + ] { + assert_eq!( + runtime_kinds_for_docker_authority(&authority), + vec![DockerNative, DockerColimaContext, PodmanMachine] + ); + assert_eq!( + pin_docker_authority(&docker_binary(), authority.as_ref().unwrap()).unwrap_err(), + IMMUTABLE_CONTEXT_REQUIRED + ); + } + } + + #[test] + fn named_contexts_are_read_only_and_do_not_duplicate_colima_target() { + use container_orphan_reclaim::ContainerRuntimeKind::{DockerColimaContext, PodmanMachine}; + + let authority = resolve_docker_ambient_authority( + DockerContextEnvironment::Context("colima".to_string()), + DockerHostEnvironment::Host("unix:///tmp/ignored-by-context.sock".to_string()), + Some("desktop-linux".to_string()), + ); + assert_eq!( + authority, + Ok(DockerAmbientAuthority::Context("colima".to_string())) + ); + assert_eq!( + runtime_kinds_for_docker_authority(&authority), + vec![DockerColimaContext, PodmanMachine] + ); + assert_eq!( + pin_docker_authority(&docker_binary(), authority.as_ref().unwrap()).unwrap_err(), + IMMUTABLE_CONTEXT_REQUIRED + ); + assert_eq!( + pin_docker_authority(&docker_binary(), &DockerAmbientAuthority::Default).unwrap_err(), + IMMUTABLE_CONTEXT_REQUIRED + ); + } + + #[test] + fn invalid_explicit_docker_authority_must_not_fall_through_to_native_target() { + use container_orphan_reclaim::ContainerRuntimeKind::{ + DockerColimaContext, DockerNative, PodmanMachine, + }; + + let invalid_context = resolve_docker_ambient_authority( + DockerContextEnvironment::Invalid, + DockerHostEnvironment::AbsentOrEmpty, + Some("desktop-linux".to_string()), + ); + let invalid_host = resolve_docker_ambient_authority( + DockerContextEnvironment::AbsentOrEmpty, + DockerHostEnvironment::Invalid, + Some("desktop-linux".to_string()), + ); + + assert!(!runtime_kinds_for_docker_authority(&invalid_context).contains(&DockerNative)); + assert!(!runtime_kinds_for_docker_authority(&invalid_host).contains(&DockerNative)); + assert_eq!( + runtime_kinds_for_docker_authority(&invalid_host), + vec![DockerColimaContext, PodmanMachine] + ); + } + + #[test] + fn docker_native_approval_is_authority_bound_without_disclosing_endpoint() { + let base = "DiskSage image orphan prune 승인 abcdef"; + let host_a = DockerAmbientAuthority::Host("unix:///tmp/customer-a.sock".to_string()); + let host_b = DockerAmbientAuthority::Host("unix:///tmp/customer-b.sock".to_string()); + let context = DockerAmbientAuthority::Context("desktop-linux".to_string()); + let default = DockerAmbientAuthority::Default; + let bound = bind_docker_authority_approval(base, &host_a); + + assert_ne!(bound, bind_docker_authority_approval(base, &host_b)); + assert_ne!(bound, bind_docker_authority_approval(base, &context)); + assert_ne!(bound, bind_docker_authority_approval(base, &default)); + assert!(!bound.contains("customer-a.sock")); + assert_eq!( + unbind_docker_authority_approval(&bound, &host_a).unwrap(), + base + ); + assert_eq!( + unbind_docker_authority_approval(&bound, &host_b).unwrap_err(), + "orphan-prune-docker-authority-mismatch" + ); + let target = + pinned_docker_target(&pin_docker_authority(&docker_binary(), &host_a).unwrap()) + .unwrap(); + let prefix = target.command_prefix().unwrap(); + assert_eq!( + &prefix[prefix.len() - 2..], + ["--host", "unix:///tmp/customer-a.sock"] + ); + } + + #[cfg(unix)] + #[test] + fn named_context_never_produces_a_mutable_context_target() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let docker = temp.path().join("docker"); + std::fs::write( + &docker, + r#"#!/bin/sh +printf '%s\n' 'a mutable named context must never be consulted for mutation' >&2 +exit 41 +"#, + ) + .unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let authority = DockerAmbientAuthority::Context("customer-local".to_string()); + assert_eq!( + pin_docker_authority(&docker, &authority).unwrap_err(), + IMMUTABLE_CONTEXT_REQUIRED + ); + } + + #[test] + fn docker_context_environment_precedence_is_fail_closed_and_matches_empty_override_fallback() { + assert_eq!( + docker_context_environment(None), + DockerContextEnvironment::AbsentOrEmpty + ); + assert_eq!( + docker_context_environment(Some(OsString::new())), + DockerContextEnvironment::AbsentOrEmpty + ); + assert_eq!( + docker_context_environment(Some(OsString::from("colima"))), + DockerContextEnvironment::Context("colima".to_string()) + ); + assert_eq!( + docker_context_environment(Some(OsString::from("bad\ncontext"))), + DockerContextEnvironment::Invalid + ); + assert_eq!( + docker_context_environment(Some(OsString::from( + "x".repeat(MAX_DOCKER_CONTEXT_BYTES + 1) + ))), + DockerContextEnvironment::Invalid + ); + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + assert_eq!( + docker_context_environment(Some(OsString::from_vec(vec![0xff]))), + DockerContextEnvironment::Invalid + ); + } + } + + #[test] + fn docker_host_environment_is_bounded_and_fail_closed() { + assert_eq!( + docker_host_environment(None), + DockerHostEnvironment::AbsentOrEmpty + ); + assert_eq!( + docker_host_environment(Some(OsString::new())), + DockerHostEnvironment::AbsentOrEmpty + ); + assert_eq!( + docker_host_environment(Some(OsString::from("unix:///tmp/docker.sock"))), + DockerHostEnvironment::Host("unix:///tmp/docker.sock".to_string()) + ); + assert_eq!( + docker_host_environment(Some(OsString::from("bad\nhost"))), + DockerHostEnvironment::Invalid + ); + assert_eq!( + docker_host_environment(Some(OsString::from("x".repeat(MAX_DOCKER_HOST_BYTES + 1)))), + DockerHostEnvironment::Invalid + ); + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + assert_eq!( + docker_host_environment(Some(OsString::from_vec(vec![0xff]))), + DockerHostEnvironment::Invalid + ); + } + } + + #[test] + fn docker_current_context_parser_is_bounded_and_exact() { + assert_eq!( + parse_docker_current_context(br#"{"currentContext":"colima"}"#).as_deref(), + Some("colima") + ); + assert_eq!( + parse_docker_current_context(br#"{"currentContext":"desktop-linux"}"#).as_deref(), + Some("desktop-linux") + ); + assert_eq!(parse_docker_current_context(b"not-json"), None); + assert_eq!( + parse_docker_current_context(br#"{"currentContext":12}"#), + None + ); + assert_eq!( + parse_docker_current_context(&vec![b'x'; MAX_DOCKER_CONFIG_BYTES + 1]), + None + ); + } +} diff --git a/src-tauri/src/container_orphan_public.rs b/src-tauri/src/container_orphan_public.rs new file mode 100644 index 000000000..e87487b9e --- /dev/null +++ b/src-tauri/src/container_orphan_public.rs @@ -0,0 +1,319 @@ +use crate::container_orphan_reclaim::{ + ContainerOrphanPlan, ContainerOrphanPruneExecution, OrphanCategory, +}; + +const FALLBACK_ISSUE: &str = "container-runtime-evidence-unavailable"; +const INDETERMINATE_PRUNE_OUTCOME: &str = "container-orphan-prune-outcome-indeterminate"; +const MUTABLE_VOLUME_IDENTITY: &str = "container-volume-identity-not-immutable"; + +fn stable_issue(raw: &str) -> String { + let token = raw.split(':').next().unwrap_or_default(); + if !token.is_empty() + && token.len() <= 128 + && token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + token.to_string() + } else { + FALLBACK_ISSUE.to_string() + } +} + +fn public_command_shape(category: OrphanCategory, has_candidates: bool) -> Vec { + if category == OrphanCategory::BuildCache { + let mut command = vec!["buildx".into(), "prune".into(), "--all".into()]; + if has_candidates { + command.extend(["--filter".into(), "id~=^()$".into()]); + } + command.push("--force".into()); + return command; + } + let mut command = vec![category.as_str().to_string(), "rm".to_string()]; + if has_candidates { + command.push("".to_string()); + } + command +} + +/// Rejects public mutation categories whose runtime deletion target cannot be bound to immutable +/// object identity. Docker/Podman volume deletion is name-addressed; a volume can be deleted and +/// recreated under the same name after the final audit but before `volume rm` executes. Until the +/// runtime provides conditional deletion bound to the audited object identity, volume evidence is +/// intentionally read-only. +pub fn ensure_mutation_category_authority(category: OrphanCategory) -> Result<(), String> { + if category == OrphanCategory::Volume { + Err(MUTABLE_VOLUME_IDENTITY.to_string()) + } else { + Ok(()) + } +} + +/// Removes runtime stderr, paths, socket details, local machine names, and record fragments from +/// the machine-readable public plan while retaining stable fail-closed issue categories. A volume +/// plan remains observable but never publishes destructive authority because the runtime delete is +/// bound only to a reusable volume name rather than immutable object identity. +pub fn sanitize_plan(mut plan: ContainerOrphanPlan) -> ContainerOrphanPlan { + plan.runtime.detail_issue = plan.runtime.detail_issue.as_deref().map(stable_issue); + plan.runtime.display_name = plan.runtime.kind.as_str().to_string(); + for category in &mut plan.categories { + category.issue = category.issue.as_deref().map(stable_issue); + if ensure_mutation_category_authority(category.category).is_err() { + category.prune_command = None; + category.approval_phrase = None; + continue; + } + let has_candidates = category + .evidence + .as_ref() + .is_some_and(|evidence| evidence.candidate_records > 0); + let public_command = public_command_shape(category.category, has_candidates); + if public_command.is_empty() { + category.prune_command = None; + category.approval_phrase = None; + } else if category.prune_command.is_some() { + category.prune_command = Some(public_command); + } + } + let mut issues = plan + .categories + .iter() + .filter_map(|category| { + category + .issue + .as_ref() + .map(|issue| format!("{}:{issue}", category.category.as_str())) + }) + .collect::>(); + if let Some(issue) = plan.runtime.detail_issue.clone() { + issues.push(issue); + } + plan.issues = issues; + plan +} + +/// Keeps the mutation receipt useful for authorization/accounting without returning arbitrary +/// runtime stdout/stderr, local executable paths, runtime scope names, or capacity observations +/// whose filesystem has not been proven to contain the runtime store. A non-zero multi-target +/// remove command cannot prove that no target was removed, so its public receipt keeps a stable +/// indeterminate-outcome code instead of presenting the sanitized runtime failure as a clean +/// no-mutation result. Callers must refresh runtime evidence before making a new decision. +pub fn sanitize_execution( + mut execution: ContainerOrphanPruneExecution, +) -> ContainerOrphanPruneExecution { + execution.runtime_display_name = "container-runtime".to_string(); + execution.command = public_command_shape(execution.category, true); + execution.stdout.clear(); + execution.stderr.clear(); + // The reclaim engine currently has no authoritative runtime-store filesystem path for native + // Docker, Colima, or Podman. Its internal current-working-directory snapshot therefore cannot + // support a customer-facing capacity attribution. Fail closed until store-bound evidence + // exists rather than serializing an unrelated host-volume delta as reclaim evidence. + execution.before_available_bytes = None; + execution.after_available_bytes = None; + execution.observed_available_gain_bytes = None; + if execution.status_code != 0 { + execution.stderr = INDETERMINATE_PRUNE_OUTCOME.to_string(); + } + execution +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::container_orphan_reclaim::{ + probe_container_orphans, probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, + ContainerRuntimeTarget, RuntimeHealthEvidence, + }; + use std::path::PathBuf; + + #[test] + fn public_plan_keeps_only_stable_runtime_issue_codes() { + let secret = "/Users/customer/private.sock bearer-token"; + let plan = ContainerOrphanPlan { + schema_kind: "disksage.container-orphan-plan", + schema_version: 1, + platform: "test", + evidence_complete: false, + elapsed_ms: 1, + runtime: RuntimeHealthEvidence { + kind: ContainerRuntimeKind::DockerNative, + display_name: "docker (docker-native)".into(), + healthy: false, + detail_issue: Some(format!("runtime-info-failed:{secret}")), + }, + categories: Vec::new(), + issues: vec![format!("runtime-info-failed:{secret}")], + receipt_directory_sha256: None, + }; + let sanitized = sanitize_plan(plan); + assert_eq!( + sanitized.runtime.detail_issue.as_deref(), + Some("runtime-info-failed") + ); + assert_eq!(sanitized.issues, vec!["runtime-info-failed"]); + let json = serde_json::to_string(&sanitized).unwrap(); + assert!(!json.contains(secret)); + assert!(!json.contains("bearer-token")); + } + + #[test] + fn public_plan_never_returns_runtime_scope_name() { + let secret_scope = "customer-colima-secret"; + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerColimaContext, + PathBuf::from("__disksage_missing_runtime__"), + Some(secret_scope.into()), + ) + .unwrap(); + + let sanitized = sanitize_plan(probe_container_orphans(&target)); + let json = serde_json::to_string(&sanitized).unwrap(); + + assert_eq!(sanitized.runtime.display_name, "docker-colima-context"); + assert!(!json.contains(secret_scope)); + } + + #[test] + fn public_execution_never_returns_runtime_output_local_identity_or_unbound_capacity() { + let secret_binary = "/Users/customer/private/bin/docker"; + let secret_scope = "customer-colima-secret"; + let execution = ContainerOrphanPruneExecution { + schema_version: 1, + runtime_display_name: format!("docker {secret_scope}"), + category: OrphanCategory::Container, + candidate_set_sha256: "a".repeat(64), + command: vec![ + secret_binary.into(), + "--context".into(), + secret_scope.into(), + "container".into(), + "rm".into(), + "".into(), + ], + status_code: 1, + stdout: "container-secret-id".into(), + stderr: "/Users/customer/private.sock".into(), + output_truncated: false, + executed: false, + executed_at_ms: 1, + before_available_bytes: Some(1_000), + after_available_bytes: Some(1_200), + observed_available_gain_bytes: Some(200), + rationale: "Reviewed exact evidence.".into(), + receipt_sha256: None, + receipt_recorded: false, + receipt_record_error: Some("orphan-receipt-create-failed".into()), + }; + let sanitized = sanitize_execution(execution); + let json = serde_json::to_string(&sanitized).unwrap(); + assert_eq!(sanitized.runtime_display_name, "container-runtime"); + assert_eq!( + sanitized.command, + vec!["container", "rm", ""] + ); + assert!(sanitized.stdout.is_empty()); + assert_eq!(sanitized.stderr, INDETERMINATE_PRUNE_OUTCOME); + assert_eq!(sanitized.before_available_bytes, None); + assert_eq!(sanitized.after_available_bytes, None); + assert_eq!(sanitized.observed_available_gain_bytes, None); + assert!(!json.contains(secret_binary)); + assert!(!json.contains(secret_scope)); + } + + #[test] + fn build_cache_public_command_exposes_only_exact_candidate_shape() { + assert_eq!( + public_command_shape(OrphanCategory::BuildCache, true), + vec![ + "buildx", + "prune", + "--all", + "--filter", + "id~=^()$", + "--force" + ] + ); + } + + #[test] + fn public_mutation_policy_rejects_name_bound_volumes() { + assert_eq!( + ensure_mutation_category_authority(OrphanCategory::Volume).unwrap_err(), + MUTABLE_VOLUME_IDENTITY + ); + for category in [ + OrphanCategory::Container, + OrphanCategory::Image, + OrphanCategory::Network, + OrphanCategory::BuildCache, + ] { + assert!(ensure_mutation_category_authority(category).is_ok()); + } + } + + #[cfg(unix)] + #[test] + fn approval_is_retained_only_with_exact_public_mutation_shape() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let receipt_dir = temp.path().join("receipts"); + std::fs::create_dir(&receipt_dir).unwrap(); + std::fs::set_permissions(&receipt_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let docker = temp.path().join("docker"); + std::fs::write( + &docker, + "#!/bin/sh\ncase \"$*\" in\n *\" info\") exit 0 ;;\n *\"buildx du\"*) printf '%s\\n' '{\"ID\":\"cache123\",\"Reclaimable\":true,\"Shared\":false,\"Mutable\":false,\"Type\":\"regular\"}' ;;\n *) exit 0 ;;\nesac\n", + ) + .unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o700)).unwrap(); + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + docker, + None, + ) + .unwrap(); + let mut plan = probe_container_orphans_with_receipt_dir(&target, &receipt_dir); + let build_cache = plan + .categories + .iter_mut() + .find(|category| category.category == OrphanCategory::BuildCache) + .unwrap(); + assert!(build_cache.approval_phrase.is_some()); + assert!(build_cache.prune_command.is_some()); + + let sanitized = sanitize_plan(plan); + let build_cache = sanitized + .categories + .iter() + .find(|category| category.category == OrphanCategory::BuildCache) + .unwrap(); + assert!(build_cache.approval_phrase.is_some()); + assert_eq!( + build_cache.prune_command.as_deref(), + Some( + [ + "buildx", + "prune", + "--all", + "--filter", + "id~=^()$", + "--force" + ] + .map(str::to_string) + .as_slice() + ) + ); + } + + #[test] + fn malformed_issue_tokens_fall_back_without_reflection() { + assert_eq!(stable_issue("Bad Token:/secret"), FALLBACK_ISSUE); + assert_eq!(stable_issue(""), FALLBACK_ISSUE); + assert_eq!( + stable_issue("orphan-list-container-failed:/secret"), + "orphan-list-container-failed" + ); + } +} diff --git a/src-tauri/src/container_orphan_reclaim.rs b/src-tauri/src/container_orphan_reclaim.rs new file mode 100644 index 000000000..f4403bcfd --- /dev/null +++ b/src-tauri/src/container_orphan_reclaim.rs @@ -0,0 +1,2700 @@ +//! Fail-closed, identity-bound orphan reclamation for Docker, Podman, and Colima runtimes. +//! +//! This module audits four orphan categories — stopped containers, unreferenced images, +//! dangling volumes, and unused custom networks — across three runtime targets: +//! +//! - `docker` with the default context (`DockerNative`) +//! - `docker --context colima` for Colima-managed Docker sockets (`DockerColimaContext`) +//! - `podman --connection ` against a running Podman machine (`PodmanMachine`) +//! +//! Safety contract shared with [`crate::podman_reclaim`]: +//! +//! 1. The audit is read-only and bounded by wall-clock timeouts and output caps. +//! 2. Every execution requires a fresh audit at execution time; the approval phrase embeds +//! a SHA-256 fingerprint of the exact sorted candidate identity set. +//! 3. Running or paused containers are never candidates. Built-in networks +//! (`bridge`, `host`, `none`, `podman`) are never candidates. Image deletion targets only +//! full image identities that no container references +//! after a bounded container-membership query proves no container references the image. +//! 4. Mutation targets only the exact identities observed by the fresh audit. Category-wide +//! `prune` commands are never used, so a resource that becomes orphaned after the audit cannot +//! be swept into the approved mutation set. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +pub const CONTAINER_ORPHAN_SCHEMA_KIND: &str = "disksage.container-orphan-plan"; +const CONTAINER_ORPHAN_SCHEMA_VERSION: u32 = 1; +/// Bounded per-command wall clock; matches the existing Podman prune bound. +const ORPHAN_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_CAPTURE_BYTES: usize = 1_048_576; +const MAX_DOCKER_HOST_BYTES: usize = 2 * 1024; +const INDETERMINATE_MUTATION_OUTCOME: &str = "container-orphan-prune-outcome-indeterminate"; + +/// Maximum number of network-inspect probes per audit; keeps the read-only pass bounded. +pub const MAX_NETWORK_CANDIDATES: usize = 64; +/// Maximum number of records retained per category before the audit fails closed. +pub const MAX_CATEGORY_RECORDS: usize = 4_096; +/// Exact deletion is deliberately capped so a single runtime invocation remains bounded on every +/// supported host, including Windows command-line limits and 200-byte volume/network names. +const MAX_EXACT_DELETE_CANDIDATES: usize = 256; +const MAX_BUILD_CACHE_FILTER_BYTES: usize = 24 * 1024; + +/// Runtime target kinds supported by the orphan reclaim engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ContainerRuntimeKind { + /// Plain `docker` against the default context / local socket. + DockerNative, + /// `docker --context colima` against a Colima-managed socket. + DockerColimaContext, + /// `podman --connection ` against a running Podman machine. + PodmanMachine, +} + +impl ContainerRuntimeKind { + /// Stable lowercase identifier used in receipts and UI labels. + pub fn as_str(self) -> &'static str { + match self { + Self::DockerNative => "docker-native", + Self::DockerColimaContext => "docker-colima-context", + Self::PodmanMachine => "podman-machine", + } + } + + fn is_docker(self) -> bool { + matches!(self, Self::DockerNative | Self::DockerColimaContext) + } +} + +/// A concrete runtime target: binary plus optional scope name (context or machine). +/// +/// Scope names are validated to reject option injection: they must be non-empty ASCII +/// alphanumeric plus `-_.`, must not start with `-`, `.`, or `..`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ContainerRuntimeTarget { + pub kind: ContainerRuntimeKind, + pub binary_path: PathBuf, + pub scope_name: Option, + docker_host: Option, + docker_context: Option, +} + +fn valid_scope_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value != "." + && value != ".." + && !value.starts_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +impl ContainerRuntimeTarget { + /// Builds a target after validating the scope name fail-closed. + pub fn new( + kind: ContainerRuntimeKind, + binary_path: PathBuf, + scope_name: Option, + ) -> Result { + if let Some(scope) = &scope_name { + if !valid_scope_name(scope) { + return Err("unsafe-runtime-scope-name".into()); + } + } + Ok(Self { + kind, + binary_path, + scope_name, + docker_host: None, + docker_context: None, + }) + } + + /// Pins Docker-native commands to one resolved daemon endpoint. + pub fn docker_native_host(binary_path: PathBuf, host: String) -> Result { + if host.is_empty() + || host.len() > MAX_DOCKER_HOST_BYTES + || host.chars().any(char::is_control) + { + return Err("unsafe-docker-host".into()); + } + Ok(Self { + kind: ContainerRuntimeKind::DockerNative, + binary_path, + scope_name: None, + docker_host: Some(host), + docker_context: None, + }) + } + + /// Pins Docker-native commands to a named CLI context, preserving its TLS material. + pub(crate) fn docker_native_context( + binary_path: PathBuf, + context: String, + ) -> Result { + if !valid_scope_name(&context) { + return Err("unsafe-runtime-scope-name".into()); + } + Ok(Self { + kind: ContainerRuntimeKind::DockerNative, + binary_path, + scope_name: None, + docker_host: None, + docker_context: Some(context), + }) + } + + /// Human-readable display name for receipts and UI copy. + pub fn display_name(&self) -> String { + let base = self + .binary_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "runtime".to_string()); + match (&self.scope_name, self.kind) { + (Some(scope), _) => format!("{base} {scope}"), + (None, kind) => format!("{base} ({})", kind.as_str()), + } + } + + /// Command-line prefix injected before every subcommand. + /// + /// The prefix is validated so no user-controlled bytes can introduce an option: + /// only fixed flags (`--context`, `--connection`) plus the validated scope name. + pub fn command_prefix(&self) -> Result, String> { + let binary = self.binary_path.to_string_lossy().into_owned(); + if binary.is_empty() || binary.contains('\0') { + return Err("unsafe-runtime-binary-path".into()); + } + let mut prefix = vec![binary]; + match self.kind { + ContainerRuntimeKind::DockerNative => { + if let Some(context) = &self.docker_context { + prefix.extend(["--context".to_string(), context.clone()]); + } else if let Some(host) = &self.docker_host { + prefix.extend(["--host".to_string(), host.clone()]); + } + } + ContainerRuntimeKind::DockerColimaContext | ContainerRuntimeKind::PodmanMachine => { + let flag = match self.kind { + ContainerRuntimeKind::PodmanMachine => "--connection", + _ => "--context", + }; + let scope = self + .scope_name + .as_ref() + .ok_or_else(|| format!("missing-scope-for-{}", self.kind.as_str()))?; + if !valid_scope_name(scope) { + return Err("unsafe-runtime-scope-name".into()); + } + prefix.push(flag.to_string()); + prefix.push(scope.clone()); + } + } + Ok(prefix) + } +} + +/// Resolves a named Docker context to the endpoint used by an explicit `--host` command. +pub(crate) fn resolve_docker_context_host( + binary_path: &Path, + context: &str, +) -> Result { + if !valid_scope_name(context) { + return Err("unsafe-runtime-scope-name".into()); + } + let output = command_text( + binary_path, + &[ + "context", + "inspect", + context, + "--format", + "{{json .Endpoints.docker.Host}}", + ], + ORPHAN_COMMAND_TIMEOUT, + "docker-context-host-inspect", + )?; + let host: String = serde_json::from_str(output.trim()) + .map_err(|_| "docker-context-host-invalid".to_string())?; + ContainerRuntimeTarget::docker_native_host(binary_path.to_path_buf(), host.clone())?; + Ok(host) +} + +/// Returns a stable fingerprint of the complete context definition, including TLS metadata. +pub(crate) fn resolve_docker_context_fingerprint( + binary_path: &Path, + context: &str, +) -> Result { + if !valid_scope_name(context) { + return Err("unsafe-runtime-scope-name".into()); + } + let output = command_text( + binary_path, + &["context", "inspect", context, "--format", "{{json .}}"], + ORPHAN_COMMAND_TIMEOUT, + "docker-context-inspect", + )?; + let value: Value = + serde_json::from_str(output.trim()).map_err(|_| "docker-context-invalid".to_string())?; + let canonical = serde_json::to_vec(&value).map_err(|_| "docker-context-invalid".to_string())?; + let digest = Sha256::digest(canonical); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").map_err(|_| "docker-context-invalid".to_string())?; + } + Ok(encoded) +} + +/// Orphan categories audited and pruned by this engine, one at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrphanCategory { + /// Stopped containers (`exited`/`created`/`dead`/`stopped` states). + Container, + /// Images with no container reference. + Image, + /// Dangling volumes not referenced by any container. + Volume, + /// Custom networks with no attached container endpoint. + Network, + /// BuildKit records currently reported as reclaimable by `docker buildx du`. + BuildCache, +} + +impl OrphanCategory { + pub fn as_str(self) -> &'static str { + match self { + Self::Container => "container", + Self::Image => "image", + Self::Volume => "volume", + Self::Network => "network", + Self::BuildCache => "build_cache", + } + } + + fn domain_tag(self) -> &'static str { + match self { + Self::Container => "disksage.container-orphans.v1", + Self::Image => "disksage.container-image-orphans.v1", + Self::Volume => "disksage.container-volume-orphans.v1", + Self::Network => "disksage.container-network-orphans.v1", + Self::BuildCache => "disksage.container-build-cache.v1", + } + } + + fn exact_delete_subcommand(self) -> [&'static str; 2] { + match self { + Self::Container => ["container", "rm"], + Self::Image => ["image", "rm"], + Self::Volume => ["volume", "rm"], + Self::Network => ["network", "rm"], + Self::BuildCache => ["buildx", "prune"], + } + } +} + +/// Per-category candidate evidence. Candidate identities are never rendered in reports; +/// only their SHA-256 set fingerprint is exposed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OrphanCandidateEvidence { + pub total_records: u64, + pub candidate_records: u64, + /// Sum of record sizes where the runtime reports them (images); otherwise null. + pub candidate_size_sum_bytes: Option, + pub candidate_set_sha256: String, +} + +/// Read-only audit result for one category on one healthy runtime target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OrphanCategoryPlan { + pub category: OrphanCategory, + pub evidence_complete: bool, + /// Bounded failure reason when evidence is incomplete; empty when complete. + pub issue: Option, + pub evidence: Option, + /// Present only when fresh evidence contains at least one candidate. + pub approval_phrase: Option, + /// Redacted exact-delete command shape; candidate identities never enter serialized reports. + pub prune_command: Option>, + /// Exact validated identities bound to `evidence`; deliberately excluded from serialization. + #[serde(skip_serializing)] + candidate_ids: Vec, +} + +/// Health observation for the probed runtime target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeHealthEvidence { + pub kind: ContainerRuntimeKind, + pub display_name: String, + pub healthy: bool, + pub detail_issue: Option, +} + +/// Full read-only plan for one runtime target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ContainerOrphanPlan { + pub schema_kind: &'static str, + pub schema_version: u32, + pub platform: &'static str, + pub evidence_complete: bool, + pub elapsed_ms: u64, + pub runtime: RuntimeHealthEvidence, + pub categories: Vec, + pub issues: Vec, + pub receipt_directory_sha256: Option, +} + +/// Execution receipt for one approved prune. Mirrors the Podman dangling-image receipt +/// shape so downstream consumers can treat both uniformly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerOrphanPruneExecution { + pub schema_version: u32, + pub runtime_display_name: String, + pub category: OrphanCategory, + pub candidate_set_sha256: String, + pub command: Vec, + pub status_code: i32, + pub stdout: String, + pub stderr: String, + pub output_truncated: bool, + pub executed: bool, + pub executed_at_ms: u64, + pub before_available_bytes: Option, + pub after_available_bytes: Option, + /// Only a positive before/after available-space delta is reported; attribution-weak. + pub observed_available_gain_bytes: Option, + pub rationale: String, + pub receipt_sha256: Option, + pub receipt_recorded: bool, + pub receipt_record_error: Option, +} + +fn private_receipt_directory_identity(path: &Path) -> Result<(PathBuf, String), String> { + if !path.is_absolute() { + return Err("orphan-receipt-directory-not-absolute".into()); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "orphan-receipt-directory-unavailable".to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("orphan-receipt-directory-unsafe".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.permissions().mode() & 0o077 != 0 { + return Err("orphan-receipt-directory-not-private".into()); + } + let canonical = std::fs::canonicalize(path) + .map_err(|_| "orphan-receipt-directory-unavailable".to_string())?; + let mut hasher = Sha256::new(); + hasher.update(b"disksage.container-orphan-receipt-directory.v1\0"); + hash_frame( + &mut hasher, + canonical.as_os_str().to_string_lossy().as_bytes(), + ); + hash_frame(&mut hasher, &metadata.dev().to_be_bytes()); + hash_frame(&mut hasher, &metadata.ino().to_be_bytes()); + return Ok((canonical, lower_hex(&hasher.finalize()))); + } + #[cfg(not(unix))] + { + Err("orphan-receipt-secure-mode-unsupported".into()) + } +} + +fn write_execution_receipt( + receipt_dir: &Path, + receipt: &ContainerOrphanPruneExecution, +) -> Result { + let (canonical, _) = private_receipt_directory_identity(receipt_dir)?; + let mut encoded = serde_json::to_vec_pretty(receipt) + .map_err(|_| "orphan-receipt-json-invalid".to_string())?; + if encoded.len() > 1024 * 1024 { + return Err("orphan-receipt-too-large".into()); + } + let digest = lower_hex(&Sha256::digest(&encoded)); + let path = canonical.join(format!( + "{}-{}-{}.json", + receipt.executed_at_ms, + receipt.category.as_str(), + receipt.candidate_set_sha256 + )); + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o400); + let mut file = options + .open(&path) + .map_err(|_| "orphan-receipt-create-failed".to_string())?; + if file + .write_all(&encoded) + .and_then(|_| file.sync_all()) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + return Err("orphan-receipt-write-failed".into()); + } + encoded.fill(0); + if std::fs::File::open(&canonical) + .and_then(|dir| dir.sync_all()) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + return Err("orphan-receipt-parent-sync-failed".into()); + } + Ok(digest) +} + +fn read_execution_receipt( + path: &Path, + expected_sha256: &str, +) -> Result { + let metadata = + std::fs::symlink_metadata(path).map_err(|_| "orphan-receipt-unavailable".to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 1024 * 1024 { + return Err("orphan-receipt-unsafe".into()); + } + let encoded = std::fs::read(path).map_err(|_| "orphan-receipt-read-failed".to_string())?; + if lower_hex(&Sha256::digest(&encoded)) != expected_sha256 { + return Err("orphan-receipt-digest-mismatch".into()); + } + serde_json::from_slice(&encoded).map_err(|_| "orphan-receipt-json-invalid".to_string()) +} + +// --------------------------------------------------------------------------- +// Tolerant record parsing. Docker emits NDJSON (one object per line) while Podman +// emits a JSON array; both use PascalCase keys except Podman's network listing, +// which uses lowercase keys. Parsers accept either envelope and key casing. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ContainerRecord { + id: String, + state: String, + names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResourceOwnershipEvidence { + identity_binding: String, + explicitly_reclaimable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImageRecord { + id: String, + tags: Vec, + containers: Option, + size_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VolumeRecord { + name: String, +} + +const DISKSAGE_OWNER_LABEL: &str = "io.contextualwisdomlab.disksage.owner"; +const DISKSAGE_RECLAIMABLE_LABEL: &str = "io.contextualwisdomlab.disksage.reclaimable"; +const DISKSAGE_BUSINESS_DATA_LABEL: &str = "io.contextualwisdomlab.disksage.business-data"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VolumeOwnershipEvidence { + name: String, + identity_binding: String, + explicitly_reclaimable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NetworkRecord { + id: Option, + name: String, + driver: String, +} + +fn split_json_envelopes(output: &str) -> Result, String> { + let trimmed = output.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + if let Ok(Value::Array(records)) = serde_json::from_str::(trimmed) { + return Ok(records); + } + // NDJSON: skip blank lines, fail closed on any malformed line instead of skipping it, + // because silently dropping a record could hide a referenced resource from evidence. + let mut records = Vec::new(); + for line in trimmed.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let value = serde_json::from_str::(line) + .map_err(|error| format!("invalid-json-record:{error}"))?; + records.push(value); + } + if records.is_empty() { + return Err("empty-json-output".to_string()); + } + Ok(records) +} + +fn string_field(record: &Value, keys: &[&str]) -> Result { + for key in keys { + if let Some(value) = record.get(*key).and_then(Value::as_str) { + return Ok(value.to_string()); + } + } + Err(format!("json-field-missing:{}", keys[0])) +} + +/// Normalizes a runtime-reported ID to bare lowercase hex, rejecting anything else. +fn normalize_hex_id(raw: &str, label: &str) -> Result { + let stripped = raw.strip_prefix("sha256:").unwrap_or(raw); + if stripped.len() == 64 + && stripped + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Ok(stripped.to_string()); + } + Err(format!("{label}-invalid-id")) +} + +fn parse_container_records(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let mut records = Vec::with_capacity(values.len()); + for value in values { + let id = string_field(&value, &["ID", "Id"])?; + let id = normalize_hex_id(&id, "container")?; + let state = string_field(&value, &["State", "state"])?.to_lowercase(); + let names = match value.get("Names") { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + // Podman may serialize Names as a JSON-encoded array string; Docker emits a plain + // comma-joined string. Names are not used for candidacy, so accept both shapes. + Some(Value::String(encoded)) => serde_json::from_str::>(encoded) + .unwrap_or_else(|_| encoded.split(',').map(str::to_string).collect()), + None => Vec::new(), + Some(_) => return Err("container-names-invalid".to_string()), + }; + records.push(ContainerRecord { id, state, names }); + } + Ok(records) +} + +/// Containers are orphan candidates only when fully stopped: `exited`, `created`, `dead`, +/// or Podman's documented `stopped`. Known pre-start/transitional states are preserved; only +/// unrecognized states fail the category closed. +fn classify_container_candidates( + records: &[ContainerRecord], +) -> Result<(u64, Vec<&ContainerRecord>), String> { + let mut candidates = Vec::new(); + for record in records { + match record.state.as_str() { + "exited" | "created" | "dead" | "stopped" => candidates.push(record), + "running" | "paused" | "restarting" | "removing" | "initialized" | "stopping" + | "configured" => {} + other => return Err(format!("unknown-container-state:{other}")), + } + } + let total = u64::try_from(records.len()).map_err(|_| "record-count-overflow".to_string())?; + Ok((total, candidates)) +} + +fn parse_u64_field(record: &Value, keys: &[&str]) -> Result, String> { + for key in keys { + let field = match record.get(*key) { + Some(field) => field, + None => continue, + }; + return match field { + Value::Number(number) => { + Ok(Some(number.as_u64().ok_or_else(|| { + format!("json-field-invalid:{}", keys[0]) + })?)) + } + Value::String(text) if text == "-1" => Err(format!("json-field-invalid:{}", keys[0])), + Value::String(text) => text + .parse::() + .map(Some) + .map_err(|_| format!("json-field-invalid:{}", keys[0])), + Value::Null => Ok(None), + _ => Err(format!("json-field-invalid:{}", keys[0])), + }; + } + Ok(None) +} + +fn parse_image_records(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let mut records = Vec::with_capacity(values.len()); + for value in values { + let raw_id = string_field(&value, &["ID", "Id", "id"])?; + let id = normalize_hex_id(&raw_id, "image")?; + let mut tags = Vec::new(); + for tag_key in ["RepoTags", "RepoDigests"] { + if let Some(Value::Array(items)) = value.get(tag_key) { + tags.extend( + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)), + ); + } + } + tags.sort(); + tags.dedup(); + let containers = parse_u64_field(&value, &["Containers", "containers"])?; + let size_bytes = parse_u64_field(&value, &["Size", "size"])? + .ok_or_else(|| "json-field-missing:Size".to_string())?; + records.push(ImageRecord { + id, + tags, + containers, + size_bytes, + }); + } + Ok(records) +} + +fn parse_docker_image_ids(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + values + .into_iter() + .map(|value| { + let raw_id = string_field(&value, &["ID", "Id"])?; + normalize_hex_id(&raw_id, "image") + }) + .collect() +} + +fn parse_buildx_private_immutable_reclaimable_ids( + output: &str, +) -> Result<(u64, Vec), String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let total = u64::try_from(values.len()).map_err(|_| "record-count-overflow".to_string())?; + let mut ids = Vec::new(); + for value in values { + let reclaimable = value + .get("Reclaimable") + .and_then(Value::as_bool) + .ok_or_else(|| "build-cache-reclaimable-missing".to_string())?; + if reclaimable { + let shared = value + .get("Shared") + .and_then(Value::as_bool) + .ok_or_else(|| "build-cache-shared-missing".to_string())?; + let mutable = value + .get("Mutable") + .and_then(Value::as_bool) + .ok_or_else(|| "build-cache-mutable-missing".to_string())?; + let record_type = value + .get("Type") + .and_then(Value::as_str) + .ok_or_else(|| "build-cache-type-missing".to_string())?; + if shared || mutable || record_type == "exec.cachemount" { + continue; + } + let id = string_field(&value, &["ID"])?; + if id.is_empty() || id.len() > 128 || !id.bytes().all(|b| b.is_ascii_alphanumeric()) { + return Err("build-cache-id-invalid".into()); + } + ids.push(id); + } + } + Ok((total, bounded_build_cache_candidate_ids(ids)?)) +} + +/// Parse the exact byte sizes returned by `docker image inspect` for the already-authorized +/// unreferenced image identities. The list command's `Size` field is human-readable, so it is not +/// converted with a unit heuristic; inspect's numeric `Size` is the only accepted estimate. +fn parse_docker_image_sizes(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let mut sizes = BTreeMap::new(); + for value in values { + let raw_id = string_field(&value, &["Id", "ID", "id"])?; + let id = normalize_hex_id(&raw_id, "image")?; + let size = ["Size", "size"] + .iter() + .find_map(|key| value.get(*key).and_then(Value::as_u64)) + .ok_or_else(|| "json-field-invalid-or-missing:Size".to_string())?; + if sizes.insert(id, size).is_some() { + return Err("duplicate-image-id".to_string()); + } + } + Ok(sizes) +} + +fn inspect_docker_image_sizes( + target: &ContainerRuntimeTarget, + prefix: &[String], + image_ids: &[String], +) -> Result, String> { + if image_ids.is_empty() { + return Ok(BTreeMap::new()); + } + let mut args: Vec = prefix.iter().skip(1).cloned().collect(); + args.extend([ + "image".to_string(), + "inspect".to_string(), + "--format".to_string(), + r#"{"Id":{{json .Id}},"Size":{{json .Size}}}"#.to_string(), + ]); + args.extend(image_ids.iter().cloned()); + let references: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = command_text( + &target.binary_path, + &references, + ORPHAN_COMMAND_TIMEOUT, + "orphan-docker-image-size-inspect", + )?; + let sizes = parse_docker_image_sizes(&output)?; + if sizes.len() != image_ids.len() + || image_ids + .iter() + .any(|image_id| !sizes.contains_key(image_id)) + || sizes + .keys() + .any(|image_id| !image_ids.iter().any(|expected| expected == image_id)) + { + return Err("docker-image-size-identity-mismatch".to_string()); + } + Ok(sizes) +} + +/// Images are candidates only with proven zero references and no usable tag/digest. +/// A missing container-reference count fails closed for that record. +fn classify_image_candidates(records: &[ImageRecord]) -> Result<(u64, Vec<&ImageRecord>), String> { + let mut candidates = Vec::new(); + for record in records { + let references = record.containers.ok_or_else(|| { + format!( + "image-reference-count-unavailable:{}", + &record.id[..8.min(record.id.len())] + ) + })?; + if references == 0 && record.tags.is_empty() { + candidates.push(record); + } + } + let total = u64::try_from(records.len()).map_err(|_| "record-count-overflow".to_string())?; + Ok((total, candidates)) +} + +fn validate_resource_name(raw: &str, label: &str) -> Result { + if raw.is_empty() + || raw.starts_with('-') + || raw.len() > 200 + || raw.chars().any(char::is_control) + { + return Err(format!("{label}-invalid-name")); + } + Ok(raw.to_string()) +} + +fn parse_volume_records(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let mut records = Vec::with_capacity(values.len()); + for value in values { + let raw_name = string_field(&value, &["Name", "name"])?; + let name = validate_resource_name(&raw_name, "volume")?; + records.push(VolumeRecord { name }); + } + Ok(records) +} + +fn parse_volume_ownership( + output: &str, + expected_name: &str, +) -> Result { + let records = split_json_envelopes(output)?; + if records.len() != 1 { + return Err("volume-inspect-record-count-invalid".to_string()); + } + let record = &records[0]; + let name = validate_resource_name(&string_field(record, &["Name", "name"])?, "volume")?; + if name != expected_name { + return Err("volume-inspect-name-mismatch".to_string()); + } + let driver = string_field(record, &["Driver", "driver"])?; + let created_at = string_field(record, &["CreatedAt", "createdAt", "created_at"])?; + if driver.is_empty() || created_at.is_empty() { + return Err("volume-inspect-identity-incomplete".to_string()); + } + let empty_labels = serde_json::Map::new(); + let labels = match record.get("Labels").or_else(|| record.get("labels")) { + Some(Value::Object(labels)) => labels, + Some(Value::Null) => &empty_labels, + Some(_) => return Err("volume-inspect-label-invalid".to_string()), + None => return Err("volume-inspect-labels-incomplete".to_string()), + }; + let mut ordered_labels = Vec::with_capacity(labels.len()); + for (key, value) in labels { + let value = value + .as_str() + .ok_or_else(|| "volume-inspect-label-invalid".to_string())?; + ordered_labels.push((key.as_str(), value)); + } + ordered_labels.sort_unstable(); + let compose_owned = ordered_labels + .iter() + .any(|(key, _)| key.starts_with("com.docker.compose.")); + let explicitly_reclaimable = !compose_owned + && labels + .get(DISKSAGE_BUSINESS_DATA_LABEL) + .and_then(Value::as_str) + != Some("true") + && labels.get(DISKSAGE_OWNER_LABEL).and_then(Value::as_str) == Some("disksage") + && labels + .get(DISKSAGE_RECLAIMABLE_LABEL) + .and_then(Value::as_str) + == Some("true"); + let mut hasher = Sha256::new(); + hasher.update(b"disksage.container-volume-identity.v1\0"); + for value in [name.as_bytes(), driver.as_bytes(), created_at.as_bytes()] { + hash_frame(&mut hasher, value); + } + for (key, value) in ordered_labels { + hash_frame(&mut hasher, key.as_bytes()); + hash_frame(&mut hasher, value.as_bytes()); + } + Ok(VolumeOwnershipEvidence { + name, + identity_binding: lower_hex(&hasher.finalize()), + explicitly_reclaimable, + }) +} + +fn parsed_labels<'a>( + record: &'a Value, + nested: Option<&str>, +) -> Result<&'a serde_json::Map, String> { + let owner = nested.and_then(|key| record.get(key)).unwrap_or(record); + match owner.get("Labels").or_else(|| owner.get("labels")) { + Some(Value::Object(labels)) => Ok(labels), + Some(_) => Err("resource-inspect-label-invalid".to_string()), + None => Err("resource-inspect-labels-incomplete".to_string()), + } +} + +fn ownership_binding( + domain: &[u8], + fields: &[&str], + labels: &serde_json::Map, +) -> Result { + let mut ordered = Vec::with_capacity(labels.len()); + for (key, value) in labels { + ordered.push(( + key.as_str(), + value + .as_str() + .ok_or_else(|| "resource-inspect-label-invalid".to_string())?, + )); + } + ordered.sort_unstable(); + let compose_owned = ordered + .iter() + .any(|(key, _)| key.starts_with("com.docker.compose.")); + let explicitly_reclaimable = !compose_owned + && labels.get(DISKSAGE_OWNER_LABEL).and_then(Value::as_str) == Some("disksage") + && labels + .get(DISKSAGE_RECLAIMABLE_LABEL) + .and_then(Value::as_str) + == Some("true"); + let mut hasher = Sha256::new(); + hasher.update(domain); + for field in fields { + hash_frame(&mut hasher, field.as_bytes()); + } + for (key, value) in ordered { + hash_frame(&mut hasher, key.as_bytes()); + hash_frame(&mut hasher, value.as_bytes()); + } + Ok(ResourceOwnershipEvidence { + identity_binding: lower_hex(&hasher.finalize()), + explicitly_reclaimable, + }) +} + +fn parse_container_ownership( + output: &str, + expected_id: &str, +) -> Result { + let records = split_json_envelopes(output)?; + if records.len() != 1 { + return Err("container-inspect-record-count-invalid".into()); + } + let record = &records[0]; + let id = normalize_hex_id(&string_field(record, &["Id", "ID", "id"])?, "container")?; + if id != expected_id { + return Err("container-inspect-id-mismatch".into()); + } + let state = record + .get("State") + .or_else(|| record.get("state")) + .and_then(|value| value.get("Status").or_else(|| value.get("status"))) + .and_then(Value::as_str) + .ok_or_else(|| "container-inspect-state-incomplete".to_string())? + .to_ascii_lowercase(); + if !matches!(state.as_str(), "exited" | "created" | "dead" | "stopped") { + return Err("container-inspect-not-stopped".into()); + } + let created = string_field(record, &["Created", "created"])?; + ownership_binding( + b"disksage.container-identity.v1\0", + &[&id, &state, &created], + parsed_labels(record, Some("Config"))?, + ) +} + +fn parse_network_ownership( + output: &str, + expected_id: &str, + require_embedded_membership: bool, +) -> Result<(ResourceOwnershipEvidence, bool), String> { + let records = split_json_envelopes(output)?; + if records.len() != 1 { + return Err("network-inspect-record-count-invalid".into()); + } + let record = &records[0]; + let id = normalize_hex_id(&string_field(record, &["Id", "ID", "id"])?, "network")?; + if id != expected_id { + return Err("network-inspect-id-mismatch".into()); + } + let name = validate_resource_name(&string_field(record, &["Name", "name"])?, "network")?; + let driver = string_field(record, &["Driver", "driver"])?; + let attached = if require_embedded_membership { + network_has_attached_containers(output)? + } else { + false + }; + let ownership = ownership_binding( + b"disksage.container-network-identity.v1\0", + &[&id, &name, &driver], + parsed_labels(record, None)?, + )?; + Ok((ownership, attached)) +} + +fn inspect_volume_ownership( + target: &ContainerRuntimeTarget, + prefix: &[String], + volume_name: &str, +) -> Result { + let mut args: Vec<&str> = prefix.iter().skip(1).map(String::as_str).collect(); + args.extend(["volume", "inspect", volume_name]); + parse_volume_ownership( + &command_text( + &target.binary_path, + &args, + ORPHAN_COMMAND_TIMEOUT, + "orphan-volume-ownership", + )?, + volume_name, + ) +} + +const BUILTIN_NETWORK_NAMES: [&str; 4] = ["bridge", "host", "none", "podman"]; + +fn parse_network_records(output: &str) -> Result, String> { + let values = split_json_envelopes(output)?; + if values.len() > MAX_CATEGORY_RECORDS { + return Err("record-count-exceeds-bound".to_string()); + } + let mut records = Vec::with_capacity(values.len()); + for value in values { + let raw_id = match value + .get("ID") + .or_else(|| value.get("Id")) + .or_else(|| value.get("id")) + { + Some(Value::String(text)) => Some(text.clone()), + _ => None, + }; + let raw_name = string_field(&value, &["Name", "name"])?; + let name = validate_resource_name(&raw_name, "network")?; + let driver = string_field(&value, &["Driver", "driver"])?.to_lowercase(); + records.push(NetworkRecord { + id: raw_id, + name, + driver, + }); + } + Ok(records) +} + +fn classify_network_candidates<'a>( + records: &'a [NetworkRecord], + attached_network_names: &[String], +) -> Result<(u64, Vec<&'a NetworkRecord>), String> { + let mut candidates = Vec::new(); + for record in records { + if BUILTIN_NETWORK_NAMES.contains(&record.name.as_str()) + || matches!(record.driver.as_str(), "host" | "null") + { + continue; + } + if attached_network_names.contains(&record.name) { + continue; + } + candidates.push(record); + } + let total = u64::try_from(records.len()).map_err(|_| "record-count-overflow".to_string())?; + Ok((total, candidates)) +} + +fn network_has_attached_containers(output: &str) -> Result { + let value = serde_json::from_str::(output.trim()) + .map_err(|error| format!("invalid-network-inspect-json:{error}"))?; + let network = match &value { + Value::Array(items) => items + .first() + .ok_or_else(|| "network-inspect-empty".to_string())?, + object @ Value::Object(_) => object, + _ => return Err("invalid-network-inspect-shape".to_string()), + }; + let containers = network + .get("Containers") + .or_else(|| network.get("containers")) + .ok_or_else(|| "network-inspect-containers-missing".to_string())?; + Ok(match containers { + Value::Object(map) => !map.is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Null => false, + _ => return Err("network-inspect-containers-invalid".to_string()), + }) +} + +fn hash_frame(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + +fn candidate_fingerprint(domain_tag: &str, ids: &[&str]) -> String { + let mut ordered: Vec<&str> = ids.to_vec(); + ordered.sort_unstable(); + let mut hasher = Sha256::new(); + hasher.update(domain_tag.as_bytes()); + for id in &ordered { + hash_frame(&mut hasher, id.as_bytes()); + } + lower_hex(&hasher.finalize()) +} + +fn approval_phrase( + category: OrphanCategory, + candidate_set_sha256: &str, + receipt_directory_sha256: &str, +) -> String { + format!( + "DiskSage {} orphan prune 승인 {} receipt {}", + category.as_str(), + candidate_set_sha256, + receipt_directory_sha256 + ) +} + +fn summarize_candidates( + category: OrphanCategory, + total_records: u64, + candidate_ids: &[&str], + size_sum: Option, +) -> Result { + let candidate_records = + u64::try_from(candidate_ids.len()).map_err(|_| "record-count-overflow".to_string())?; + let mut sorted_ids: Vec<&str> = candidate_ids.to_vec(); + sorted_ids.sort_unstable(); + if sorted_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err("duplicate-candidate-id".to_string()); + } + Ok(OrphanCandidateEvidence { + total_records, + candidate_records, + candidate_size_sum_bytes: size_sum, + candidate_set_sha256: candidate_fingerprint(category.domain_tag(), &sorted_ids), + }) +} + +fn bounded_exact_candidate_ids(mut candidate_ids: Vec) -> Result, String> { + if candidate_ids.len() > MAX_EXACT_DELETE_CANDIDATES { + return Err("exact-delete-candidate-count-exceeds-bound".to_string()); + } + candidate_ids.sort_unstable(); + if candidate_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err("duplicate-candidate-id".to_string()); + } + Ok(candidate_ids) +} + +fn bounded_build_cache_candidate_ids( + mut candidate_ids: Vec, +) -> Result, String> { + candidate_ids.sort_unstable(); + if candidate_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err("duplicate-candidate-id".to_string()); + } + let filter_bytes = candidate_ids.iter().try_fold(8usize, |total, id| { + total + .checked_add(id.len()) + .and_then(|value| value.checked_add(1)) + .ok_or_else(|| "build-cache-filter-size-overflow".to_string()) + })?; + if filter_bytes > MAX_BUILD_CACHE_FILTER_BYTES { + return Err("build-cache-filter-exceeds-bound".to_string()); + } + Ok(candidate_ids) +} + +fn build_cache_id_filter(candidate_ids: &[String]) -> Result { + if candidate_ids.is_empty() { + return Err("orphan-prune-empty-candidate-set".into()); + } + if candidate_ids.iter().any(|id| { + id.is_empty() || id.len() > 128 || !id.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }) { + return Err("build-cache-id-invalid".into()); + } + let candidate_ids = bounded_build_cache_candidate_ids(candidate_ids.to_vec())?; + Ok(format!("id~=^({})$", candidate_ids.join("|"))) +} + +fn redacted_exact_delete_command( + prefix: &[String], + category: OrphanCategory, + has_candidates: bool, +) -> Vec { + let mut command = prefix.to_vec(); + if category == OrphanCategory::BuildCache { + command.extend(["buildx".into(), "prune".into(), "--all".into()]); + if has_candidates { + command.extend(["--filter".into(), "id~=^()$".into()]); + } + command.push("--force".into()); + return command; + } + command.extend( + category + .exact_delete_subcommand() + .into_iter() + .map(str::to_string), + ); + if has_candidates { + command.push("".to_string()); + } + command +} + +fn drain_bounded(mut reader: R) -> std::io::Result<(Vec, bool)> { + let mut buffer = [0u8; 65_536]; + let mut captured = Vec::new(); + let mut truncated = false; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let room = MAX_CAPTURE_BYTES.saturating_sub(captured.len()); + let retained = read.min(room); + captured.extend_from_slice(&buffer[..retained]); + if retained < read { + truncated = true; + } + } + Ok((captured, truncated)) +} + +fn join_capture( + handle: thread::JoinHandle, bool)>>, + label: &str, + stream: &str, +) -> Result<(Vec, bool), String> { + handle + .join() + .map_err(|_| format!("{label}-{stream}-reader-panicked"))? + .map_err(|error| format!("{label}-{stream}:{error}")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandCapture { + status_code: i32, + stdout: String, + stderr: String, + output_truncated: bool, +} + +fn command_capture( + executable: &Path, + args: &[&str], + timeout: Duration, + label: &str, +) -> Result { + let mut command = Command::new(executable); + command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .map_err(|error| format!("{label}-spawn:{error}"))?; + let child_pid = child.id(); + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("{label}-stdout-pipe-unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| format!("{label}-stderr-pipe-unavailable"))?; + let stdout_reader = thread::spawn(move || drain_bounded(stdout)); + let stderr_reader = thread::spawn(move || drain_bounded(stderr)); + + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() >= timeout => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + let _ = join_capture(stdout_reader, label, "stdout"); + let _ = join_capture(stderr_reader, label, "stderr"); + return Err(format!("{label}-timeout")); + } + Ok(None) => thread::sleep(Duration::from_millis(25)), + Err(error) => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + let _ = join_capture(stdout_reader, label, "stdout"); + let _ = join_capture(stderr_reader, label, "stderr"); + return Err(format!("{label}-wait:{error}")); + } + } + }; + + // The direct CLI may exit while a descendant still owns the capture pipes. The child was + // isolated in its own process group, so terminate any such descendants before joining the + // reader threads; otherwise a successful probe can hang until the descendant exits. + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + + let (stdout, stdout_truncated) = join_capture(stdout_reader, label, "stdout")?; + let (stderr, stderr_truncated) = join_capture(stderr_reader, label, "stderr")?; + if stdout_truncated || stderr_truncated { + return Err(format!("{label}-output-too-large")); + } + Ok(CommandCapture { + status_code: status.code().unwrap_or(-1), + stdout: String::from_utf8(stdout).map_err(|_| format!("{label}-stdout-not-utf8"))?, + stderr: String::from_utf8(stderr).map_err(|_| format!("{label}-stderr-not-utf8"))?, + output_truncated: false, + }) +} + +fn mutation_capture_result( + result: Result, + label: &str, +) -> Result { + match result { + Ok(output) => Ok(output), + Err(error) if error == format!("{label}-output-too-large") => Ok(CommandCapture { + status_code: -1, + stdout: String::new(), + stderr: INDETERMINATE_MUTATION_OUTCOME.to_string(), + output_truncated: true, + }), + Err(error) if !error.starts_with(&format!("{label}-spawn:")) => Ok(CommandCapture { + status_code: -1, + stdout: String::new(), + stderr: INDETERMINATE_MUTATION_OUTCOME.to_string(), + output_truncated: false, + }), + Err(error) => Err(error), + } +} + +fn command_text( + executable: &Path, + args: &[&str], + timeout: Duration, + label: &str, +) -> Result { + let output = command_capture(executable, args, timeout, label)?; + if output.status_code != 0 { + let flattened = output.stderr.replace(['\r', '\n'], " "); + let detail: String = flattened.chars().take(512).collect(); + return Err(format!("{label}-failed:{detail}")); + } + Ok(output.stdout) +} + +pub fn probe_runtime_health(target: &ContainerRuntimeTarget) -> RuntimeHealthEvidence { + let detail_issue = (|| -> Result<(), String> { + let prefix = target.command_prefix()?; + let mut args: Vec<&str> = prefix.iter().skip(1).map(String::as_str).collect(); + args.push("info"); + command_text( + &target.binary_path, + &args, + ORPHAN_COMMAND_TIMEOUT, + "runtime-info", + ) + .map(|_| ()) + })() + .err(); + RuntimeHealthEvidence { + kind: target.kind, + display_name: target.display_name(), + healthy: detail_issue.is_none(), + detail_issue, + } +} + +fn audit_category( + target: &ContainerRuntimeTarget, + category: OrphanCategory, + receipt_directory_sha256: Option<&str>, +) -> OrphanCategoryPlan { + let build_issue_plan = |issue: String| OrphanCategoryPlan { + category, + evidence_complete: false, + issue: Some(issue), + evidence: None, + approval_phrase: None, + prune_command: None, + candidate_ids: Vec::new(), + }; + let prefix = match target.command_prefix() { + Ok(prefix) => prefix, + Err(error) => return build_issue_plan(error), + }; + let outcome = (|| -> Result<(Option, Vec), String> { + let list_label = format!("orphan-list-{}", category.as_str()); + let mut args: Vec<&str> = prefix.iter().skip(1).map(String::as_str).collect(); + match category { + OrphanCategory::Container => { + args.extend(["container", "ps", "--all"]); + if target.kind.is_docker() { + args.push("--no-trunc"); + } + args.extend(["--format", "json"]); + } + OrphanCategory::Image if target.kind.is_docker() => { + args.extend([ + "images", + "--all", + "--filter", + "dangling=true", + "--no-trunc", + "--format", + "json", + ]); + } + OrphanCategory::Image => { + args.extend([ + "images", + "--filter", + "dangling=true", + "--no-trunc", + "--format", + "json", + ]); + } + OrphanCategory::Volume => { + args.extend([ + "volume", + "ls", + "--filter", + "dangling=true", + "--format", + "json", + ]); + } + OrphanCategory::Network => { + args.extend(["network", "ls", "--no-trunc", "--format", "json"]); + } + OrphanCategory::BuildCache if target.kind.is_docker() => { + args.extend([ + "buildx", + "du", + "--format", + r#"{"ID":{{json .ID}},"Reclaimable":{{json .Reclaimable}},"Shared":{{json .Shared}},"Mutable":{{json .Mutable}},"Type":{{json .Type}}}"#, + ]); + } + OrphanCategory::BuildCache => return Err("build-cache-docker-only".into()), + } + let output = command_text( + &target.binary_path, + &args, + ORPHAN_COMMAND_TIMEOUT, + &list_label, + )?; + let image_has_container_reference = |image_id: &str| -> Result { + let filter = format!("ancestor={image_id}"); + let mut membership_args: Vec<&str> = + prefix.iter().skip(1).map(String::as_str).collect(); + membership_args.extend(["container", "ps", "--all", "--filter", &filter]); + if target.kind == ContainerRuntimeKind::PodmanMachine { + // Buildah working containers are hidden without --external but still retain images. + membership_args.push("--external"); + } + if target.kind.is_docker() { + membership_args.push("--no-trunc"); + } + membership_args.extend(["--format", "json"]); + let membership = command_text( + &target.binary_path, + &membership_args, + ORPHAN_COMMAND_TIMEOUT, + "orphan-image-container-membership", + )?; + Ok(!split_json_envelopes(&membership)?.is_empty()) + }; + let (evidence, candidate_ids) = match category { + OrphanCategory::Container => { + let records = parse_container_records(&output)?; + let (total, candidates) = classify_container_candidates(&records)?; + if candidates.len() > MAX_EXACT_DELETE_CANDIDATES { + return Err("candidate-count-exceeds-bound".to_string()); + } + let mut candidate_ids = Vec::new(); + let mut identity_bindings = Vec::new(); + for candidate in candidates { + let mut inspect_args: Vec<&str> = + prefix.iter().skip(1).map(String::as_str).collect(); + inspect_args.extend(["container", "inspect", &candidate.id]); + let ownership = parse_container_ownership( + &command_text( + &target.binary_path, + &inspect_args, + ORPHAN_COMMAND_TIMEOUT, + "orphan-container-ownership", + )?, + &candidate.id, + )?; + if ownership.explicitly_reclaimable { + candidate_ids.push(candidate.id.clone()); + identity_bindings.push(ownership.identity_binding); + } + } + let ids: Vec<&str> = identity_bindings.iter().map(String::as_str).collect(); + ( + Some(summarize_candidates(category, total, &ids, None)?), + candidate_ids, + ) + } + OrphanCategory::Image if target.kind.is_docker() => { + let listed_ids = bounded_exact_candidate_ids(parse_docker_image_ids(&output)?)?; + let total = u64::try_from(listed_ids.len()) + .map_err(|_| "record-count-overflow".to_string())?; + let mut candidate_ids = Vec::with_capacity(listed_ids.len()); + for image_id in listed_ids { + if !image_has_container_reference(&image_id)? { + candidate_ids.push(image_id); + } + } + let sizes = inspect_docker_image_sizes(target, &prefix, &candidate_ids)?; + let size_sum = candidate_ids.iter().try_fold(0u64, |sum, image_id| { + sum.checked_add( + *sizes + .get(image_id) + .ok_or_else(|| "docker-image-size-identity-mismatch".to_string())?, + ) + .ok_or_else(|| "size-overflow".to_string()) + })?; + let refs: Vec<&str> = candidate_ids.iter().map(String::as_str).collect(); + ( + Some(summarize_candidates( + category, + total, + &refs, + Some(size_sum), + )?), + candidate_ids, + ) + } + OrphanCategory::Image => { + let records = parse_image_records(&output)?; + let total = u64::try_from(records.len()) + .map_err(|_| "record-count-overflow".to_string())?; + let listed_ids = bounded_exact_candidate_ids( + records.iter().map(|record| record.id.clone()).collect(), + )?; + let mut candidate_ids = Vec::with_capacity(listed_ids.len()); + for image_id in listed_ids { + if !image_has_container_reference(&image_id)? { + candidate_ids.push(image_id); + } + } + let ids: Vec<&str> = candidate_ids.iter().map(String::as_str).collect(); + let size_sum = records + .iter() + .filter(|record| candidate_ids.binary_search(&record.id).is_ok()) + .try_fold(0u64, |sum, record| { + sum.checked_add(record.size_bytes) + .ok_or_else(|| "size-overflow".to_string()) + })?; + let mut evidence = summarize_candidates(category, total, &ids, None)?; + evidence.candidate_size_sum_bytes = Some(size_sum); + (Some(evidence), candidate_ids) + } + OrphanCategory::Volume => { + let records = parse_volume_records(&output)?; + let total = u64::try_from(records.len()) + .map_err(|_| "record-count-overflow".to_string())?; + if records.len() > MAX_EXACT_DELETE_CANDIDATES { + return Err("candidate-count-exceeds-bound".to_string()); + } + let mut candidate_ids = Vec::new(); + let mut identity_bindings = Vec::new(); + for record in &records { + let ownership = inspect_volume_ownership(target, &prefix, &record.name)?; + if ownership.explicitly_reclaimable { + candidate_ids.push(ownership.name); + identity_bindings.push(ownership.identity_binding); + } + } + let ids: Vec<&str> = identity_bindings.iter().map(String::as_str).collect(); + ( + Some(summarize_candidates(category, total, &ids, None)?), + candidate_ids, + ) + } + OrphanCategory::Network => { + let records = parse_network_records(&output)?; + let mut attached: Vec = Vec::new(); + let mut ownership_by_id = BTreeMap::new(); + let mut inspected_candidates = 0usize; + for record in &records { + if BUILTIN_NETWORK_NAMES.contains(&record.name.as_str()) + || matches!(record.driver.as_str(), "host" | "null") + { + continue; + } + if inspected_candidates >= MAX_NETWORK_CANDIDATES { + return Err("network-candidate-count-exceeds-bound".to_string()); + } + inspected_candidates = inspected_candidates.saturating_add(1); + let network_id = record + .id + .as_deref() + .ok_or_else(|| "network-id-missing".to_string()) + .and_then(|id| normalize_hex_id(id, "network"))?; + let mut ownership_args: Vec<&str> = + prefix.iter().skip(1).map(String::as_str).collect(); + ownership_args.extend(["network", "inspect", &network_id]); + let ownership_output = command_text( + &target.binary_path, + &ownership_args, + ORPHAN_COMMAND_TIMEOUT, + "orphan-network-ownership", + )?; + let (ownership, inspected_attached) = parse_network_ownership( + &ownership_output, + &network_id, + target.kind != ContainerRuntimeKind::PodmanMachine, + )?; + ownership_by_id.insert(network_id.clone(), ownership); + let has_attached_containers = + if target.kind == ContainerRuntimeKind::PodmanMachine { + let filter = format!("network={network_id}"); + let mut membership_args: Vec<&str> = + prefix.iter().skip(1).map(String::as_str).collect(); + membership_args.extend([ + "container", + "ps", + "--all", + "--filter", + &filter, + "--format", + "json", + ]); + !split_json_envelopes(&command_text( + &target.binary_path, + &membership_args, + ORPHAN_COMMAND_TIMEOUT, + "orphan-network-membership", + )?)? + .is_empty() + } else { + inspected_attached + }; + if has_attached_containers { + attached.push(record.name.clone()); + } + } + let (total, candidates) = classify_network_candidates(&records, &attached)?; + let mut candidate_ids = Vec::new(); + let mut identity_bindings = Vec::new(); + for candidate in candidates { + let id = candidate + .id + .as_deref() + .ok_or_else(|| "network-id-missing".to_string()) + .and_then(|id| normalize_hex_id(id, "network"))?; + let ownership = ownership_by_id + .get(&id) + .ok_or_else(|| "network-ownership-evidence-missing".to_string())?; + if ownership.explicitly_reclaimable { + candidate_ids.push(id); + identity_bindings.push(ownership.identity_binding.clone()); + } + } + let ids: Vec<&str> = identity_bindings.iter().map(String::as_str).collect(); + ( + Some(summarize_candidates(category, total, &ids, None)?), + candidate_ids, + ) + } + OrphanCategory::BuildCache => { + let (total, candidate_ids) = + parse_buildx_private_immutable_reclaimable_ids(&output)?; + let ids: Vec<&str> = candidate_ids.iter().map(String::as_str).collect(); + ( + Some(summarize_candidates(category, total, &ids, None)?), + candidate_ids, + ) + } + }; + let candidate_ids = if category == OrphanCategory::BuildCache { + bounded_build_cache_candidate_ids(candidate_ids)? + } else { + bounded_exact_candidate_ids(candidate_ids)? + }; + Ok((evidence, candidate_ids)) + })(); + match outcome { + Ok((evidence, candidate_ids)) => { + let has_candidates = evidence + .as_ref() + .is_some_and(|item| item.candidate_records > 0); + let approval_phrase = match (&evidence, has_candidates) { + (Some(item), true) => receipt_directory_sha256.map(|identity| { + approval_phrase(category, &item.candidate_set_sha256, identity) + }), + _ => None, + }; + OrphanCategoryPlan { + category, + evidence_complete: true, + issue: None, + evidence, + approval_phrase, + prune_command: Some(redacted_exact_delete_command( + &prefix, + category, + has_candidates, + )), + candidate_ids, + } + } + Err(issue) => build_issue_plan(issue), + } +} + +pub fn probe_container_orphans_with_receipt_dir( + target: &ContainerRuntimeTarget, + receipt_dir: &Path, +) -> ContainerOrphanPlan { + let started = Instant::now(); + let receipt_identity = private_receipt_directory_identity(receipt_dir) + .ok() + .map(|(_, identity)| identity); + let runtime = probe_runtime_health(target); + let categories: Vec = if runtime.healthy { + [ + OrphanCategory::Container, + OrphanCategory::Image, + OrphanCategory::Volume, + OrphanCategory::Network, + OrphanCategory::BuildCache, + ] + .into_iter() + .filter(|category| target.kind.is_docker() || *category != OrphanCategory::BuildCache) + .map(|category| audit_category(target, category, receipt_identity.as_deref())) + .collect() + } else { + Vec::new() + }; + let issues: Vec = categories + .iter() + .filter_map(|plan| { + plan.issue + .clone() + .map(|issue| format!("{}:{issue}", plan.category.as_str())) + }) + .chain(runtime.detail_issue.clone()) + .collect(); + ContainerOrphanPlan { + schema_kind: CONTAINER_ORPHAN_SCHEMA_KIND, + schema_version: CONTAINER_ORPHAN_SCHEMA_VERSION, + platform: std::env::consts::OS, + evidence_complete: runtime.healthy && categories.iter().all(|plan| plan.evidence_complete), + elapsed_ms: started.elapsed().as_millis() as u64, + runtime, + categories, + issues, + receipt_directory_sha256: receipt_identity, + } +} + +pub fn probe_container_orphans(target: &ContainerRuntimeTarget) -> ContainerOrphanPlan { + let mut plan = probe_container_orphans_with_receipt_dir(target, Path::new(".")); + plan.receipt_directory_sha256 = None; + for category in &mut plan.categories { + category.approval_phrase = None; + } + plan +} + +fn host_available_bytes(observed_at_ms: u64) -> Option { + std::env::current_dir() + .ok() + .and_then(|path| crate::volume_pressure::snapshot_volume(&path, observed_at_ms).ok()) + .map(|snapshot| snapshot.available_bytes) +} + +fn current_epoch_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +pub fn execute_container_orphan_prune( + target: &ContainerRuntimeTarget, + category: OrphanCategory, + confirmation_phrase: &str, + rationale: &str, + executed_at_ms: u64, + receipt_dir: &Path, +) -> Result { + if executed_at_ms == 0 { + return Err("orphan-prune-time-invalid".into()); + } + if rationale.trim().is_empty() + || rationale != rationale.trim() + || rationale.chars().count() > 1_000 + || rationale.chars().any(char::is_control) + { + return Err("orphan-prune-rationale-invalid".into()); + } + let prefix = target.command_prefix()?; + let (_, receipt_directory_sha256) = private_receipt_directory_identity(receipt_dir)?; + let plan = audit_category(target, category, Some(&receipt_directory_sha256)); + if !plan.evidence_complete { + return Err(format!( + "orphan-prune-evidence-incomplete:{}", + plan.issue.unwrap_or_else(|| "unknown".into()) + )); + } + let evidence = plan + .evidence + .as_ref() + .ok_or("orphan-prune-evidence-missing")?; + if evidence.candidate_records == 0 { + return Err("orphan-prune-empty-candidate-set".into()); + } + let candidate_count = usize::try_from(evidence.candidate_records) + .map_err(|_| "record-count-overflow".to_string())?; + if plan.candidate_ids.len() != candidate_count { + return Err("orphan-prune-candidate-set-internal-mismatch".into()); + } + let expected_phrase = approval_phrase( + category, + &evidence.candidate_set_sha256, + &receipt_directory_sha256, + ); + if confirmation_phrase != expected_phrase { + return Err("orphan-prune-confirmation-mismatch".into()); + } + + let before_available_bytes = host_available_bytes(executed_at_ms); + let mut owned_args: Vec = prefix.iter().skip(1).cloned().collect(); + if category == OrphanCategory::BuildCache { + let filter = build_cache_id_filter(&plan.candidate_ids)?; + owned_args.extend([ + "buildx".into(), + "prune".into(), + "--all".into(), + "--filter".into(), + filter, + "--force".into(), + ]); + } else { + owned_args.extend( + category + .exact_delete_subcommand() + .into_iter() + .map(str::to_string), + ); + owned_args.extend(plan.candidate_ids.iter().cloned()); + } + let args: Vec<&str> = owned_args.iter().map(String::as_str).collect(); + let label = format!("orphan-prune-{}", category.as_str()); + let output = mutation_capture_result( + command_capture(&target.binary_path, &args, ORPHAN_COMMAND_TIMEOUT, &label), + &label, + )?; + let after_observed_at_ms = current_epoch_ms().max(executed_at_ms); + let after_available_bytes = host_available_bytes(after_observed_at_ms); + let observed_available_gain_bytes = before_available_bytes + .zip(after_available_bytes) + .and_then(|(before, after)| after.checked_sub(before)); + let mut receipt = ContainerOrphanPruneExecution { + schema_version: CONTAINER_ORPHAN_SCHEMA_VERSION, + runtime_display_name: target.display_name(), + category, + candidate_set_sha256: evidence.candidate_set_sha256.clone(), + command: redacted_exact_delete_command(&prefix, category, true), + status_code: output.status_code, + stdout: output.stdout, + stderr: output.stderr, + output_truncated: output.output_truncated, + executed: true, + executed_at_ms, + before_available_bytes, + after_available_bytes, + observed_available_gain_bytes, + rationale: rationale.to_string(), + receipt_sha256: None, + receipt_recorded: false, + receipt_record_error: None, + }; + match write_execution_receipt(receipt_dir, &receipt) { + Ok(digest) => { + receipt.receipt_sha256 = Some(digest); + receipt.receipt_recorded = true; + } + Err(error) => receipt.receipt_record_error = Some(error), + } + Ok(receipt) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCKER_ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DOCKER_ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn scope_name_rejects_option_injection() { + assert!(!valid_scope_name("")); + assert!(!valid_scope_name("-flag")); + assert!(!valid_scope_name(".")); + assert!(!valid_scope_name("..")); + assert!(!valid_scope_name("has space")); + assert!(!valid_scope_name(&"x".repeat(129))); + assert!(valid_scope_name("colima")); + assert!(valid_scope_name("podman-machine-default")); + } + + #[test] + fn target_new_rejects_unsafe_scope() { + let error = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerColimaContext, + PathBuf::from("docker"), + Some("-evil".to_string()), + ) + .unwrap_err(); + assert_eq!(error, "unsafe-runtime-scope-name"); + } + + #[test] + fn docker_native_prefix_has_no_flags() { + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + PathBuf::from("docker"), + None, + ) + .unwrap(); + assert_eq!(target.command_prefix().unwrap(), vec!["docker".to_string()]); + assert_eq!(target.display_name(), "docker (docker-native)"); + } + + #[test] + fn colima_prefix_uses_context_flag_and_display_includes_scope() { + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerColimaContext, + PathBuf::from("/usr/local/bin/docker"), + Some("colima".to_string()), + ) + .unwrap(); + assert_eq!( + target.command_prefix().unwrap(), + vec![ + "/usr/local/bin/docker".to_string(), + "--context".to_string(), + "colima".to_string() + ] + ); + assert_eq!(target.display_name(), "docker colima"); + } + + #[test] + fn podman_prefix_uses_connection_flag() { + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::PodmanMachine, + PathBuf::from("podman"), + Some("podman-machine-default".to_string()), + ) + .unwrap(); + assert_eq!( + target.command_prefix().unwrap(), + vec![ + "podman".to_string(), + "--connection".to_string(), + "podman-machine-default".to_string() + ] + ); + } + + #[test] + fn scoped_kinds_require_a_scope_name() { + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::PodmanMachine, + PathBuf::from("podman"), + None, + ) + .unwrap(); + assert_eq!( + target.command_prefix().unwrap_err(), + "missing-scope-for-podman-machine" + ); + } + + #[test] + fn empty_binary_path_fails_closed() { + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + PathBuf::from(""), + None, + ) + .unwrap(); + assert_eq!( + target.command_prefix().unwrap_err(), + "unsafe-runtime-binary-path" + ); + } + + #[test] + fn envelopes_accept_array_and_ndjson() { + let array = r#"[{"ID":"a"},{"ID":"b"}]"#; + assert_eq!(split_json_envelopes(array).unwrap().len(), 2); + let ndjson = "{\"ID\":\"a\"}\n{\"ID\":\"b\"}\n"; + assert_eq!(split_json_envelopes(ndjson).unwrap().len(), 2); + assert!(split_json_envelopes("").unwrap().is_empty()); + assert!(split_json_envelopes(" \n\t").unwrap().is_empty()); + assert!(split_json_envelopes("{\"ID\":\"a\"\n{oops}") + .unwrap_err() + .starts_with("invalid-json-record:")); + } + + #[test] + fn docker_container_records_parse_from_ndjson() { + let output = format!( + "{{\"Command\":\"sleep\",\"CreatedAt\":\"now\",\"ID\":\"sha256:{DOCKER_ID_A}\",\"Image\":\"img\",\"Names\":[\"web\"],\"State\":\"exited\"}}\n{{\"Command\":\"top\",\"ID\":\"{DOCKER_ID_B}\",\"State\":\"running\",\"Names\":[\"db\"]}}\n" + ); + let records = parse_container_records(&output).unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].id, DOCKER_ID_A); + assert_eq!(records[0].state, "exited"); + assert_eq!(records[0].names, vec!["web"]); + let (total, candidates) = classify_container_candidates(&records).unwrap(); + assert_eq!(total, 2); + assert_eq!(candidates.len(), 1); + } + + #[test] + fn docker_container_plain_name_is_one_name() { + let output = format!("{{\"ID\":\"{DOCKER_ID_A}\",\"State\":\"exited\",\"Names\":\"web\"}}"); + assert_eq!(parse_container_records(&output).unwrap()[0].names, ["web"]); + } + + #[test] + fn podman_container_records_parse_from_array_with_encoded_names() { + let output = format!( + "[{{\"Id\":\"{DOCKER_ID_A}\",\"State\":\"created\",\"Names\":\"[\\\"worker\\\"]\"}},{{\"Id\":\"{DOCKER_ID_B}\",\"State\":\"paused\",\"Names\":[\"x\"]}}]" + ); + let records = parse_container_records(&output).unwrap(); + assert_eq!(records[0].names, vec!["worker"]); + let (_, candidates) = classify_container_candidates(&records).unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].state, "created"); + } + + #[test] + fn unknown_container_state_fails_closed() { + let output = format!("{{\"ID\":\"{DOCKER_ID_A}\",\"State\":\"zombie\",\"Names\":[]}}"); + let error = + classify_container_candidates(&parse_container_records(&output).unwrap()).unwrap_err(); + assert_eq!(error, "unknown-container-state:zombie"); + } + + #[test] + fn invalid_container_id_fails_closed() { + let output = "{\"ID\":\"short\",\"State\":\"exited\",\"Names\":[]}"; + assert_eq!( + parse_container_records(output).unwrap_err(), + "container-invalid-id" + ); + } + + #[test] + fn uppercase_container_id_fails_closed() { + let output = + "{\"ID\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"State\":\"exited\",\"Names\":[]}"; + assert_eq!( + parse_container_records(output).unwrap_err(), + "container-invalid-id" + ); + } + + #[test] + fn malformed_container_names_fail_closed() { + let output = format!("{{\"ID\":\"{DOCKER_ID_A}\",\"State\":\"exited\",\"Names\":5}}"); + assert_eq!( + parse_container_records(&output).unwrap_err(), + "container-names-invalid" + ); + } + + #[test] + fn record_count_bound_is_enforced_for_containers() { + let mut output = String::new(); + for index in 0..(MAX_CATEGORY_RECORDS + 1) { + output.push_str(&format!( + "{{\"ID\":\"{index:064x}\",\"State\":\"exited\",\"Names\":[]}}\n" + )); + } + assert_eq!( + parse_container_records(&output).unwrap_err(), + "record-count-exceeds-bound" + ); + } + + #[test] + fn docker_image_records_coerce_string_numbers_and_fail_on_negative() { + let ok = format!( + "{{\"Containers\":\"0\",\"ID\":\"sha256:{DOCKER_ID_A}\",\"RepoTags\":[],\"RepoDigests\":[\"a@sha256:x\"],\"Size\":\"123\"}}\n{{\"Containers\":\"-1\",\"ID\":\"{DOCKER_ID_B}\",\"RepoTags\":[],\"RepoDigests\":[],\"Size\":\"5\"}}" + ); + let error = parse_image_records(&ok).unwrap_err(); + assert_eq!(error, "json-field-invalid:Containers"); + } + + #[test] + fn docker_image_records_bind_only_full_ids() { + let documented = format!( + "{{\"Containers\":\"N/A\",\"ID\":\"{DOCKER_ID_A}\",\"Repository\":\"\",\"Size\":\"72.9MB\",\"Tag\":\"\"}}" + ); + assert_eq!( + parse_docker_image_ids(&documented).unwrap(), + vec![DOCKER_ID_A.to_string()] + ); + assert_eq!( + parse_docker_image_ids("{\"ID\":\"a762a2b37a1d\"}").unwrap_err(), + "image-invalid-id" + ); + } + + #[test] + fn docker_image_size_parser_accepts_only_numeric_inspect_sizes() { + let output = format!( + "{{\"Id\":\"sha256:{DOCKER_ID_A}\",\"Size\":72900000}}\n{{\"ID\":\"{DOCKER_ID_B}\",\"Size\":5}}" + ); + let sizes = parse_docker_image_sizes(&output).unwrap(); + assert_eq!(sizes.get(DOCKER_ID_A), Some(&72_900_000)); + assert_eq!(sizes.get(DOCKER_ID_B), Some(&5)); + let missing = format!("{{\"Id\":\"{DOCKER_ID_A}\"}}"); + assert_eq!( + parse_docker_image_sizes(&missing).unwrap_err(), + "json-field-invalid-or-missing:Size" + ); + let human = format!("{{\"Id\":\"{DOCKER_ID_A}\",\"Size\":\"72.9MB\"}}"); + assert_eq!( + parse_docker_image_sizes(&human).unwrap_err(), + "json-field-invalid-or-missing:Size" + ); + let duplicate = format!( + "{{\"Id\":\"{DOCKER_ID_A}\",\"Size\":1}}\n{{\"Id\":\"{DOCKER_ID_A}\",\"Size\":2}}" + ); + assert_eq!( + parse_docker_image_sizes(&duplicate).unwrap_err(), + "duplicate-image-id" + ); + } + + #[test] + fn image_orphans_require_zero_references_and_no_tags() { + let referenced = format!( + "{{\"Containers\":\"2\",\"ID\":\"{DOCKER_ID_A}\",\"RepoTags\":[\"img:latest\"],\"RepoDigests\":[],\"Size\":\"100\"}}" + ); + let tagged_unused = format!( + "{{\"Containers\":\"0\",\"ID\":\"{DOCKER_ID_A}\",\"RepoTags\":[\"img:v2\"],\"RepoDigests\":[],\"Size\":\"100\"}}" + ); + let dangling = format!( + "{{\"Containers\":0,\"ID\":\"{DOCKER_ID_A}\",\"RepoTags\":null,\"RepoDigests\":[],\"Size\":100}}" + ); + let orphan_count = |text: &str| { + let records = parse_image_records(text).unwrap(); + classify_image_candidates(&records).unwrap().1.len() + }; + assert_eq!(orphan_count(&referenced), 0); + assert_eq!(orphan_count(&tagged_unused), 0); + assert_eq!(orphan_count(&dangling), 1); + } + + #[test] + fn missing_container_reference_count_fails_closed_per_record() { + let output = + format!("{{\"ID\":\"{DOCKER_ID_A}\",\"RepoTags\":[],\"RepoDigests\":[],\"Size\":10}}"); + let records = parse_image_records(&output).unwrap(); + assert_eq!( + classify_image_candidates(&records).unwrap_err(), + format!("image-reference-count-unavailable:{}", &DOCKER_ID_A[..8]) + ); + } + + #[test] + fn missing_size_field_fails_closed() { + let output = format!("{{\"ID\":\"{DOCKER_ID_A}\",\"Containers\":0}}"); + assert_eq!( + parse_image_records(&output).unwrap_err(), + "json-field-missing:Size" + ); + } + + #[test] + fn invalid_image_id_fails_closed() { + let output = format!("{{\"ID\":\"zzzz\",\"Containers\":0,\"Size\":1}}"); + assert_eq!( + parse_image_records(&output).unwrap_err(), + "image-invalid-id" + ); + } + + #[test] + fn volume_names_parse_from_both_envelopes() { + let ndjson = "{\"Availability\":\"active\",\"Driver\":\"local\",\"Name\":\"data-vol\"}\n"; + assert_eq!(parse_volume_records(ndjson).unwrap()[0].name, "data-vol"); + let array = "[{\"name\":\"cache-vol\",\"driver\":\"local\"}]"; + assert_eq!(parse_volume_records(array).unwrap()[0].name, "cache-vol"); + } + + #[test] + fn volume_ownership_requires_explicit_disksage_labels_and_rejects_compose() { + let owned = r#"[{"Name":"cache-vol","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}]"#; + let evidence = parse_volume_ownership(owned, "cache-vol").unwrap(); + assert!(evidence.explicitly_reclaimable); + assert_eq!(evidence.identity_binding.len(), 64); + + let compose = r#"[{"Name":"cache-vol","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true","com.docker.compose.project":"app"}}]"#; + assert!( + !parse_volume_ownership(compose, "cache-vol") + .unwrap() + .explicitly_reclaimable + ); + + let unlabeled = r#"[{"Name":"cache-vol","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":null}]"#; + assert!( + !parse_volume_ownership(unlabeled, "cache-vol") + .unwrap() + .explicitly_reclaimable + ); + + let business = r#"[{"Name":"cache-vol","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true","io.contextualwisdomlab.disksage.business-data":"true"}}]"#; + assert!( + !parse_volume_ownership(business, "cache-vol") + .unwrap() + .explicitly_reclaimable + ); + } + + #[test] + fn container_ownership_requires_labels_stopped_state_and_exact_identity() { + let owned = format!( + r#"[{{"Id":"{DOCKER_ID_A}","Created":"2026-08-30T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]"# + ); + assert!( + parse_container_ownership(&owned, DOCKER_ID_A) + .unwrap() + .explicitly_reclaimable + ); + assert_eq!( + parse_container_ownership(&owned.replace("exited", "running"), DOCKER_ID_A) + .unwrap_err(), + "container-inspect-not-stopped" + ); + let compose = owned.replace( + "\"io.contextualwisdomlab.disksage.owner\"", + "\"com.docker.compose.project\":\"app\",\"io.contextualwisdomlab.disksage.owner\"", + ); + assert!( + !parse_container_ownership(&compose, DOCKER_ID_A) + .unwrap() + .explicitly_reclaimable + ); + assert_eq!( + parse_container_ownership(&owned, DOCKER_ID_B).unwrap_err(), + "container-inspect-id-mismatch" + ); + } + + #[test] + fn network_ownership_requires_labels_and_no_compose_namespace() { + let owned = format!( + r#"[{{"Id":"{DOCKER_ID_A}","Name":"cache-net","Driver":"bridge","Containers":{{}},"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}]"# + ); + let (evidence, attached) = parse_network_ownership(&owned, DOCKER_ID_A, true).unwrap(); + assert!(evidence.explicitly_reclaimable); + assert!(!attached); + let compose = owned.replace( + "\"io.contextualwisdomlab.disksage.owner\"", + "\"com.docker.compose.network\":\"default\",\"io.contextualwisdomlab.disksage.owner\"", + ); + assert!( + !parse_network_ownership(&compose, DOCKER_ID_A, true) + .unwrap() + .0 + .explicitly_reclaimable + ); + } + + #[test] + fn volume_ownership_fails_closed_without_complete_identity_or_labels() { + assert_eq!( + parse_volume_ownership( + r#"[{"Name":"cache-vol","Driver":"local","Labels":{}}]"#, + "cache-vol" + ) + .unwrap_err(), + "json-field-missing:CreatedAt" + ); + assert_eq!( + parse_volume_ownership( + r#"[{"Name":"other","Driver":"local","CreatedAt":"now","Labels":{}}]"#, + "cache-vol" + ) + .unwrap_err(), + "volume-inspect-name-mismatch" + ); + } + + #[test] + fn unsafe_volume_names_fail_closed() { + let overlong = format!("{{\"Name\":\"{}\"}}", "v".repeat(201)); + assert_eq!( + parse_volume_records(&overlong).unwrap_err(), + "volume-invalid-name" + ); + assert_eq!( + parse_volume_records("{\"Name\":\"\"}").unwrap_err(), + "volume-invalid-name" + ); + } + + #[test] + fn network_records_parse_docker_casing_and_podman_casing() { + let docker = format!( + "[{{\"Driver\":\"bridge\",\"ID\":\"net-id-1\",\"Name\":\"app-net\"}},{{\"Driver\":\"host\",\"ID\":\"h\",\"Name\":\"host\"}}]" + ); + let records = parse_network_records(&docker).unwrap(); + assert_eq!(records.len(), 2); + let attached: Vec = Vec::new(); + let (total, candidates) = classify_network_candidates(&records, &attached).unwrap(); + assert_eq!(total, 2); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].name, "app-net"); + + let podman = "[{\"driver\":\"bridge\",\"id\":\"p1\",\"name\":\"podman\"},{\"driver\":\"bridge\",\"id\":\"p2\",\"name\":\"custom-net\"}]"; + let records = parse_network_records(podman).unwrap(); + let (_, candidates) = classify_network_candidates(&records, &attached).unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].name, "custom-net"); + } + + #[test] + fn attached_networks_are_not_candidates() { + let docker = format!("[{{\"Driver\":\"bridge\",\"ID\":\"1\",\"Name\":\"used-net\"}}]"); + let records = parse_network_records(&docker).unwrap(); + let attached = vec!["used-net".to_string()]; + let (_, candidates) = classify_network_candidates(&records, &attached).unwrap(); + assert!(candidates.is_empty()); + } + + #[test] + fn network_attached_container_detection_covers_shapes() { + let docker_empty = r#"[{"Containers":{},"Name":"x"}]"#; + assert!(!network_has_attached_containers(docker_empty).unwrap()); + let docker_full = + format!(r#"[{{"Containers":{{"endpoint-1":{{"Name":"web"}}}},"Name":"x"}}]"#); + assert!(network_has_attached_containers(&docker_full).unwrap()); + let podman_empty = r#"{"containers":[],"name":"y"}"#; + assert!(!network_has_attached_containers(podman_empty).unwrap()); + let podman_full = r#"{"containers":["c1"],"name":"y"}"#; + assert!(network_has_attached_containers(podman_full).unwrap()); + let null_containers = r#"{"containers":null}"#; + assert!(!network_has_attached_containers(null_containers).unwrap()); + } + + #[test] + fn network_inspect_failures_are_typed() { + assert!(network_has_attached_containers("not json") + .unwrap_err() + .starts_with("invalid-network-inspect-json:")); + assert_eq!( + network_has_attached_containers("[]").unwrap_err(), + "network-inspect-empty" + ); + assert_eq!( + network_has_attached_containers("{\"Name\":\"x\"}").unwrap_err(), + "network-inspect-containers-missing" + ); + assert_eq!( + network_has_attached_containers("{\"Containers\":true}").unwrap_err(), + "network-inspect-containers-invalid" + ); + assert_eq!( + network_has_attached_containers("42").unwrap_err(), + "invalid-network-inspect-shape" + ); + } + + #[test] + fn unsafe_network_names_fail_closed() { + let overlong = format!( + "[{{\"driver\":\"bridge\",\"id\":\"1\",\"name\":\"{}\"}}]", + "n".repeat(201) + ); + assert_eq!( + parse_network_records(&overlong).unwrap_err(), + "network-invalid-name" + ); + assert_eq!( + parse_network_records("[{\"driver\":\"bridge\",\"name\":\"-danger\"}]").unwrap_err(), + "network-invalid-name" + ); + } + + #[test] + fn fingerprint_binds_sorted_identity_set_and_domain() { + let ids = vec![DOCKER_ID_B, DOCKER_ID_A]; + let first = candidate_fingerprint("domain-a", &ids); + let reordered = candidate_fingerprint("domain-a", &[DOCKER_ID_A, DOCKER_ID_B]); + assert_eq!(first, reordered); + let other_domain = candidate_fingerprint("domain-b", &ids); + assert_ne!(first, other_domain); + let other_set = candidate_fingerprint("domain-a", &[DOCKER_ID_A, DOCKER_ID_A]); + assert_ne!(first, other_set); + assert_eq!(first.len(), 64); + } + + #[test] + fn approval_phrases_embed_category_and_fingerprint() { + let phrase = approval_phrase(OrphanCategory::Volume, "abc123", "dir456"); + assert_eq!( + phrase, + "DiskSage volume orphan prune 승인 abc123 receipt dir456" + ); + } + + #[cfg(unix)] + fn test_execution(status_code: i32) -> ContainerOrphanPruneExecution { + ContainerOrphanPruneExecution { + schema_version: 1, + runtime_display_name: "runtime".into(), + category: OrphanCategory::Container, + candidate_set_sha256: "a".repeat(64), + command: vec!["container".into(), "rm".into(), "".into()], + status_code, + stdout: String::new(), + stderr: (status_code != 0) + .then(|| INDETERMINATE_MUTATION_OUTCOME.to_string()) + .unwrap_or_default(), + output_truncated: false, + executed: true, + executed_at_ms: 42, + before_available_bytes: None, + after_available_bytes: None, + observed_available_gain_bytes: None, + rationale: "reviewed exact candidates".into(), + receipt_sha256: None, + receipt_recorded: false, + receipt_record_error: None, + } + } + + #[cfg(unix)] + #[test] + fn immutable_receipt_rejects_duplicate_and_detects_tamper_and_symlink_directory() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let receipt = test_execution(0); + let digest = write_execution_receipt(dir.path(), &receipt).unwrap(); + assert_eq!( + write_execution_receipt(dir.path(), &receipt).unwrap_err(), + "orphan-receipt-create-failed" + ); + let path = dir.path().join(format!( + "42-container-{}.json", + receipt.candidate_set_sha256 + )); + assert_eq!(read_execution_receipt(&path, &digest).unwrap(), receipt); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::write(&path, b"{}").unwrap(); + assert_eq!( + read_execution_receipt(&path, &digest).unwrap_err(), + "orphan-receipt-digest-mismatch" + ); + let parent = tempfile::tempdir().unwrap(); + let link = parent.path().join("receipts"); + symlink(dir.path(), &link).unwrap(); + assert_eq!( + private_receipt_directory_identity(&link).unwrap_err(), + "orphan-receipt-directory-unsafe" + ); + } + + #[cfg(unix)] + #[test] + fn nonzero_partial_outcome_is_persisted_before_return() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let receipt = test_execution(-1); + let digest = write_execution_receipt(dir.path(), &receipt).unwrap(); + let path = dir.path().join(format!( + "42-container-{}.json", + receipt.candidate_set_sha256 + )); + let persisted = read_execution_receipt(&path, &digest).unwrap(); + assert_eq!(persisted.status_code, -1); + assert_eq!(persisted.stderr, INDETERMINATE_MUTATION_OUTCOME); + assert!(persisted.executed); + } + + #[cfg(unix)] + #[test] + fn timeout_terminates_descendants_that_hold_capture_pipes() { + let started = Instant::now(); + let result = command_capture( + Path::new("/bin/sh"), + &["-c", "sleep 30 & wait"], + Duration::from_millis(100), + "descendant-timeout", + ); + + assert_eq!(result.unwrap_err(), "descendant-timeout-timeout"); + assert!(started.elapsed() < Duration::from_secs(2)); + + let receipt = mutation_capture_result( + Err("descendant-timeout-timeout".into()), + "descendant-timeout", + ) + .unwrap(); + assert_eq!(receipt.status_code, -1); + assert_eq!(receipt.stderr, INDETERMINATE_MUTATION_OUTCOME); + assert!(mutation_capture_result( + Err("descendant-timeout-spawn:unavailable".into()), + "descendant-timeout", + ) + .is_err()); + } + + #[test] + fn summarize_candidates_sorts_and_detects_duplicates() { + let evidence = summarize_candidates( + OrphanCategory::Container, + 3, + &[DOCKER_ID_B, DOCKER_ID_A], + Some(7), + ) + .unwrap(); + assert_eq!(evidence.total_records, 3); + assert_eq!(evidence.candidate_records, 2); + assert_eq!(evidence.candidate_size_sum_bytes, Some(7)); + let duplicate = summarize_candidates( + OrphanCategory::Container, + 2, + &[DOCKER_ID_A, DOCKER_ID_A], + None, + ) + .unwrap_err(); + assert_eq!(duplicate, "duplicate-candidate-id"); + } + + #[test] + fn exact_delete_candidate_bound_is_enforced() { + let ids: Vec = (0..=MAX_EXACT_DELETE_CANDIDATES) + .map(|index| format!("{index:064x}")) + .collect(); + assert_eq!( + bounded_exact_candidate_ids(ids).unwrap_err(), + "exact-delete-candidate-count-exceeds-bound" + ); + } + + #[test] + fn build_cache_candidates_are_bounded_by_filter_bytes() { + let ids: Vec = (0..260).map(|index| format!("cache{index:04}")).collect(); + assert_eq!(bounded_build_cache_candidate_ids(ids).unwrap().len(), 260); + let oversized: Vec = (0..MAX_CATEGORY_RECORDS) + .map(|index| format!("{index:0128}")) + .collect(); + assert_eq!( + bounded_build_cache_candidate_ids(oversized).unwrap_err(), + "build-cache-filter-exceeds-bound" + ); + } + + #[test] + fn build_cache_filter_is_anchored_to_reviewed_ids() { + assert_eq!( + build_cache_id_filter(&["abc123".into(), "def456".into()]).unwrap(), + "id~=^(abc123|def456)$" + ); + assert_eq!( + build_cache_id_filter(&[]).unwrap_err(), + "orphan-prune-empty-candidate-set" + ); + assert_eq!( + build_cache_id_filter(&["abc.*".into()]).unwrap_err(), + "build-cache-id-invalid" + ); + } + + #[test] + fn category_metadata_is_stable() { + assert_eq!(OrphanCategory::Container.as_str(), "container"); + assert_eq!(OrphanCategory::Image.as_str(), "image"); + assert_eq!(OrphanCategory::Volume.as_str(), "volume"); + assert_eq!(OrphanCategory::Network.as_str(), "network"); + assert_eq!(OrphanCategory::BuildCache.as_str(), "build_cache"); + assert_eq!( + OrphanCategory::Container.exact_delete_subcommand(), + ["container", "rm"] + ); + assert_eq!( + OrphanCategory::Image.exact_delete_subcommand(), + ["image", "rm"] + ); + assert_eq!( + OrphanCategory::Volume.exact_delete_subcommand(), + ["volume", "rm"] + ); + assert_eq!( + OrphanCategory::Network.exact_delete_subcommand(), + ["network", "rm"] + ); + assert_eq!( + serde_json::to_value(OrphanCategory::Container).unwrap(), + serde_json::json!("container") + ); + assert_eq!( + ContainerRuntimeKind::PodmanMachine.as_str(), + "podman-machine" + ); + } + + #[test] + fn buildx_inventory_preserves_active_shared_mutable_and_cache_mount_records() { + let output = concat!( + r#"{"ID":"abc123","Reclaimable":true,"Shared":false,"Mutable":false,"Type":"regular"}"#, + "\n", + r#"{"ID":"kept456","Reclaimable":false}"#, + "\n", + r#"{"ID":"shared789","Reclaimable":true,"Shared":true,"Mutable":false,"Type":"regular"}"#, + "\n", + r#"{"ID":"mutable012","Reclaimable":true,"Shared":false,"Mutable":true,"Type":"regular"}"#, + "\n", + r#"{"ID":"mount345","Reclaimable":true,"Shared":false,"Mutable":false,"Type":"exec.cachemount"}"#, + ); + let (total, ids) = parse_buildx_private_immutable_reclaimable_ids(output).unwrap(); + assert_eq!(total, 5); + assert_eq!(ids, vec!["abc123"]); + + for (output, issue) in [ + ( + r#"{"ID":"abc123","Reclaimable":true,"Mutable":false,"Type":"regular"}"#, + "build-cache-shared-missing", + ), + ( + r#"{"ID":"abc123","Reclaimable":true,"Shared":false,"Type":"regular"}"#, + "build-cache-mutable-missing", + ), + ( + r#"{"ID":"abc123","Reclaimable":true,"Shared":false,"Mutable":false}"#, + "build-cache-type-missing", + ), + ] { + assert_eq!( + parse_buildx_private_immutable_reclaimable_ids(output).unwrap_err(), + issue + ); + } + } +} diff --git a/src-tauri/src/dev_artifacts.rs b/src-tauri/src/dev_artifacts.rs index 56a034654..254023eea 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -8,6 +8,14 @@ use crate::scanner; // 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; +const VSCODE_OBSOLETE_METADATA_MAX_BYTES: u64 = 1024 * 1024; +// Reversible Trash cleanup backs an interactive path, so an incomplete active-use probe must fail +// closed without inheriting the longer latency budget reserved for irreversible deletion. +const ARTIFACT_REVERSIBLE_ACTIVE_USE_TIMEOUT_MS: u64 = crate::reclaim::ACTIVE_USE_PROBE_TIMEOUT_MS; +// Recursive lsof must enumerate the artifact tree. Real Python environments exceeded the generic +// 2-second probe while completing in roughly 3 seconds, so the irreversible boundary owns a +// longer operational timeout instead of silently weakening the active-use gate. +const ARTIFACT_PERMANENT_ACTIVE_USE_TIMEOUT_MS: u64 = 30_000; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DevArtifact { @@ -36,21 +44,81 @@ pub struct DevArtifactCleanResult { /// (아티팩트 디렉토리명, 같은 부모에 있어야 하는 프로젝트 마커들) const ARTIFACT_KINDS: &[(&str, &[&str])] = &[ ("node_modules", &["package.json"]), + (".next", &["package.json"]), + ("dist-electron", &["package.json"]), ("target", &["Cargo.toml"]), (".venv", &["pyproject.toml", "requirements.txt", "setup.py"]), + (".venv314", &["pyproject.toml", "requirements.txt", "setup.py", ".git"]), ("venv", &["pyproject.toml", "requirements.txt", "setup.py"]), ("__pycache__", &[]), // 마커 불필요 — 이름 자체가 파이썬 캐시 + (".mypy_cache", &[]), + (".pytest_cache", &[]), + (".ruff_cache", &[]), + (".tox", &["pyproject.toml", "tox.ini", "setup.cfg"]), + (".nox", &["pyproject.toml", "noxfile.py"]), (".codegraph", &[]), // 재생성 가능한 CodeGraph 인덱스 ]; +fn marker_exists(parent: &Path, artifact_name: &str, marker: &str) -> bool { + let path = parent.join(marker); + if artifact_name != ".tox" || marker != "setup.cfg" { + return path.exists(); + } + std::fs::metadata(&path).is_ok_and(|metadata| metadata.is_file() && metadata.len() <= 1_048_576) + && std::fs::read_to_string(path).is_ok_and(|text| { + text.lines() + .any(|line| line.trim().eq_ignore_ascii_case("[tox:tox]")) + }) +} + +fn is_python_314_environment(path: &Path) -> bool { + let config = path.join("pyvenv.cfg"); + std::fs::metadata(&config) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() <= 65_536) + && std::fs::read_to_string(config).is_ok_and(|text| { + text.lines().any(|line| { + line.split_once('=').is_some_and(|(key, value)| { + let key = key.trim(); + (key.eq_ignore_ascii_case("version") + || key.eq_ignore_ascii_case("version_info")) + && value + .trim() + .strip_prefix("3.14") + .is_some_and(|rest| rest.is_empty() || rest.starts_with('.')) + }) + }) + }) +} + fn artifact_kind(name: &str) -> Option<&'static (&'static str, &'static [&'static str])> { ARTIFACT_KINDS.iter().find(|(k, _)| *k == name) } +fn cargo_target_cache(path: &Path) -> bool { + let tag_path = path.join("CACHEDIR.TAG"); + let tagged = std::fs::metadata(&tag_path) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() <= 65_536) + && std::fs::read_to_string(tag_path).is_ok_and(|tag| { + tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55\n") + && tag.contains("cache directory tag created by cargo") + }) + && path.join(".rustc_info.json").is_file(); + tagged && path.join("debug").is_dir() +} + +fn detected_artifact_kind(path: &Path, name: &str) -> Option<(&'static str, &'static [&'static str])> { + if cargo_target_cache(path) { + return Some(("cargo-target-cache", &[])); + } + artifact_kind(name).map(|(kind, markers)| (*kind, *markers)) +} + 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 } @@ -142,14 +210,17 @@ fn artifact_manifest(root: &Path) -> ArtifactManifest { }); manifest.bytes = manifest.bytes.saturating_add(metadata.len()); manifest.files = manifest.files.saturating_add(1); - manifest - .records - .push(format!("F\0{relative}\0{identity}\0{}\0{modified}", metadata.len())); + manifest.records.push(format!( + "F\0{relative}\0{identity}\0{}\0{modified}", + metadata.len() + )); } } if !manifest.scan_complete { - manifest.records.push("!incomplete\0bounded-artifact-manifest".into()); + manifest + .records + .push("!incomplete\0bounded-artifact-manifest".into()); } manifest.records.sort_unstable(); manifest.fingerprint = metadata_fingerprint(&manifest.records); @@ -157,8 +228,16 @@ fn artifact_manifest(root: &Path) -> ArtifactManifest { } 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())) + 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 { @@ -170,61 +249,171 @@ fn metadata_fingerprint(records: &[String]) -> String { hasher.finalize().to_hex().to_string() } +fn editor_product(root_name: &str) -> Option<&'static str> { + match root_name { + ".vscode" => Some("Visual Studio Code"), + ".vscode-insiders" => Some("Visual Studio Code Insiders"), + ".vscode-server" => Some("Visual Studio Code Server"), + ".cursor" => Some("Cursor"), + _ => None, + } +} + +fn editor_product_for_extensions_dir(extensions: &Path) -> Option<&'static str> { + if extensions.file_name().and_then(|name| name.to_str()) != Some("extensions") { + return None; + } + let parent = extensions.parent()?; + let editor_root = if parent.file_name().and_then(|name| name.to_str()) == Some("data") { + parent.parent()? + } else { + parent + }; + editor_root + .file_name() + .and_then(|name| name.to_str()) + .and_then(editor_product) +} + +fn is_editor_extension_directory(path: &Path) -> bool { + path.parent() + .and_then(editor_product_for_extensions_dir) + .is_some() +} + +/// Return extension directories that VS Code itself marked obsolete. +/// +/// `.obsolete` is native lifecycle authority, so no version-age heuristic is needed. Only a real +/// metadata file at `.vscode/extensions/.obsolete` and single-component real child directories are +/// accepted. +fn vscode_obsolete_extension_paths(metadata_path: &Path) -> Vec<(PathBuf, &'static str)> { + let mut paths = Vec::new(); + let Some(extensions) = metadata_path.parent() else { + return paths; + }; + let Some(product) = editor_product_for_extensions_dir(extensions) else { + return paths; + }; + let Ok(metadata) = std::fs::symlink_metadata(metadata_path) else { + return paths; + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > VSCODE_OBSOLETE_METADATA_MAX_BYTES + { + return paths; + } + let Ok(bytes) = std::fs::read(metadata_path) else { + return paths; + }; + let Ok(document) = serde_json::from_slice::(&bytes) else { + return paths; + }; + let Some(names) = document.as_object() else { + return paths; + }; + for (name, obsolete) in names { + if obsolete.as_bool() != Some(true) { + continue; + } + let mut components = Path::new(name).components(); + let Some(std::path::Component::Normal(component)) = components.next() else { + continue; + }; + if components.next().is_some() || component.is_empty() { + continue; + } + let candidate = extensions.join(component); + let Ok(candidate_metadata) = std::fs::symlink_metadata(&candidate) else { + continue; + }; + if candidate_metadata.is_dir() && !candidate_metadata.file_type().is_symlink() { + paths.push((candidate, product)); + } + } + paths.sort(); + paths.dedup(); + paths +} + /// 마커 인접 아티팩트 디렉토리를 찾아 mtime 나이로 걸러 크기 내림차순으로 반환. /// -/// 2패스로 나눈 이유: 순회 백엔드의 방문 순서에 의존하지 않고 부모/자식 관계를 -/// 보장하지 않는다. 그래서 "이미 찾은 아티팩트의 하위는 건너뛴다" 식으로 순회 -/// 도중 걸러내면, 중첩 node_modules의 자식이 부모보다 먼저 방문될 경우 둘 다 -/// 별도 항목으로 남는다. 1패스에서는 마커 인접 검증까지만 마친 후보 경로를 전부 -/// 모으고(순서 무관), 2패스에서 다른 후보의 하위 경로인 것을 제거한 뒤에야 크기를 -/// 계산해 중첩분을 이중 계산하지 않는다. +/// WalkDir의 부모 우선 순회를 이용해 검증된 아티팩트 아래는 즉시 건너뛴다. 생성물 +/// 내부의 중첩 `node_modules`까지 다시 훑지 않으므로 큰 개발 트리에서도 같은 바이트를 +/// 탐색 단계와 manifest 단계에서 두 번 읽지 않는다. pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec { let mut candidates: Vec = Vec::new(); - let walker = walkdir::WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_entry(|entry| { - // 심링크/reparse point 제외 — scanner의 순회 전반 패턴과 동일 - entry.depth() == 0 || scanner::keep_entry(entry) - }); + let mut obsolete_extensions = Vec::new(); + let mut walker = walkdir::WalkDir::new(root).follow_links(false).into_iter(); - for entry in walker { + while let Some(entry) = walker.next() { let Ok(e) = entry else { continue }; + if crate::safety::is_explicitly_protected(e.path()) { + if e.file_type().is_dir() { + walker.skip_current_dir(); + } + continue; + } + if e.depth() > 0 && !scanner::keep_entry(&e) { + if e.file_type().is_dir() { + walker.skip_current_dir(); + } + continue; + } + if e.file_type().is_file() && e.file_name() == ".obsolete" { + obsolete_extensions.extend(vscode_obsolete_extension_paths(e.path())); + continue; + } if !e.file_type().is_dir() { continue; } let path = e.path(); - let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue }; - let Some((_, markers)) = artifact_kind(&name) else { continue }; + if is_editor_extension_directory(path) { + walker.skip_current_dir(); + continue; + } + let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else { + continue; + }; + let Some((_, markers)) = detected_artifact_kind(path, &name) else { + continue; + }; let parent = path.parent().unwrap_or(root); - let marker_ok = markers.is_empty() || markers.iter().any(|m| parent.join(m).exists()); + let marker_ok = markers.is_empty() + || markers + .iter() + .any(|marker| marker_exists(parent, &name, marker)); + if name == ".venv314" && (!marker_ok || !is_python_314_environment(path)) { + walker.skip_current_dir(); + continue; + } if marker_ok { candidates.push(path.to_path_buf()); + walker.skip_current_dir(); } } - // 다른 후보의 하위 경로(중첩 아티팩트)는 제거 — 방문 순서에 의존하지 않는 비교 - let top_level: Vec<&Path> = candidates + obsolete_extensions.sort(); + obsolete_extensions.dedup(); + let mut found: Vec = candidates .iter() - .enumerate() - .filter(|(i, p)| { - !candidates + .map(PathBuf::as_path) + .filter(|path| { + !obsolete_extensions .iter() - .enumerate() - .any(|(j, other)| *i != j && p.starts_with(other)) + .any(|(obsolete, _)| path.starts_with(obsolete)) }) - .map(|(_, p)| p.as_path()) - .collect(); - - let mut found: 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 (kind, _) = detected_artifact_kind(path, &name)?; let parent = path.parent().unwrap_or(root); let manifest = artifact_manifest(path); Some(DevArtifact { @@ -245,6 +434,34 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec { + clean_artifacts_with_disposition(requests, root, min_age_days, journal_path, now_ms, false) +} + +/// Permanently delete only unchanged, inactive development artifacts after an explicit caller +/// approval. This provides physical reclaim without requiring a global Trash-empty operation. +pub fn permanently_delete_artifacts( + requests: &[DevArtifact], + root: &Path, + min_age_days: u64, + journal_path: &Path, + now_ms: u64, +) -> Vec { + clean_artifacts_with_disposition(requests, root, min_age_days, journal_path, now_ms, true) +} + +fn artifact_active_use_timeout_ms(permanent: bool) -> u64 { + if permanent { + ARTIFACT_PERMANENT_ACTIVE_USE_TIMEOUT_MS + } else { + ARTIFACT_REVERSIBLE_ACTIVE_USE_TIMEOUT_MS + } +} + +fn clean_artifacts_with_disposition( + requests: &[DevArtifact], + root: &Path, + min_age_days: u64, + journal_path: &Path, + now_ms: u64, + permanent: bool, ) -> Vec { let current = find_artifacts(root, min_age_days, now_ms); requests @@ -289,13 +537,49 @@ pub fn clean_artifacts( }; } - match crate::safety::trash_delete_if_identity( + let active_use = crate::git_worktree::active_use_evidence( Path::new(&request.path), - &request.object_id, - request.bytes, - journal_path, - now_ms, - ) { + artifact_active_use_timeout_ms(permanent), + crate::reclaim::ACTIVE_USE_PROBE_MAX_PIDS, + true, + ); + if !active_use.assessed + || !active_use.evidence_complete + || active_use.error.is_some() + || active_use.results_truncated + { + return DevArtifactCleanResult { + path: request.path.clone(), + ok: false, + error: "development artifact active-use evidence incomplete; rescan before cleanup".into(), + }; + } + if active_use.active { + return DevArtifactCleanResult { + path: request.path.clone(), + ok: false, + error: "development artifact is active; close the using process before cleanup".into(), + }; + } + + let mutation = if permanent { + crate::safety::permanent_delete_dir_if_identity( + Path::new(&request.path), + &request.object_id, + request.bytes, + journal_path, + now_ms, + ) + } else { + crate::safety::trash_delete_if_identity( + Path::new(&request.path), + &request.object_id, + request.bytes, + journal_path, + now_ms, + ) + }; + match mutation { Ok(()) => DevArtifactCleanResult { path: request.path.clone(), ok: true, @@ -316,7 +600,12 @@ mod tests { use super::*; use std::fs; - fn project(root: &std::path::Path, name: &str, marker: &str, artifact: &str) -> 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(); @@ -350,6 +639,25 @@ mod tests { assert_eq!(nm.age_days, 0, "sentinel now_ms는 age_days 0으로 보고"); } + #[test] + fn finds_only_explicit_javascript_build_outputs() { + let tmp = tempfile::tempdir().unwrap(); + for name in [".next", "dist-electron"] { + project(tmp.path(), name, "package.json", name); + } + let generic_project = tmp.path().join("generic"); + fs::create_dir_all(generic_project.join(".build")).unwrap(); + fs::write(generic_project.join("package.json"), b"{}").unwrap(); + fs::write(generic_project.join(".build/customer-data.bin"), b"owned").unwrap(); + fs::create_dir_all(tmp.path().join("unowned/.next")).unwrap(); + let found = find_artifacts(tmp.path(), 0, u64::MAX); + for name in [".next", "dist-electron"] { + assert!(found.iter().any(|artifact| artifact.kind == name)); + } + assert!(!found.iter().any(|artifact| artifact.kind == ".build")); + assert!(!found.iter().any(|artifact| artifact.path.contains("unowned"))); + } + #[test] fn finds_regenerable_codegraph_indexes() { let tmp = tempfile::tempdir().unwrap(); @@ -364,6 +672,51 @@ mod tests { })); } + #[cfg(unix)] + #[test] + fn finds_only_native_marked_real_vscode_extension_directories() { + use std::os::unix::fs::symlink; + + let tmp = tempfile::tempdir().unwrap(); + let extensions = tmp.path().join(".vscode/extensions"); + let obsolete = extensions.join("publisher.tool-1.0.0"); + let retained = extensions.join("publisher.keep-1.0.0"); + let server_extensions = tmp.path().join(".vscode-server/data/extensions"); + let server_obsolete = server_extensions.join("publisher.server-1.0.0"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(&obsolete).unwrap(); + fs::create_dir(&retained).unwrap(); + fs::create_dir_all(&server_obsolete).unwrap(); + fs::create_dir(&outside).unwrap(); + symlink(&outside, extensions.join("linked-1.0.0")).unwrap(); + fs::write(obsolete.join("package.json"), b"{}").unwrap(); + fs::write( + extensions.join(".obsolete"), + br#"{"publisher.tool-1.0.0":true,"publisher.keep-1.0.0":false,"../outside":true,"linked-1.0.0":true}"#, + ) + .unwrap(); + fs::write( + server_extensions.join(".obsolete"), + br#"{"publisher.server-1.0.0":true}"#, + ) + .unwrap(); + + let found = find_artifacts(tmp.path(), 0, u64::MAX); + + assert_eq!(found.len(), 2); + assert!(found + .iter() + .all(|item| item.kind == "vscode-obsolete-extension")); + assert!(found + .iter() + .any(|item| item.path == obsolete.to_string_lossy())); + assert!(found + .iter() + .any(|item| item.path == server_obsolete.to_string_lossy())); + assert_eq!(editor_product(".cursor"), Some("Cursor")); + assert_eq!(editor_product(".unknown-editor"), None); + } + #[test] fn respects_min_age() { let tmp = tempfile::tempdir().unwrap(); @@ -408,6 +761,158 @@ mod tests { assert!(results[0].error.contains("changed")); assert!(live.exists()); assert!(original.exists()); - assert!(!journal.exists(), "stale identity must not create a journal"); + assert!( + !journal.exists(), + "stale identity must not create a journal" + ); + } + + #[cfg(unix)] + #[test] + fn permanent_cleanup_physically_removes_an_unchanged_inactive_artifact() { + let tmp = tempfile::tempdir().unwrap(); + let artifact = project(tmp.path(), "app", "package.json", "node_modules"); + let candidates = find_artifacts(tmp.path(), 0, u64::MAX); + let journal = tmp.path().join("journal.jsonl"); + + let results = permanently_delete_artifacts(&candidates, tmp.path(), 0, &journal, 1); + + assert_eq!(results.len(), 1); + assert!(results[0].ok, "{}", results[0].error); + assert!(!artifact.exists()); + assert_eq!( + crate::safety::journal_recent(&journal, 1)[0].op, + "permanent_generated_directory_delete" + ); + } + + #[test] + fn discovers_regenerable_python_tool_caches() { + let tmp = tempfile::tempdir().unwrap(); + for name in [".mypy_cache", ".pytest_cache", ".ruff_cache"] { + let path = tmp.path().join(name); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("cache.bin"), b"cache").unwrap(); + } + let mut kinds = find_artifacts(tmp.path(), 0, u64::MAX) + .into_iter() + .map(|artifact| artifact.kind) + .collect::>(); + kinds.sort(); + assert_eq!(kinds, [".mypy_cache", ".pytest_cache", ".ruff_cache"]); + } + + #[test] + fn discovers_marker_gated_python_tool_environments() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("setup.cfg"), "[tox:tox]").unwrap(); + fs::create_dir(tmp.path().join(".tox")).unwrap(); + fs::write(tmp.path().join("noxfile.py"), "").unwrap(); + fs::create_dir(tmp.path().join(".nox")).unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert!(artifacts.iter().any(|artifact| artifact.kind == ".tox")); + assert!(artifacts.iter().any(|artifact| artifact.kind == ".nox")); + } + + #[test] + fn ignores_tox_directory_when_setup_cfg_has_no_tox_section() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("setup.cfg"), "[metadata]").unwrap(); + fs::create_dir(tmp.path().join(".tox")).unwrap(); + + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); + } + + #[test] + fn discovers_python_314_project_environment() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join(".git"), "gitdir: /private/fixture").unwrap(); + fs::create_dir(tmp.path().join(".venv314")).unwrap(); + fs::write(tmp.path().join(".venv314/pyvenv.cfg"), "version = 3.14.0").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].kind, ".venv314"); + } + + #[test] + fn discovers_standalone_cargo_target_cache_by_native_tag() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("wardnet-pr95-target"); + fs::create_dir_all(target.join("debug")).unwrap(); + fs::write( + target.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by cargo.\n", + ) + .unwrap(); + fs::write(target.join(".rustc_info.json"), "{}").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].kind, "cargo-target-cache"); + } + + #[test] + fn discovers_named_target_cache_without_project_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + fs::create_dir_all(target.join("debug")).unwrap(); + fs::write( + target.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by cargo.\n", + ) + .unwrap(); + fs::write(target.join(".rustc_info.json"), "{}").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].kind, "cargo-target-cache"); + } + + #[test] + fn ignores_named_target_layout_without_cargo_authority() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target"); + for child in ["deps", "build", "incremental"] { + fs::create_dir_all(target.join("debug").join(child)).unwrap(); + } + fs::write(target.join("customer-owned.sqlite"), b"business data").unwrap(); + + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); + } + + #[test] + fn ignores_oversized_standalone_cargo_cache_tag() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("standalone-cache"); + fs::create_dir_all(cache.join("debug")).unwrap(); + fs::write(cache.join(".rustc_info.json"), "{}").unwrap(); + let mut tag = "Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by cargo.\n".to_owned(); + tag.push_str(&"x".repeat(65_536)); + fs::write(cache.join("CACHEDIR.TAG"), tag).unwrap(); + + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); + } + + #[test] + fn ignores_named_python_314_directory_without_matching_environment_metadata() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join(".git"), "gitdir: /private/fixture").unwrap(); + fs::create_dir(tmp.path().join(".venv314")).unwrap(); + fs::write(tmp.path().join(".venv314/pyvenv.cfg"), "version = 3.13.9").unwrap(); + + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); + + fs::write(tmp.path().join(".venv314/pyvenv.cfg"), "version = 3.140.0").unwrap(); + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); + + fs::remove_file(tmp.path().join(".git")).unwrap(); + fs::write(tmp.path().join("pyproject.toml"), "[project]").unwrap(); + assert!(find_artifacts(tmp.path(), 0, u64::MAX).is_empty()); } } diff --git a/src-tauri/src/duplicate_audit.rs b/src-tauri/src/duplicate_audit.rs index 5f68d0925..c30deb1a0 100644 --- a/src-tauri/src/duplicate_audit.rs +++ b/src-tauri/src/duplicate_audit.rs @@ -6,10 +6,12 @@ use crate::cloud::{self, MetadataEvidence}; use crate::content_digest::{ContentDigests, ContentHasher}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::{File, Metadata}; use std::io::Read; use std::path::{Component, Path, PathBuf}; +#[cfg(all(unix, not(coverage)))] +use std::process::Command; pub const EXACT_DUPLICATE_AUDIT_VERSION: u32 = 1; pub const DEFAULT_MAX_ENTRIES: usize = 200_000; @@ -129,9 +131,32 @@ pub struct ExactDuplicateAuditSummary { pub requires_human_canonical_selection: bool, pub automatic_delete_allowed: bool, pub mutation_performed: bool, + pub reclaim_plan_fingerprint: Option, + pub exact_reclaim_approval_phrase: Option, + pub canonical_selection_policy: String, + pub quality_equivalence_basis: String, pub notices: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExactDuplicateReclaimExecution { + pub schema_version: u32, + pub audit_fingerprint: String, + pub reclaim_plan_fingerprint: String, + pub candidate_file_count: usize, + pub removed_file_count: usize, + pub active_file_count: usize, + pub failed_file_count: usize, + pub skipped_file_count: usize, + pub failure_reasons: Vec, + pub removed_allocated_bytes_upper_bound: u64, + pub evidence_complete: bool, + pub executed: bool, + pub executed_at_ms: u64, + pub rationale: String, +} + #[derive(Debug, Clone)] struct FileObservation { path: PathBuf, @@ -157,6 +182,15 @@ fn valid_relative_path(path: &Path) -> bool { .all(|component| matches!(component, Component::Normal(_))) } +fn is_managed_photo_library(path: &Path) -> bool { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + name.ends_with(".photoslibrary") || name.ends_with(".photolibrary") +} + fn system_time_ms(value: std::io::Result) -> u64 { value .ok() @@ -255,10 +289,15 @@ fn metadata_fingerprint(metadata: &ExactDuplicateProductionMetadata) -> String { #[cfg(unix)] fn storage_identity_fingerprint(observation: &FileObservation) -> Option { + storage_identity_fingerprint_from_metadata(observation.device, observation.inode) +} + +#[cfg(unix)] +fn storage_identity_fingerprint_from_metadata(device: u64, inode: u64) -> Option { let mut hasher = blake3::Hasher::new(); hasher.update(b"disksage-exact-duplicate-storage-identity-v1\0"); - hash_value(&mut hasher, &observation.device.to_le_bytes()); - hash_value(&mut hasher, &observation.inode.to_le_bytes()); + hash_value(&mut hasher, &device.to_le_bytes()); + hash_value(&mut hasher, &inode.to_le_bytes()); Some(hasher.finalize().to_hex().to_string()) } @@ -395,6 +434,428 @@ fn audit_fingerprint( hasher.finalize().to_hex().to_string() } +fn canonical_member(cluster: &ExactDuplicateAuditCluster) -> &ExactDuplicateAuditMember { + cluster + .members + .iter() + .min_by_key(|member| { + ( + !member.production_metadata.metadata_probe_complete, + member + .production_metadata + .embedded_production_time_ms + .is_none(), + member.filesystem_created_ms == 0, + member.filesystem_created_ms, + member.relative_path.as_str(), + ) + }) + .expect("duplicate clusters always contain at least two members") +} + +fn reclaim_plan_fingerprint(report: &ExactDuplicateAuditReport) -> Option { + (report.evidence_complete && !report.clusters.is_empty()).then(|| { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage-exact-duplicate-reclaim-v1\0"); + hash_value(&mut hasher, report.audit_fingerprint.as_bytes()); + for cluster in &report.clusters { + hash_value(&mut hasher, cluster.cluster_fingerprint.as_bytes()); + hash_value( + &mut hasher, + canonical_member(cluster).member_fingerprint.as_bytes(), + ); + } + hasher.finalize().to_hex().to_string() + }) +} + +pub fn exact_duplicate_reclaim_approval_phrase( + report: &ExactDuplicateAuditReport, +) -> Option { + let fingerprint = reclaim_plan_fingerprint(report)?; + let candidates = report + .clusters + .iter() + .map(|cluster| cluster.file_count.saturating_sub(1)) + .sum::(); + Some(format!( + "DiskSage exact duplicate reclaim {candidates} {} 승인 {fingerprint}", + report.logical_redundant_bytes + )) +} + +#[cfg(unix)] +fn allocated_bytes(metadata: &Metadata) -> u64 { + use std::os::unix::fs::MetadataExt; + metadata.blocks().saturating_mul(512) +} + +#[cfg(not(unix))] +fn allocated_bytes(metadata: &Metadata) -> u64 { + metadata.len() +} + +#[cfg(all(unix, not(coverage)))] +fn active_duplicate_candidates(paths: &[PathBuf]) -> Result, String> { + let mut active = BTreeSet::new(); + let candidate_identities: BTreeMap<(u64, u64), &PathBuf> = paths + .iter() + .filter_map(|path| { + std::fs::metadata(path) + .ok() + .map(|metadata| (unix_identity(&metadata), path)) + }) + .collect(); + let descriptors = std::fs::read_dir("/dev/fd") + .map_err(|_| "duplicate-reclaim-active-use-fd-unavailable".to_string())?; + for descriptor in descriptors.flatten() { + if let Ok(metadata) = File::open(descriptor.path()).and_then(|file| file.metadata()) { + if let Some(path) = candidate_identities.get(&unix_identity(&metadata)) { + active.insert((*path).clone()); + } + } + } + for chunk in paths.chunks(64) { + let mut command = Command::new("lsof"); + command.args(["-F", "pn", "--"]); + command.args(chunk); + let result = command + .output() + .map_err(|_| "duplicate-reclaim-active-use-lsof-unavailable".to_string())?; + if !matches!(result.status.code(), Some(0) | Some(1)) + || result.stdout.len() > 2 * 1024 * 1024 + { + return Err("duplicate-reclaim-active-use-lsof-incomplete".into()); + } + for field in result.stdout.split(|byte| *byte == b'\n') { + let Some(path) = field.strip_prefix(b"n") else { + continue; + }; + if let Some(candidate) = chunk + .iter() + .find(|candidate| candidate.as_os_str().as_encoded_bytes() == path) + { + active.insert(candidate.clone()); + } + } + } + let ps = Command::new("ps") + .args(["-axo", "command="]) + .output() + .map_err(|_| "duplicate-reclaim-active-use-ps-unavailable".to_string())?; + if !ps.status.success() || ps.stdout.len() > 2 * 1024 * 1024 { + return Err("duplicate-reclaim-active-use-ps-incomplete".into()); + } + let commands = String::from_utf8(ps.stdout) + .map_err(|_| "duplicate-reclaim-active-use-ps-invalid".to_string())?; + for path in paths { + if path.to_str().is_some_and(|path| commands.contains(path)) { + active.insert(path.clone()); + } + } + Ok(active) +} + +#[cfg(any(not(unix), coverage))] +fn active_duplicate_candidates(_paths: &[PathBuf]) -> Result, String> { + Err("duplicate-reclaim-active-use-unsupported-platform".into()) +} + +#[cfg(unix)] +fn preserve_staged_candidate( + original: &Path, + staged: &Path, + staging_token: u64, +) -> Result<(), String> { + if std::fs::symlink_metadata(original).is_err() { + return std::fs::rename(staged, original) + .map_err(|_| "duplicate-reclaim-recovery-failed".to_string()); + } + let file_name = original + .file_name() + .ok_or_else(|| "duplicate-reclaim-candidate-name-missing".to_string())? + .to_string_lossy(); + let recovery = + original.with_file_name(format!("{file_name}.disksage-recovery-{staging_token}")); + if std::fs::symlink_metadata(&recovery).is_ok() { + return Err("duplicate-reclaim-recovery-location-occupied".into()); + } + std::fs::rename(staged, recovery) + .map_err(|_| "duplicate-reclaim-recovery-failed".to_string())?; + Err("duplicate-reclaim-recovery-preserved".into()) +} + +#[cfg(unix)] +fn remove_if_storage_identity( + path: &Path, + expected_storage_identity: &str, + expected_logical_bytes: u64, + expected_content_digests: &ContentDigests, + staging_token: u64, +) -> Result<(), String> { + if crate::safety::is_protected(path) { + return Err("duplicate-reclaim-protected-path".into()); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "duplicate-reclaim-candidate-changed".to_string())?; + let (device, inode) = unix_identity(&metadata); + if storage_identity_fingerprint_from_metadata(device, inode).as_deref() + != Some(expected_storage_identity) + { + return Err("duplicate-reclaim-candidate-changed".into()); + } + match active_duplicate_candidates(&[path.to_path_buf()]) { + Ok(active) if active.contains(path) => { + return Err("duplicate-reclaim-active-before-staging".into()); + } + Ok(_) => {} + Err(error) => return Err(error), + } + let parent = path + .parent() + .ok_or_else(|| "duplicate-reclaim-candidate-parent-missing".to_string())?; + let staging_dir = parent.join(format!( + ".disksage-duplicate-stage-{}-{staging_token}", + std::process::id() + )); + std::fs::create_dir(&staging_dir) + .map_err(|_| "duplicate-reclaim-staging-unavailable".to_string())?; + let staged = staging_dir.join( + path.file_name() + .ok_or_else(|| "duplicate-reclaim-candidate-name-missing".to_string())?, + ); + if let Err(error) = std::fs::rename(path, &staged) { + let _ = std::fs::remove_dir(&staging_dir); + return Err(format!("duplicate-reclaim-staging-failed:{error}")); + } + let staged_matches = std::fs::symlink_metadata(&staged) + .ok() + .map(|metadata| unix_identity(&metadata)) + .and_then(|(device, inode)| storage_identity_fingerprint_from_metadata(device, inode)) + .as_deref() + == Some(expected_storage_identity); + if !staged_matches { + let recovery = preserve_staged_candidate(path, &staged, staging_token); + let _ = std::fs::remove_dir(&staging_dir); + return recovery.and(Err( + "duplicate-reclaim-candidate-changed-during-staging".into() + )); + } + let staged_observation = FileObservation { + path: staged.clone(), + relative_path: path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + logical_bytes: expected_logical_bytes, + filesystem_created_ms: system_time_ms(metadata.created()), + filesystem_modified_ms: system_time_ms(metadata.modified()), + device, + inode, + }; + if hash_stable_file(&staged_observation).as_ref() != Ok(expected_content_digests) { + let recovery = preserve_staged_candidate(path, &staged, staging_token); + let _ = std::fs::remove_dir(&staging_dir); + return recovery.and(Err("duplicate-reclaim-candidate-content-changed".into())); + } + match active_duplicate_candidates(std::slice::from_ref(&staged)) { + Ok(active) if active.contains(&staged) => { + let recovery = preserve_staged_candidate(path, &staged, staging_token); + let _ = std::fs::remove_dir(&staging_dir); + return recovery.and(Err("duplicate-reclaim-active-during-staging".into())); + } + Ok(_) => {} + Err(error) => { + let recovery = preserve_staged_candidate(path, &staged, staging_token); + let _ = std::fs::remove_dir(&staging_dir); + return recovery.and(Err(error)); + } + } + if let Err(error) = std::fs::remove_file(&staged) { + let recovery = preserve_staged_candidate(path, &staged, staging_token); + let _ = std::fs::remove_dir(&staging_dir); + if let Err(recovery_error) = recovery { + return Err(recovery_error); + } + return Err(format!("duplicate-reclaim-delete-failed:{error}")); + } + let _ = std::fs::remove_dir(&staging_dir); + Ok(()) +} + +#[cfg(not(unix))] +fn remove_if_storage_identity( + _path: &Path, + _expected_storage_identity: &str, + _expected_logical_bytes: u64, + _expected_content_digests: &ContentDigests, + _staging_token: u64, +) -> Result<(), String> { + Err("duplicate-reclaim-permanent-delete-unsupported-platform".into()) +} + +fn removal_failure_code(error: &str) -> String { + error.split(':').next().unwrap_or(error).to_string() +} + +/// Permanently remove only freshly re-hashed members of exact-content clusters while retaining +/// one provenance-preferred, byte-identical canonical member in every cluster. +#[cfg(not(coverage))] +pub fn execute_exact_duplicate_reclaim( + source_root: &Path, + min_bytes: u64, + max_entries: usize, + approved_audit_fingerprint: &str, + confirmation_phrase: &str, + rationale: &str, + executed_at_ms: u64, +) -> Result { + let report = + collect_exact_duplicate_audit(source_root, executed_at_ms, min_bytes, max_entries)?; + execute_exact_duplicate_reclaim_from_report( + source_root, + &report, + approved_audit_fingerprint, + confirmation_phrase, + rationale, + executed_at_ms, + ) +} + +/// Execute an approved immutable private report by freshly revalidating only its exact candidates. +/// Unrelated additions or changes elsewhere in the live source tree cannot expand the approved set. +#[cfg(not(coverage))] +pub fn execute_exact_duplicate_reclaim_from_report( + source_root: &Path, + report: &ExactDuplicateAuditReport, + approved_audit_fingerprint: &str, + confirmation_phrase: &str, + rationale: &str, + executed_at_ms: u64, +) -> Result { + if rationale.trim() != rationale || rationale.is_empty() || rationale.chars().count() > 1_000 { + return Err("duplicate-reclaim-rationale-invalid".into()); + } + if !exact_duplicate_audit_integrity_valid(&report) || !report.evidence_complete { + return Err("duplicate-reclaim-evidence-incomplete".into()); + } + if report.audit_fingerprint != approved_audit_fingerprint { + return Err("duplicate-reclaim-audit-fingerprint-mismatch".into()); + } + let plan_fingerprint = reclaim_plan_fingerprint(&report) + .ok_or_else(|| "duplicate-reclaim-empty-candidate-set".to_string())?; + if exact_duplicate_reclaim_approval_phrase(&report).as_deref() != Some(confirmation_phrase) { + return Err("duplicate-reclaim-confirmation-mismatch".into()); + } + + let canonical_root = std::fs::canonicalize(source_root) + .map_err(|_| "duplicate-reclaim-root-unavailable".to_string())?; + if canonical_root.to_str() != Some(report.source_root.as_str()) + || executed_at_ms < report.observed_at_ms + { + return Err("duplicate-reclaim-source-scope-mismatch".into()); + } + let candidate_file_count = report + .clusters + .iter() + .map(|cluster| cluster.file_count.saturating_sub(1)) + .sum::(); + let mut verified = Vec::with_capacity(candidate_file_count); + for cluster in &report.clusters { + let retained = canonical_member(cluster).member_fingerprint.as_str(); + for expected in &cluster.members { + let relative = Path::new(&expected.relative_path); + if !valid_relative_path(relative) { + return Err("duplicate-reclaim-relative-path-unsafe".into()); + } + let path = canonical_root.join(relative); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|_| "duplicate-reclaim-candidate-changed".to_string())?; + let observation = observe_file(&canonical_root, path.clone(), metadata.clone()) + .map_err(|_| "duplicate-reclaim-candidate-changed".to_string())?; + let digests = hash_stable_file(&observation)?; + if digests != cluster.content_digests + || observation.relative_path != expected.relative_path + || observation.logical_bytes != expected.logical_bytes + || observation.filesystem_created_ms != expected.filesystem_created_ms + || observation.filesystem_modified_ms != expected.filesystem_modified_ms + || storage_identity_fingerprint(&observation) + != expected.storage_identity_fingerprint + { + return Err("duplicate-reclaim-candidate-changed".into()); + } + if expected.member_fingerprint != retained { + verified.push(( + path, + allocated_bytes(&metadata), + expected.logical_bytes, + cluster.content_digests.clone(), + expected + .storage_identity_fingerprint + .clone() + .ok_or_else(|| "duplicate-reclaim-storage-identity-missing".to_string())?, + )); + } + } + } + let active = active_duplicate_candidates( + &verified + .iter() + .map(|(path, _, _, _, _)| path.clone()) + .collect::>(), + )?; + let removable = verified + .into_iter() + .filter(|(path, _, _, _, _)| !active.contains(path)) + .collect::>(); + + let mut removed_file_count = 0usize; + let mut removed_allocated_bytes_upper_bound = 0u64; + let mut failure_reasons = BTreeSet::new(); + for (index, (path, bytes, logical_bytes, content_digests, storage_identity)) in + removable.into_iter().enumerate() + { + match remove_if_storage_identity( + &path, + &storage_identity, + logical_bytes, + &content_digests, + executed_at_ms.saturating_add(index as u64), + ) { + Ok(()) => { + removed_file_count = removed_file_count.saturating_add(1); + removed_allocated_bytes_upper_bound = + removed_allocated_bytes_upper_bound.saturating_add(bytes); + } + Err(error) => { + failure_reasons.insert(removal_failure_code(&error)); + } + } + } + let active_file_count = active.len(); + let failed_file_count = candidate_file_count + .saturating_sub(active_file_count) + .saturating_sub(removed_file_count); + let skipped_file_count = candidate_file_count.saturating_sub(removed_file_count); + Ok(ExactDuplicateReclaimExecution { + schema_version: EXACT_DUPLICATE_AUDIT_VERSION, + audit_fingerprint: report.audit_fingerprint.clone(), + reclaim_plan_fingerprint: plan_fingerprint, + candidate_file_count, + removed_file_count, + active_file_count, + failed_file_count, + skipped_file_count, + failure_reasons: failure_reasons.into_iter().collect(), + removed_allocated_bytes_upper_bound, + evidence_complete: skipped_file_count == 0, + executed: true, + executed_at_ms, + rationale: rationale.into(), + }) +} + #[cfg(unix)] fn unix_identity(metadata: &Metadata) -> (u64, u64) { use std::os::unix::fs::MetadataExt; @@ -530,8 +991,9 @@ fn member( /// Recursively collect exact duplicate evidence without following symlinks or mutating files. /// -/// Filesystem timestamps are stability evidence only. The audit deliberately does not assign a -/// production date or choose a canonical copy because path context may carry distinct lineage. +/// Filesystem timestamps are stability evidence only. The audit records production evidence but +/// remains read-only; the separately approved reclaim path retains one byte-identical canonical +/// member and revalidates every candidate before deletion. #[cfg(not(coverage))] pub fn collect_exact_duplicate_audit( source_root: &Path, @@ -550,6 +1012,9 @@ pub fn collect_exact_duplicate_audit( } let canonical_root = std::fs::canonicalize(source_root) .map_err(|_| "duplicate-audit-root-unavailable".to_string())?; + if crate::safety::is_explicitly_protected(&canonical_root) { + return Err("duplicate-audit-root-explicitly-protected".into()); + } if canonical_root.to_str().is_none() { return Err("duplicate-audit-root-non-unicode".into()); } @@ -558,6 +1023,9 @@ pub fn collect_exact_duplicate_audit( if !root_metadata.is_dir() || root_metadata.file_type().is_symlink() { return Err("duplicate-audit-root-unsafe".into()); } + if is_managed_photo_library(&canonical_root) { + return Err("duplicate-audit-root-system-managed-photo-library".into()); + } let mut evidence_complete = true; let mut entries_seen = 0usize; @@ -609,6 +1077,13 @@ pub fn collect_exact_duplicate_audit( } let path = entry.path(); if file_type.is_dir() { + if is_managed_photo_library(&path) { + increment_issue( + &mut issue_counts, + "duplicate-audit-system-managed-photo-library-excluded", + ); + continue; + } if depth >= MAX_DEPTH { evidence_complete = false; increment_issue(&mut issue_counts, "duplicate-audit-depth-limit-reached"); @@ -661,7 +1136,7 @@ pub fn collect_exact_duplicate_audit( continue; } let mut by_digest = - BTreeMap::<(String, String, String), Vec>::new(); + BTreeMap::<(String, String, String), Vec<(FileObservation, ContentDigests)>>::new(); for observation in size_group { match hash_stable_file(&observation) { Ok(digests) => { @@ -671,13 +1146,10 @@ pub fn collect_exact_duplicate_audit( digests.sha256.clone(), digests.quick_xor_base64.clone(), ); - match member(&observation, &digests) { - Ok(member) => by_digest.entry(key).or_default().push(member), - Err(reason) => { - evidence_complete = false; - increment_issue(&mut issue_counts, &reason); - } - } + by_digest + .entry(key) + .or_default() + .push((observation, digests)); } Err(reason) => { evidence_complete = false; @@ -685,7 +1157,20 @@ pub fn collect_exact_duplicate_audit( } } } - for ((blake3, sha256, quick_xor_base64), mut members) in by_digest { + for ((blake3, sha256, quick_xor_base64), observations) in by_digest { + if observations.len() < 2 { + continue; + } + let mut members = Vec::with_capacity(observations.len()); + for (observation, digests) in observations { + match member(&observation, &digests) { + Ok(member) => members.push(member), + Err(reason) => { + evidence_complete = false; + increment_issue(&mut issue_counts, &reason); + } + } + } if members.len() < 2 { continue; } @@ -901,6 +1386,7 @@ pub fn exact_duplicate_audit_integrity_valid(report: &ExactDuplicateAuditReport) pub fn summarize_exact_duplicate_audit( report: &ExactDuplicateAuditReport, ) -> ExactDuplicateAuditSummary { + let reclaim_plan_fingerprint = reclaim_plan_fingerprint(report); ExactDuplicateAuditSummary { schema_version: report.schema_version, output_mode: "exact-duplicate-audit-summary".into(), @@ -931,6 +1417,11 @@ pub fn summarize_exact_duplicate_audit( requires_human_canonical_selection: report.cluster_count > 0, automatic_delete_allowed: false, mutation_performed: false, + reclaim_plan_fingerprint, + exact_reclaim_approval_phrase: exact_duplicate_reclaim_approval_phrase(report), + canonical_selection_policy: + "exact-content-quality-tie>embedded-provenance>oldest-created>relative-path".into(), + quality_equivalence_basis: "blake3+sha256+quickxor exact encoded bytes".into(), notices: vec![ "read-only-no-file-created-modified-renamed-or-deleted".into(), "content-hashes-and-relative-paths-redacted-from-summary".into(), @@ -939,7 +1430,8 @@ pub fn summarize_exact_duplicate_audit( "logical-redundant-bytes-are-not-verified-physical-reclaimable-bytes".into(), "identical-content-does-not-prove-identical-lineage-context".into(), "canonical-copy-selection-requires-private-metadata-review".into(), - "no-delete-approval-created".into(), + "no-automatic-delete-approval-created".into(), + "reclaim-still-requires-exact-human-approval-and-fresh-rehash".into(), ], } } @@ -984,6 +1476,137 @@ mod tests { ); assert_eq!(summary.physical_reclaimable_bytes, None); assert!(summary.requires_human_canonical_selection); + assert!(summary.reclaim_plan_fingerprint.is_some()); + assert!(summary.exact_reclaim_approval_phrase.is_some()); + } + + #[cfg(all(unix, not(coverage)))] + #[test] + fn exact_reclaim_keeps_one_byte_identical_canonical_member() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a.bin"), b"same exact content").unwrap(); + std::fs::write(root.path().join("b.bin"), b"same exact content").unwrap(); + let plan = collect_exact_duplicate_audit(root.path(), 42, 1, 100).unwrap(); + let phrase = exact_duplicate_reclaim_approval_phrase(&plan).unwrap(); + std::fs::write(root.path().join("unrelated.bin"), b"new unrelated content").unwrap(); + let execution = execute_exact_duplicate_reclaim_from_report( + root.path(), + &plan, + &plan.audit_fingerprint, + &phrase, + "operator reviewed exact byte-identical copies", + 43, + ) + .unwrap(); + assert_eq!(execution.candidate_file_count, 1); + assert_eq!(execution.removed_file_count, 1); + assert_eq!(execution.active_file_count, 0); + assert_eq!(execution.failed_file_count, 0); + assert!(execution.failure_reasons.is_empty()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2); + } + + #[cfg(all(unix, not(coverage)))] + #[test] + fn exact_reclaim_fails_before_delete_when_retained_member_disappears() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a.bin"), b"same exact content").unwrap(); + std::fs::write(root.path().join("b.bin"), b"same exact content").unwrap(); + let plan = collect_exact_duplicate_audit(root.path(), 42, 1, 100).unwrap(); + let retained = canonical_member(&plan.clusters[0]).relative_path.clone(); + std::fs::remove_file(root.path().join(retained)).unwrap(); + let phrase = exact_duplicate_reclaim_approval_phrase(&plan).unwrap(); + assert!(execute_exact_duplicate_reclaim_from_report( + root.path(), + &plan, + &plan.audit_fingerprint, + &phrase, + "operator reviewed exact byte-identical copies", + 43, + ) + .is_err()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn identity_bound_remove_preserves_replacement_file() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("duplicate.bin"); + std::fs::write(&path, b"approved bytes").unwrap(); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + let (device, inode) = unix_identity(&metadata); + let approved = storage_identity_fingerprint_from_metadata(device, inode).unwrap(); + let original = observe_file(root.path(), path.clone(), metadata).unwrap(); + let approved_digests = hash_stable_file(&original).unwrap(); + + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"replacement bytes").unwrap(); + let error = remove_if_storage_identity( + &path, + &approved, + original.logical_bytes, + &approved_digests, + 1, + ) + .unwrap_err(); + assert!(matches!( + error.as_str(), + "duplicate-reclaim-candidate-changed" | "duplicate-reclaim-candidate-content-changed" + )); + assert_eq!(std::fs::read(&path).unwrap(), b"replacement bytes"); + assert_eq!( + removal_failure_code("duplicate-reclaim-delete-failed:permission denied"), + "duplicate-reclaim-delete-failed" + ); + } + + #[cfg(all(unix, not(coverage)))] + #[test] + fn identity_bound_remove_preserves_open_candidate_after_staging() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("duplicate.bin"); + std::fs::write(&path, b"approved bytes").unwrap(); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + let (device, inode) = unix_identity(&metadata); + let approved = storage_identity_fingerprint_from_metadata(device, inode).unwrap(); + let original = observe_file(root.path(), path.clone(), metadata).unwrap(); + let approved_digests = hash_stable_file(&original).unwrap(); + let open_handle = File::open(&path).unwrap(); + + let error = remove_if_storage_identity( + &path, + &approved, + original.logical_bytes, + &approved_digests, + 2, + ) + .unwrap_err(); + drop(open_handle); + + assert!(!error.is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), b"approved bytes"); + } + + #[cfg(unix)] + #[test] + fn staged_candidate_is_surfaced_when_original_name_is_occupied() { + let root = tempfile::tempdir().unwrap(); + let original = root.path().join("duplicate.bin"); + let staged = root.path().join(".hidden-stage"); + std::fs::write(&original, b"replacement bytes").unwrap(); + std::fs::write(&staged, b"approved bytes").unwrap(); + + assert_eq!( + preserve_staged_candidate(&original, &staged, 77).unwrap_err(), + "duplicate-reclaim-recovery-preserved" + ); + assert_eq!(std::fs::read(&original).unwrap(), b"replacement bytes"); + assert_eq!( + std::fs::read(root.path().join("duplicate.bin.disksage-recovery-77")).unwrap(), + b"approved bytes" + ); + assert!(!staged.exists()); } #[test] @@ -1048,6 +1671,30 @@ mod tests { assert!(exact_duplicate_audit_integrity_valid(&report)); } + #[test] + fn managed_photo_libraries_are_never_traversed() { + let root = tempfile::tempdir().unwrap(); + let library = root.path().join("Photos Library.photoslibrary"); + std::fs::create_dir(&library).unwrap(); + std::fs::write(root.path().join("outside.bin"), b"same exact content").unwrap(); + std::fs::write(library.join("database.bin"), b"same exact content").unwrap(); + + let report = collect_exact_duplicate_audit(root.path(), 42, 1, 100).unwrap(); + + assert!(report.evidence_complete); + assert_eq!(report.file_count, 1); + assert_eq!(report.cluster_count, 0); + assert_eq!( + report.issue_counts["duplicate-audit-system-managed-photo-library-excluded"], + 1 + ); + assert!(exact_duplicate_audit_integrity_valid(&report)); + assert_eq!( + collect_exact_duplicate_audit(&library, 42, 1, 100).unwrap_err(), + "duplicate-audit-root-system-managed-photo-library" + ); + } + #[test] fn integrity_rejects_tampered_delete_or_fingerprint_claims() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/duplicate_audit_public.rs b/src-tauri/src/duplicate_audit_public.rs new file mode 100644 index 000000000..2066cb70e --- /dev/null +++ b/src-tauri/src/duplicate_audit_public.rs @@ -0,0 +1,181 @@ +//! Public exact-duplicate boundary with fail-closed policy for legacy private reports. +//! +//! Reports created before managed Photos-library exclusion existed can still be structurally valid. +//! Reapplying the current exclusion at approval and execution prevents stale evidence from granting +//! permanent-delete authority inside `.photoslibrary` or `.photolibrary` packages. + +use std::path::{Component, Path}; + +pub use crate::duplicate_audit_implementation::{ + collect_exact_duplicate_audit, ExactDuplicateAuditCluster, ExactDuplicateAuditMember, + ExactDuplicateAuditReport, ExactDuplicateAuditSummary, ExactDuplicateProductionMetadata, + ExactDuplicateReclaimExecution, DEFAULT_MAX_ENTRIES, DEFAULT_MIN_BYTES, + EXACT_DUPLICATE_AUDIT_VERSION, MAX_ENTRIES, +}; + +fn managed_photo_component(path: &Path) -> bool { + path.components().any(|component| { + let Component::Normal(name) = component else { + return false; + }; + let name = name.to_string_lossy().to_ascii_lowercase(); + name.ends_with(".photoslibrary") || name.ends_with(".photolibrary") + }) +} + +fn report_contains_managed_photo_library(report: &ExactDuplicateAuditReport) -> bool { + managed_photo_component(Path::new(&report.source_root)) + || report.clusters.iter().any(|cluster| { + cluster + .members + .iter() + .any(|member| managed_photo_component(Path::new(&member.relative_path))) + }) +} + +fn live_report_scope_is_safe(source_root: &Path, report: &ExactDuplicateAuditReport) -> bool { + let Ok(canonical_root) = std::fs::canonicalize(source_root) else { + return false; + }; + if managed_photo_component(&canonical_root) { + return false; + } + report.clusters.iter().all(|cluster| { + cluster.members.iter().all(|member| { + let candidate = canonical_root.join(&member.relative_path); + std::fs::canonicalize(candidate).is_ok_and(|canonical_candidate| { + canonical_candidate.starts_with(&canonical_root) + && !managed_photo_component(&canonical_candidate) + }) + }) + }) +} + +/// Validate both the immutable report structure and current destructive-policy exclusions. +pub fn exact_duplicate_audit_integrity_valid(report: &ExactDuplicateAuditReport) -> bool { + !report_contains_managed_photo_library(report) + && crate::duplicate_audit_implementation::exact_duplicate_audit_integrity_valid(report) +} + +/// Never issue an approval phrase for legacy evidence that crosses a managed Photos-library scope. +pub fn exact_duplicate_reclaim_approval_phrase( + report: &ExactDuplicateAuditReport, +) -> Option { + if report_contains_managed_photo_library(report) { + None + } else { + crate::duplicate_audit_implementation::exact_duplicate_reclaim_approval_phrase(report) + } +} + +/// Redact destructive authority from summaries of legacy managed-library reports as well. +pub fn summarize_exact_duplicate_audit( + report: &ExactDuplicateAuditReport, +) -> ExactDuplicateAuditSummary { + let mut summary = + crate::duplicate_audit_implementation::summarize_exact_duplicate_audit(report); + if report_contains_managed_photo_library(report) { + summary.reclaim_plan_fingerprint = None; + summary.exact_reclaim_approval_phrase = None; + summary + .notices + .push("system-managed-photo-library-reclaim-disabled".into()); + } + summary +} + +/// Collect fresh evidence, then pass it through the same current-policy execution boundary used for +/// immutable private reports. +#[cfg(not(coverage))] +pub fn execute_exact_duplicate_reclaim( + source_root: &Path, + min_bytes: u64, + max_entries: usize, + approved_audit_fingerprint: &str, + confirmation_phrase: &str, + rationale: &str, + executed_at_ms: u64, +) -> Result { + let report = crate::duplicate_audit_implementation::collect_exact_duplicate_audit( + source_root, + executed_at_ms, + min_bytes, + max_entries, + )?; + execute_exact_duplicate_reclaim_from_report( + source_root, + &report, + approved_audit_fingerprint, + confirmation_phrase, + rationale, + executed_at_ms, + ) +} + +/// Re-apply current managed-library policy before validating or acting on any historical report. +#[cfg(not(coverage))] +pub fn execute_exact_duplicate_reclaim_from_report( + source_root: &Path, + report: &ExactDuplicateAuditReport, + approved_audit_fingerprint: &str, + confirmation_phrase: &str, + rationale: &str, + executed_at_ms: u64, +) -> Result { + if report_contains_managed_photo_library(report) { + return Err("duplicate-reclaim-system-managed-photo-library".into()); + } + if !live_report_scope_is_safe(source_root, report) { + return Err("duplicate-reclaim-live-source-scope-unsafe".into()); + } + crate::duplicate_audit_implementation::execute_exact_duplicate_reclaim_from_report( + source_root, + report, + approved_audit_fingerprint, + confirmation_phrase, + rationale, + executed_at_ms, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_photo_components_are_case_insensitive_and_component_bounded() { + assert!(managed_photo_component(Path::new( + "nested/Library.PhOtOsLiBrArY/original.jpg" + ))); + assert!(managed_photo_component(Path::new( + "legacy/Library.photolibrary/database" + ))); + assert!(!managed_photo_component(Path::new( + "nested/not-a.photoslibrary-backup/original.jpg" + ))); + } + + #[cfg(unix)] + #[test] + fn live_scope_rejects_parent_symlink_redirected_into_managed_photo_library() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + let managed_library = managed.path().join("Library.photoslibrary"); + std::fs::create_dir(&managed_library).unwrap(); + std::fs::write(managed_library.join("copy-a.bin"), b"same").unwrap(); + std::fs::write(managed_library.join("copy-b.bin"), b"same").unwrap(); + std::fs::create_dir(root.path().join("copies")).unwrap(); + std::fs::write(root.path().join("copies/copy-a.bin"), b"same").unwrap(); + std::fs::write(root.path().join("copies/copy-b.bin"), b"same").unwrap(); + let report = collect_exact_duplicate_audit(root.path(), 42, 1, 100).unwrap(); + + std::fs::remove_file(root.path().join("copies/copy-a.bin")).unwrap(); + std::fs::remove_file(root.path().join("copies/copy-b.bin")).unwrap(); + std::fs::remove_dir(root.path().join("copies")).unwrap(); + symlink(&managed_library, root.path().join("copies")).unwrap(); + + assert!(!live_report_scope_is_safe(root.path(), &report)); + } +} diff --git a/src-tauri/src/git_clone_reclaim.rs b/src-tauri/src/git_clone_reclaim.rs new file mode 100644 index 000000000..aadfde33e --- /dev/null +++ b/src-tauri/src/git_clone_reclaim.rs @@ -0,0 +1,655 @@ +//! Evidence-bound reclamation for a standalone Git clone left on a stale pull-request head. +//! +//! This module never discovers or guesses an age threshold. The operator supplies an explicit +//! cutoff, GitHub resolves the exact same-repository branch and head OID, and DiskSage moves only a +//! clean, inactive, single-worktree clone to the operating-system Trash after a fresh re-audit. + +use crate::git_worktree::{ + self, ClosedPullRequestHeads, GitWorktreeActiveUseEvidence, GitWorktreeAuditOptions, + GitWorktreeAuditReport, GitWorktreeSizeEvidence, StaleOpenPullRequestHeads, +}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const GIT_CLONE_RECLAIM_SCHEMA_KIND: &str = "disksage.git-clone-reclaim-plan"; +pub const GIT_CLONE_RECLAIM_VERSION: u32 = 1; +const MAX_APPROVAL_AGE_MS: u64 = 5 * 60 * 1_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitCloneReclaimPlan { + pub schema_kind: String, + pub version: u32, + pub generated_at_ms: u64, + pub repository_root: String, + pub repository_object_id: String, + pub head: String, + pub branch: String, + pub closed_pull_request_head: bool, + pub stale_open_pull_request_head: bool, + pub stale_open_pull_request_cutoff_ms: Option, + pub size: GitWorktreeSizeEvidence, + pub active_use: GitWorktreeActiveUseEvidence, + pub authority_fingerprint: String, + pub plan_fingerprint: String, + pub exact_approval_phrase: Option, + pub eligible_after_human_approval: bool, + pub blockers: Vec, + pub filesystem_mutation_executed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitCloneReclaimApproval { + pub version: u32, + pub approval_id: String, + pub plan_fingerprint: String, + pub exact_approval_phrase: String, + pub approved_at_ms: u64, + pub approved_by: String, + pub rationale: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitCloneReclaimResult { + pub version: u32, + pub approval_id: String, + pub plan_fingerprint: String, + pub requested_at_ms: u64, + pub completed_at_ms: u64, + pub allocated_bytes_upper_bound: u64, + pub trash_move_executed: bool, + pub path_absence_verified: bool, + pub branch_delete_command_executed: bool, + pub git_prune_executed: bool, + pub physically_reclaimed_bytes: Option, +} + +fn hash_field(hasher: &mut blake3::Hasher, value: &str) { + hasher.update(&(value.len() as u64).to_le_bytes()); + hasher.update(value.as_bytes()); +} + +fn valid_human_text(value: &str, max_chars: usize) -> bool { + !value.trim().is_empty() + && value == value.trim() + && value.chars().count() <= max_chars + && !value.chars().any(char::is_control) +} + +fn plan_fingerprint( + report: &GitWorktreeAuditReport, + repository_object_id: &str, + head: &str, + branch: &str, + size: &GitWorktreeSizeEvidence, + blockers: &[String], +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage.git-clone-reclaim-plan\0v1\0"); + for value in [ + &report.repository_root, + &report.common_dir, + &report.removal_authority_fingerprint, + repository_object_id, + head, + branch, + &size.allocated_bytes.to_string(), + &size.logical_bytes.to_string(), + ] { + hash_field(&mut hasher, value); + } + for blocker in blockers { + hash_field(&mut hasher, blocker); + } + hasher.finalize().to_hex().to_string() +} + +fn approval_id(plan: &GitCloneReclaimPlan, approved_at_ms: u64, approved_by: &str) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage.git-clone-reclaim-approval\0v1\0"); + hash_field(&mut hasher, &plan.plan_fingerprint); + hash_field(&mut hasher, &approved_at_ms.to_string()); + hash_field(&mut hasher, approved_by); + hasher.finalize().to_hex().to_string() +} + +fn ensure_git_clone_approval_fresh( + approval: &GitCloneReclaimApproval, + observed_at_ms: u64, +) -> Result<(), String> { + if observed_at_ms < approval.approved_at_ms + || observed_at_ms.saturating_sub(approval.approved_at_ms) > MAX_APPROVAL_AGE_MS + { + Err("git-clone-execution-approval-invalid-or-stale".into()) + } else { + Ok(()) + } +} + +/// Return whether the requested root is a regular standalone clone rather than a linked +/// worktree or a repository whose administrative directory is redirected through a symlink. +/// +/// `git-worktree list` identifies the checkout, while this check binds the administrative +/// directory to the canonical root. Keeping both observations is important: a linked worktree +/// may look like an ordinary checkout at its own path, and a symlinked `.git` can change what a +/// later Trash operation affects without changing the displayed repository path. +fn has_bounded_standalone_git_directory(repository_root: &Path, common_dir: &Path) -> bool { + let git_entry = repository_root.join(".git"); + let Ok(metadata) = std::fs::symlink_metadata(&git_entry) else { + return false; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return false; + } + common_dir + .parent() + .is_some_and(|parent| parent == repository_root) + && std::fs::canonicalize(git_entry).ok().as_deref() == Some(common_dir) +} + +/// Validate the append-only journal destination before the source clone can be moved. +/// +/// The journal is part of the rollback contract. It must live outside the clone being moved, +/// have a real private parent, and be either absent or a regular file. This prevents an +/// application-data misconfiguration from moving the journal into Trash together with its source +/// or from appending through a symlink. +fn validate_journal_destination(repository_root: &Path, journal_path: &Path) -> Result<(), String> { + if !journal_path.is_absolute() + || journal_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("git-clone-journal-path-invalid".into()); + } + let parent = journal_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| "git-clone-journal-parent-invalid".to_string())?; + let parent_metadata = std::fs::symlink_metadata(parent) + .map_err(|_| "git-clone-journal-parent-unavailable".to_string())?; + if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() { + return Err("git-clone-journal-parent-unsafe".into()); + } + let canonical_source = std::fs::canonicalize(repository_root) + .map_err(|_| "git-clone-source-root-unavailable".to_string())?; + let canonical_parent = std::fs::canonicalize(parent) + .map_err(|_| "git-clone-journal-parent-unavailable".to_string())?; + if canonical_parent.starts_with(&canonical_source) { + return Err("git-clone-journal-inside-source".into()); + } + match std::fs::symlink_metadata(journal_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + Err("git-clone-journal-file-unsafe".into()) + } else { + Ok(()) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("git-clone-journal-file-unavailable".into()), + } +} + +/// Build a plan from already-resolved GitHub PR heads. This is also the deterministic test seam. +pub fn plan_git_clone_reclaim_with_pull_request_heads( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + let report = git_worktree::audit_git_worktrees_with_pull_request_heads( + repository_root, + retention_references, + closed_pull_request_heads, + stale_open_pull_request_heads, + stale_open_pull_request_cutoff_ms, + options, + generated_at_ms, + )?; + let primary = report + .entries + .iter() + .find(|entry| entry.primary) + .ok_or_else(|| "git-clone-primary-worktree-missing".to_string())?; + let repository_path = PathBuf::from(&report.repository_root); + let common_dir = PathBuf::from(&report.common_dir); + let branch = primary.branch.clone().unwrap_or_default(); + let active_use = git_worktree::active_use_evidence( + &repository_path, + options.command_timeout_ms, + options.max_active_pids, + true, + ); + let repository_object_id = crate::safety::filesystem_object_id(&repository_path) + .map_err(|_| "git-clone-object-identity-unavailable".to_string())?; + let mut blockers = Vec::new(); + if !report.evidence_complete { + blockers.push("git-clone-audit-evidence-incomplete".into()); + } + if report.worktree_count != 1 { + blockers.push("git-clone-linked-worktrees-present".into()); + } + if primary.bare || primary.detached || !primary.audit_origin { + blockers.push("git-clone-primary-shape-unsupported".into()); + } + if branch.is_empty() { + blockers.push("git-clone-branch-missing".into()); + } + if primary.status_clean != Some(true) { + blockers.push("git-clone-working-tree-not-clean".into()); + } + if !primary.closed_pull_request_head && !primary.stale_open_pull_request_head { + blockers.push("git-clone-pr-head-authority-missing".into()); + } + if primary.head_is_retained_tip { + blockers.push("git-clone-head-is-retained-tip".into()); + } + if primary.actor_cwd_inside != Some(false) { + blockers.push("git-clone-actor-cwd-evidence-incomplete-or-active".into()); + } + if !primary.size.evidence_complete { + blockers.push("git-clone-size-evidence-incomplete".into()); + } + if !active_use.evidence_complete { + blockers.push("git-clone-active-use-evidence-incomplete".into()); + } else if active_use.active { + blockers.push("git-clone-active-use-detected".into()); + } + if !has_bounded_standalone_git_directory(&repository_path, &common_dir) { + blockers.push("git-clone-git-directory-not-real-or-bounded".into()); + } + if crate::safety::is_protected(&repository_path) { + blockers.push("git-clone-path-protected".into()); + } + blockers.sort(); + blockers.dedup(); + let fingerprint = plan_fingerprint( + &report, + &repository_object_id, + &primary.head, + &branch, + &primary.size, + &blockers, + ); + let eligible = blockers.is_empty(); + Ok(GitCloneReclaimPlan { + schema_kind: GIT_CLONE_RECLAIM_SCHEMA_KIND.into(), + version: GIT_CLONE_RECLAIM_VERSION, + generated_at_ms, + repository_root: report.repository_root, + repository_object_id, + head: primary.head.clone(), + branch, + closed_pull_request_head: primary.closed_pull_request_head, + stale_open_pull_request_head: primary.stale_open_pull_request_head, + stale_open_pull_request_cutoff_ms, + size: primary.size.clone(), + active_use, + authority_fingerprint: report.removal_authority_fingerprint, + exact_approval_phrase: eligible.then(|| { + format!( + "DiskSage stale clone 1 {} 승인 {fingerprint}", + primary.size.allocated_bytes + ) + }), + plan_fingerprint: fingerprint, + eligible_after_human_approval: eligible, + blockers, + filesystem_mutation_executed: false, + }) +} + +/// Resolve current GitHub evidence and build a read-only standalone-clone reclaim plan. +pub fn plan_git_clone_reclaim( + repository_root: &Path, + retention_references: &[String], + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + let closed = if include_closed_pull_requests { + git_worktree::github_closed_pull_request_heads_with_options(repository_root, options)? + } else { + ClosedPullRequestHeads::new() + }; + let stale_open = if let Some(cutoff_ms) = stale_open_pull_request_cutoff_ms { + git_worktree::github_stale_open_pull_request_heads( + repository_root, + cutoff_ms, + options.command_timeout_ms, + )? + } else { + StaleOpenPullRequestHeads::new() + }; + plan_git_clone_reclaim_with_pull_request_heads( + repository_root, + retention_references, + &closed, + &stale_open, + stale_open_pull_request_cutoff_ms, + options, + generated_at_ms, + ) +} + +pub fn approve_git_clone_reclaim( + plan: &GitCloneReclaimPlan, + exact_approval_phrase: &str, + approved_at_ms: u64, + approved_by: &str, + rationale: &str, +) -> Result { + if !plan.eligible_after_human_approval + || plan.exact_approval_phrase.as_deref() != Some(exact_approval_phrase) + || approved_at_ms < plan.generated_at_ms + { + return Err("git-clone-approval-plan-mismatch".into()); + } + if !valid_human_text(approved_by, 256) || !valid_human_text(rationale, 1_000) { + return Err("git-clone-approval-text-invalid".into()); + } + Ok(GitCloneReclaimApproval { + version: GIT_CLONE_RECLAIM_VERSION, + approval_id: approval_id(plan, approved_at_ms, approved_by), + plan_fingerprint: plan.plan_fingerprint.clone(), + exact_approval_phrase: exact_approval_phrase.into(), + approved_at_ms, + approved_by: approved_by.into(), + rationale: rationale.into(), + }) +} + +/// Re-resolve GitHub and filesystem evidence, then move the exact clone object to OS Trash. +pub fn execute_git_clone_reclaim( + approved_plan: &GitCloneReclaimPlan, + approval: &GitCloneReclaimApproval, + retention_references: &[String], + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + journal_path: &Path, + requested_at_ms: u64, +) -> Result { + if approval.version != GIT_CLONE_RECLAIM_VERSION + || approval.plan_fingerprint != approved_plan.plan_fingerprint + || approved_plan.exact_approval_phrase.as_deref() + != Some(approval.exact_approval_phrase.as_str()) + { + return Err("git-clone-execution-approval-invalid-or-stale".into()); + } + ensure_git_clone_approval_fresh(approval, requested_at_ms)?; + validate_journal_destination(Path::new(&approved_plan.repository_root), journal_path)?; + let live = plan_git_clone_reclaim( + Path::new(&approved_plan.repository_root), + retention_references, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + requested_at_ms, + )?; + if live.plan_fingerprint != approved_plan.plan_fingerprint + || live.repository_object_id != approved_plan.repository_object_id + || !live.eligible_after_human_approval + { + return Err("git-clone-live-plan-mismatch".into()); + } + ensure_git_clone_approval_fresh(approval, crate::cloud::system_now_ms())?; + crate::safety::trash_delete_if_identity( + Path::new(&live.repository_root), + &live.repository_object_id, + live.size.allocated_bytes, + journal_path, + requested_at_ms, + ) + .map_err(|error| format!("git-clone-trash-failed:{error}"))?; + let path_absence_verified = matches!( + std::fs::symlink_metadata(&live.repository_root), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ); + if !path_absence_verified { + return Err("git-clone-trash-path-still-present".into()); + } + Ok(GitCloneReclaimResult { + version: GIT_CLONE_RECLAIM_VERSION, + approval_id: approval.approval_id.clone(), + plan_fingerprint: live.plan_fingerprint, + requested_at_ms, + completed_at_ms: crate::cloud::system_now_ms(), + allocated_bytes_upper_bound: live.size.allocated_bytes, + trash_move_executed: true, + path_absence_verified, + branch_delete_command_executed: false, + git_prune_executed: false, + physically_reclaimed_bytes: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn git(repository: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repository) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() + } + + #[test] + fn approval_freshness_is_rechecked_at_mutation_boundary() { + let approval = GitCloneReclaimApproval { + version: GIT_CLONE_RECLAIM_VERSION, + approval_id: "approval".into(), + plan_fingerprint: "plan".into(), + exact_approval_phrase: "phrase".into(), + approved_at_ms: 100, + approved_by: "human:test".into(), + rationale: "reviewed".into(), + }; + assert!(ensure_git_clone_approval_fresh(&approval, 100 + MAX_APPROVAL_AGE_MS).is_ok()); + assert_eq!( + ensure_git_clone_approval_fresh(&approval, 101 + MAX_APPROVAL_AGE_MS).unwrap_err(), + "git-clone-execution-approval-invalid-or-stale" + ); + assert_eq!( + ensure_git_clone_approval_fresh(&approval, 99).unwrap_err(), + "git-clone-execution-approval-invalid-or-stale" + ); + } + + #[cfg(unix)] + #[test] + fn exact_closed_pr_head_authorizes_only_clean_inactive_single_clone() { + let repository = tempfile::tempdir().unwrap(); + git(repository.path(), &["init", "-b", "main"]); + git( + repository.path(), + &["config", "user.email", "clone@example.invalid"], + ); + git( + repository.path(), + &["config", "user.name", "DiskSage Clone Test"], + ); + std::fs::write(repository.path().join("tracked.txt"), b"main\n").unwrap(); + git(repository.path(), &["add", "tracked.txt"]); + git(repository.path(), &["commit", "-m", "main"]); + git(repository.path(), &["switch", "-c", "old-pr"]); + std::fs::write(repository.path().join("tracked.txt"), b"old pr\n").unwrap(); + git(repository.path(), &["commit", "-am", "old pr"]); + let head = git(repository.path(), &["rev-parse", "HEAD"]); + let closed = ClosedPullRequestHeads::from([("refs/heads/old-pr".into(), head)]); + + let plan = plan_git_clone_reclaim_with_pull_request_heads( + repository.path(), + &["refs/heads/main".into()], + &closed, + &StaleOpenPullRequestHeads::new(), + None, + GitWorktreeAuditOptions::default(), + 10, + ) + .unwrap(); + + assert!(plan.eligible_after_human_approval, "{:?}", plan.blockers); + assert!(plan.closed_pull_request_head); + assert!(!plan.stale_open_pull_request_head); + assert!(plan.exact_approval_phrase.is_some()); + assert!(!plan.filesystem_mutation_executed); + assert!(repository.path().exists()); + } + + #[cfg(unix)] + #[test] + fn dirty_clone_never_receives_approval_authority() { + let repository = tempfile::tempdir().unwrap(); + git(repository.path(), &["init", "-b", "main"]); + git( + repository.path(), + &["config", "user.email", "clone@example.invalid"], + ); + git( + repository.path(), + &["config", "user.name", "DiskSage Clone Test"], + ); + std::fs::write(repository.path().join("tracked.txt"), b"main\n").unwrap(); + git(repository.path(), &["add", "tracked.txt"]); + git(repository.path(), &["commit", "-m", "main"]); + git(repository.path(), &["switch", "-c", "old-pr"]); + std::fs::write(repository.path().join("tracked.txt"), b"old pr\n").unwrap(); + git(repository.path(), &["commit", "-am", "old pr"]); + let head = git(repository.path(), &["rev-parse", "HEAD"]); + std::fs::write(repository.path().join("untracked.txt"), b"keep me\n").unwrap(); + let closed = ClosedPullRequestHeads::from([("refs/heads/old-pr".into(), head)]); + + let plan = plan_git_clone_reclaim_with_pull_request_heads( + repository.path(), + &["refs/heads/main".into()], + &closed, + &StaleOpenPullRequestHeads::new(), + None, + GitWorktreeAuditOptions::default(), + 10, + ) + .unwrap(); + + assert!(!plan.eligible_after_human_approval); + assert!(plan + .blockers + .contains(&"git-clone-working-tree-not-clean".into())); + assert!(approve_git_clone_reclaim(&plan, "wrong", 11, "human:test", "reviewed").is_err()); + assert!(repository.path().join("untracked.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn symlinked_git_directory_is_not_a_standalone_clone() { + let repository = tempfile::tempdir().unwrap(); + git(repository.path(), &["init", "-b", "main"]); + git( + repository.path(), + &["config", "user.email", "clone@example.invalid"], + ); + git( + repository.path(), + &["config", "user.name", "DiskSage Clone Test"], + ); + std::fs::write(repository.path().join("tracked.txt"), b"main\n").unwrap(); + git(repository.path(), &["add", "tracked.txt"]); + git(repository.path(), &["commit", "-m", "main"]); + let head = git(repository.path(), &["rev-parse", "HEAD"]); + let git_directory = repository.path().join(".git"); + let real_git_directory = repository.path().join(".git-real"); + std::fs::rename(&git_directory, &real_git_directory).unwrap(); + std::os::unix::fs::symlink(".git-real", &git_directory).unwrap(); + + let closed = ClosedPullRequestHeads::from([("refs/heads/main".into(), head)]); + let plan = plan_git_clone_reclaim_with_pull_request_heads( + repository.path(), + &["refs/heads/main".into()], + &closed, + &StaleOpenPullRequestHeads::new(), + None, + GitWorktreeAuditOptions::default(), + 10, + ) + .unwrap(); + + assert!(!plan.eligible_after_human_approval); + assert!(plan + .blockers + .contains(&"git-clone-git-directory-not-real-or-bounded".into())); + } + + #[test] + fn journal_destination_must_be_outside_the_clone() { + let repository = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let inside = repository.path().join("journal.jsonl"); + assert_eq!( + validate_journal_destination(repository.path(), &inside).unwrap_err(), + "git-clone-journal-inside-source" + ); + + let relative = Path::new("journal.jsonl"); + assert_eq!( + validate_journal_destination(repository.path(), relative).unwrap_err(), + "git-clone-journal-path-invalid" + ); + + let journal = outside.path().join("journal.jsonl"); + assert!(validate_journal_destination(repository.path(), &journal).is_ok()); + } + + #[cfg(unix)] + #[test] + fn stale_open_authority_requires_an_explicit_cutoff() { + let repository = tempfile::tempdir().unwrap(); + git(repository.path(), &["init", "-b", "main"]); + git( + repository.path(), + &["config", "user.email", "clone@example.invalid"], + ); + git( + repository.path(), + &["config", "user.name", "DiskSage Clone Test"], + ); + std::fs::write(repository.path().join("tracked.txt"), b"main\n").unwrap(); + git(repository.path(), &["add", "tracked.txt"]); + git(repository.path(), &["commit", "-m", "main"]); + git(repository.path(), &["switch", "-c", "open-pr"]); + std::fs::write(repository.path().join("tracked.txt"), b"open\n").unwrap(); + git(repository.path(), &["commit", "-am", "open"]); + let head = git(repository.path(), &["rev-parse", "HEAD"]); + let stale = StaleOpenPullRequestHeads::from([( + ("refs/heads/open-pr".into(), head), + std::collections::BTreeSet::from([1]), + )]); + + let error = plan_git_clone_reclaim_with_pull_request_heads( + repository.path(), + &["refs/heads/main".into()], + &ClosedPullRequestHeads::new(), + &stale, + None, + GitWorktreeAuditOptions::default(), + 10, + ) + .unwrap_err(); + assert_eq!(error, "git-worktree-stale-open-pull-request-heads-invalid"); + } +} diff --git a/src-tauri/src/git_worktree.rs b/src-tauri/src/git_worktree.rs index b6eebacc4..bef765b9f 100644 --- a/src-tauri/src/git_worktree.rs +++ b/src-tauri/src/git_worktree.rs @@ -6,7 +6,7 @@ //! it is neither locked nor prunable, and no active CWD or open-file consumer is observed. The //! resulting approval phrase is evidence, not execution. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fs; use std::io::{Read, Write}; @@ -23,16 +23,17 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; -pub const GIT_WORKTREE_AUDIT_SCHEMA_KIND: &str = "disksage.git-worktree-audit/v2"; +pub const GIT_WORKTREE_AUDIT_SCHEMA_KIND: &str = "disksage.git-worktree-audit/v4"; const MAX_COMMAND_OUTPUT_BYTES: usize = 4 * 1024 * 1024; /// Maximum UTF-8 byte length accepted for a Git reference at the audit boundary. pub const MAX_REFERENCE_BYTES: usize = 1_024; const MAX_REACHABLE_COMMITS: usize = 100_000; -const GIT_WORKTREE_REMOVAL_VERSION: u32 = 1; +const GIT_WORKTREE_REMOVAL_VERSION: u32 = 2; const MAX_RATIONALE_BYTES: usize = 1_000; const MAX_ADMIN_FALLBACK_ENTRIES: usize = 512; const MAX_ADMIN_FALLBACK_FILE_BYTES: u64 = 16 * 1024; const POLL_INTERVAL_MS: u64 = 10; +const GITHUB_SEARCH_INTERVAL_MS: u64 = 2_100; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -49,7 +50,7 @@ impl Default for GitWorktreeAuditOptions { Self { command_timeout_ms: 10_000, size_scan_timeout_ms: 60_000, - max_worktrees: 512, + max_worktrees: 2_048, max_entries_per_worktree: 2_000_000, max_active_pids: 64, } @@ -105,6 +106,15 @@ pub struct GitWorktreeAuditEntry { pub status_clean: Option, pub status_entry_count: Option, pub contained_in_reference: Option, + pub closed_pull_request_head: bool, + /// The exact worktree commit occurs in a completed same-repository pull request. + #[serde(default)] + pub completed_pull_request_commit: bool, + /// The exact worktree commit occurs in an open same-repository pull request and must be kept. + #[serde(default)] + pub open_pull_request_commit: bool, + #[serde(default)] + pub stale_open_pull_request_head: bool, pub head_is_retained_tip: bool, pub actor_cwd_inside: Option, pub size: GitWorktreeSizeEvidence, @@ -129,8 +139,11 @@ pub struct GitWorktreeAuditReport { pub repository_root: String, pub common_dir: String, pub generated_at_ms: u64, + #[serde(default)] + pub stale_open_pull_request_cutoff_ms: Option, pub retention_references: Vec, pub retention_reference_set_fingerprint: String, + pub removal_authority_fingerprint: String, pub retention_reachable_commit_count: usize, pub worktree_count: usize, pub removal_candidate_count: usize, @@ -150,8 +163,10 @@ pub struct GitWorktreeAuditPublicSummary { pub schema_kind: String, pub version: u32, pub generated_at_ms: u64, + pub stale_open_pull_request_cutoff_ms: Option, pub retention_reference_count: usize, pub retention_reference_set_fingerprint: String, + pub removal_authority_fingerprint: String, pub retention_reachable_commit_count: usize, pub worktree_count: usize, pub removal_candidate_count: usize, @@ -175,6 +190,7 @@ pub struct GitWorktreeRemovalApproval { pub approval_id: String, pub removal_plan_fingerprint: String, pub retention_reference_set_fingerprint: String, + pub removal_authority_fingerprint: String, pub removal_candidate_count: usize, pub removal_candidate_allocated_bytes: u64, pub exact_approval_phrase: String, @@ -208,6 +224,7 @@ pub struct GitWorktreeRemovalResult { pub approval_id: String, pub removal_plan_fingerprint: String, pub retention_reference_set_fingerprint: String, + pub removal_authority_fingerprint: String, pub requested_at_ms: u64, pub completed_at_ms: u64, pub planned_candidate_count: usize, @@ -302,6 +319,10 @@ struct ClassificationInput { path_valid: bool, status_clean: Option, contained_in_reference: Option, + closed_pull_request_head: bool, + completed_pull_request_commit: bool, + open_pull_request_commit: bool, + stale_open_pull_request_head: bool, head_is_retained_tip: bool, actor_cwd_inside: Option, size_complete: bool, @@ -311,7 +332,7 @@ struct ClassificationInput { } fn validate_options(options: GitWorktreeAuditOptions) -> Result<(), String> { - if options.command_timeout_ms == 0 || options.command_timeout_ms > 300_000 { + if options.command_timeout_ms == 0 || options.command_timeout_ms > 3_600_000 { return Err("git-worktree-command-timeout-out-of-bounds".into()); } if options.size_scan_timeout_ms == 0 || options.size_scan_timeout_ms > 600_000 { @@ -385,6 +406,8 @@ fn run_bounded_command( .stderr(Stdio::piped()); if program == "git" { command.env("GIT_OPTIONAL_LOCKS", "0"); + } else if program == "gh" { + command.env_remove("GH_REPO"); } #[cfg(unix)] // Keep descendants in a private process group so a timeout cannot leave a Git helper holding @@ -474,6 +497,537 @@ fn run_git( Ok(result) } +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct GitHubPullRequestHead { + #[serde(default)] + number: Option, + #[serde(rename = "headRefName")] + head_ref_name: String, + #[serde(rename = "headRefOid")] + head_ref_oid: String, + #[serde(rename = "isCrossRepository")] + is_cross_repository: bool, + #[serde(rename = "createdAt")] + created_at: Option, + state: String, +} + +#[derive(serde::Deserialize)] +struct GitHubRestSearchPullRequest { + number: u64, + state: String, + repository_url: String, +} + +#[derive(serde::Deserialize)] +struct GitHubRestSearchResponse { + total_count: u64, + items: Vec, +} + +pub type ClosedPullRequestHeads = BTreeSet<(String, String)>; +pub type StaleOpenPullRequestHeads = BTreeMap<(String, String), BTreeSet>; +pub type PullRequestCommits = BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PullRequestCommitMembership { + pub completed: PullRequestCommits, + pub open: BTreeMap>, +} + +fn retain_registered_pull_request_membership( + membership: PullRequestCommitMembership, + registered_heads: &BTreeSet, +) -> PullRequestCommitMembership { + PullRequestCommitMembership { + completed: membership + .completed + .intersection(registered_heads) + .cloned() + .collect(), + open: membership + .open + .into_iter() + .filter(|(head, _)| registered_heads.contains(head)) + .collect(), + } +} + +fn parse_closed_pull_request_heads(bytes: &[u8]) -> Result { + let records: Vec = + serde_json::from_slice(bytes).map_err(|_| "github-closed-pr-json-invalid".to_string())?; + let closed_heads: ClosedPullRequestHeads = records + .into_iter() + .filter(|record| { + matches!(record.state.as_str(), "CLOSED" | "MERGED") && !record.is_cross_repository + }) + .map(|record| { + let oid = record.head_ref_oid.to_ascii_lowercase(); + let branch_ref = format!("refs/heads/{}", record.head_ref_name); + validate_reference(&branch_ref)?; + if !is_oid(&oid) { + return Err("github-closed-pr-head-invalid".to_string()); + } + Ok((branch_ref, oid)) + }) + .collect::>()?; + if closed_heads.len() > 10_000 { + return Err("github-closed-pr-count-exceeds-limit".into()); + } + Ok(closed_heads) +} + +fn parse_open_pull_request_heads(bytes: &[u8]) -> Result { + let records: Vec = + serde_json::from_slice(bytes).map_err(|_| "github-open-pr-json-invalid".to_string())?; + records + .into_iter() + .filter(|record| record.state == "OPEN" && !record.is_cross_repository) + .map(|record| { + let oid = record.head_ref_oid.to_ascii_lowercase(); + let branch_ref = format!("refs/heads/{}", record.head_ref_name); + validate_reference(&branch_ref)?; + if !is_oid(&oid) { + return Err("github-open-pr-head-invalid".to_string()); + } + Ok((branch_ref, oid)) + }) + .collect() +} + +fn parse_exact_pull_request_commit_membership( + bytes: &[u8], +) -> Result { + let records: Vec = serde_json::from_slice(bytes) + .map_err(|_| "github-exact-pr-membership-json-invalid".to_string())?; + let mut membership = PullRequestCommitMembership::default(); + for record in records { + if record.is_cross_repository { + continue; + } + let number = record + .number + .filter(|number| *number > 0) + .ok_or_else(|| "github-exact-pr-number-invalid".to_string())?; + let oid = record.head_ref_oid.to_ascii_lowercase(); + if !is_oid(&oid) { + return Err("github-exact-pr-head-invalid".into()); + } + match record.state.as_str() { + "OPEN" => { + membership.open.entry(oid).or_default().insert(number); + } + "CLOSED" | "MERGED" => { + membership.completed.insert(oid); + } + _ => return Err("github-exact-pr-state-invalid".into()), + } + } + Ok(membership) +} + +fn github_pull_request_heads_result( + repository_root: &Path, + timeout_ms: u64, + reason: &str, +) -> Result { + let result = run_bounded_command( + "gh", + &[ + OsString::from("api"), + OsString::from("--paginate"), + OsString::from("repos/{owner}/{repo}/pulls?state=all&per_page=100"), + OsString::from("--jq"), + OsString::from( + ".[] | {number, headRefName:.head.ref, headRefOid:.head.sha, isCrossRepository:(.head.repo.full_name != .base.repo.full_name), createdAt:.created_at, state:(if .state == \"open\" then \"OPEN\" elif .merged_at != null then \"MERGED\" else \"CLOSED\" end)}", + ), + ], + repository_root, + timeout_ms, + )?; + if result.timed_out { + return Err(format!("{reason}-timeout")); + } + if result.stdout_truncated || result.stderr_truncated { + return Err(format!("{reason}-output-truncated")); + } + if result.status_code != Some(0) { + return Err(format!("{reason}-failed")); + } + let records = serde_json::Deserializer::from_slice(&result.stdout) + .into_iter::() + .collect::, _>>() + .map_err(|_| format!("{reason}-json-invalid"))?; + Ok(CommandResult { + stdout: serde_json::to_vec(&records).map_err(|_| format!("{reason}-json-invalid"))?, + ..result + }) +} + +fn parse_pull_request_rest_search( + bytes: &[u8], + repository: &str, +) -> Result, String> { + let response: GitHubRestSearchResponse = serde_json::from_slice(bytes) + .map_err(|_| "github-pr-commit-search-json-invalid".to_string())?; + if response.total_count > 100 || response.items.len() > 100 { + return Err("github-pr-commit-search-incomplete".into()); + } + let expected_suffix = format!("/repos/{repository}"); + response + .items + .into_iter() + .map(|record| { + if !record.repository_url.ends_with(&expected_suffix) { + return Err("github-pr-commit-search-repository-mismatch".into()); + } + match record.state.as_str() { + "open" => Ok((record.number, true)), + "closed" => Ok((record.number, false)), + _ => Err("github-pr-commit-search-state-invalid".into()), + } + }) + .collect() +} + +fn pull_request_contains_commit(bytes: &[u8], head: &str) -> Result { + let text = command_text(bytes, "github-pr-commits-not-utf8")?; + let mut count = 0usize; + let mut found = false; + for line in text.lines() { + let oid = line.trim().to_ascii_lowercase(); + if !is_oid(&oid) { + return Err("github-pr-commit-invalid".into()); + } + count = count.saturating_add(1); + found |= oid == head; + } + if count > 10_000 { + return Err("github-pr-commit-count-exceeds-limit".into()); + } + Ok(found) +} + +fn parse_github_timestamp_ms(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || bytes[19] != b'Z' + { + return None; + } + let number = |start: usize, end: usize| value.get(start..end)?.parse::().ok(); + let year = number(0, 4)?; + let month = number(5, 7)?; + let day = number(8, 10)?; + let hour = number(11, 13)?; + let minute = number(14, 16)?; + let second = number(17, 19)?; + if year == 0 + || !(1..=12).contains(&month) + || day == 0 + || day > days_in_month(year, month) + || hour > 23 + || minute > 59 + || second > 59 + { + return None; + } + let days = days_before_year(year)? + .checked_add(days_before_month(year, month))? + .checked_add(day - 1)? + .checked_sub(days_before_year(1970)?)?; + days.checked_mul(86_400_000)? + .checked_add(hour * 3_600_000)? + .checked_add(minute * 60_000)? + .checked_add(second * 1_000) +} + +fn is_leap_year(year: u64) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +fn days_in_month(year: u64, month: u64) -> u64 { + match month { + 2 if is_leap_year(year) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn days_before_year(year: u64) -> Option { + let prior = year.checked_sub(1)?; + year.checked_mul(365)? + .checked_add(prior / 4)? + .checked_sub(prior / 100)? + .checked_add(prior / 400) +} + +fn days_before_month(year: u64, month: u64) -> u64 { + (1..month) + .map(|candidate| days_in_month(year, candidate)) + .sum() +} + +fn parse_stale_open_pull_request_heads( + bytes: &[u8], + cutoff_ms: u64, +) -> Result { + let records: Vec = + serde_json::from_slice(bytes).map_err(|_| "github-open-pr-json-invalid".to_string())?; + let mut stale_heads = StaleOpenPullRequestHeads::new(); + for record in records { + if record.state != "OPEN" || record.is_cross_repository { + continue; + } + let created_at = record + .created_at + .as_deref() + .ok_or_else(|| "github-open-pr-created-at-missing".to_string())?; + let created_ms = parse_github_timestamp_ms(created_at) + .ok_or_else(|| "github-open-pr-created-at-invalid".to_string())?; + if created_ms >= cutoff_ms { + continue; + } + let head_ref_name = record.head_ref_name; + let head_ref_oid = record.head_ref_oid; + let binding = { + let oid = head_ref_oid.to_ascii_lowercase(); + let branch_ref = format!("refs/heads/{head_ref_name}"); + validate_reference(&branch_ref)?; + if !is_oid(&oid) { + return Err("github-open-pr-head-invalid".to_string()); + } + (branch_ref, oid) + }; + let number = record + .number + .filter(|number| *number > 0) + .ok_or_else(|| "github-open-pr-number-missing".to_string())?; + stale_heads.entry(binding).or_default().insert(number); + } + if stale_heads.len() > 10_000 { + return Err("github-open-pr-count-exceeds-limit".into()); + } + Ok(stale_heads) +} + +/// Resolve exact head OIDs for same-repository GitHub pull requests that are closed or merged. +/// +/// The authenticated `gh` client resolves repository identity from the selected repository and +/// returns only bounded JSON. Runtime diagnostics are never reflected to the caller. +pub fn github_closed_pull_request_heads( + repository_root: &Path, + timeout_ms: u64, +) -> Result { + github_closed_pull_request_heads_with_options( + repository_root, + GitWorktreeAuditOptions { + command_timeout_ms: timeout_ms, + ..GitWorktreeAuditOptions::default() + }, + ) +} + +/// Resolve closed or merged pull-request heads within the caller's worktree bounds. +pub fn github_closed_pull_request_heads_with_options( + repository_root: &Path, + options: GitWorktreeAuditOptions, +) -> Result { + validate_options(options)?; + let result = github_pull_request_heads_result( + repository_root, + options.command_timeout_ms, + "github-closed-pr-list", + )?; + let mut heads = parse_closed_pull_request_heads(&result.stdout)?; + let open_vetoes = parse_open_pull_request_heads(&result.stdout)?; + heads.retain(|binding| !open_vetoes.contains(binding)); + Ok(heads) +} + +/// Resolve exact commit membership for the repository's registered worktrees. +/// +/// Search results are only discovery hints: every hit is rebound to the exact repository and then +/// verified against the pull request's authoritative commit list. Open membership is retained +/// separately so it can veto every removal authority, including a second completed PR containing +/// the same commit. +pub fn github_pull_request_commit_membership( + repository_root: &Path, + options: GitWorktreeAuditOptions, +) -> Result { + github_pull_request_commit_membership_with_exact( + repository_root, + options, + PullRequestCommitMembership::default(), + ) +} + +pub(crate) fn github_exact_pull_request_commit_membership( + repository_root: &Path, + timeout_ms: u64, +) -> Result { + let result = github_pull_request_heads_result( + repository_root, + timeout_ms, + "github-exact-pr-membership", + )?; + parse_exact_pull_request_commit_membership(&result.stdout) +} + +pub(crate) fn github_pull_request_commit_membership_with_exact( + repository_root: &Path, + options: GitWorktreeAuditOptions, + exact: PullRequestCommitMembership, +) -> Result { + validate_options(options)?; + let started = Instant::now(); + let remaining = || { + options + .command_timeout_ms + .saturating_sub(started.elapsed().as_millis() as u64) + }; + let run = |args: &[OsString], reason: &str| -> Result { + let timeout_ms = remaining(); + if timeout_ms == 0 { + return Err(format!("{reason}-timeout")); + } + let result = run_bounded_command("gh", args, repository_root, timeout_ms)?; + if result.timed_out { + return Err(format!("{reason}-timeout")); + } + if result.stdout_truncated || result.stderr_truncated { + return Err(format!("{reason}-output-truncated")); + } + if result.status_code != Some(0) { + return Err(format!("{reason}-failed")); + } + Ok(result) + }; + + let repository_result = run( + &[ + OsString::from("api"), + OsString::from("repos/{owner}/{repo}"), + OsString::from("--jq"), + OsString::from(".full_name"), + ], + "github-repository-identity", + )?; + let repository = command_text( + &repository_result.stdout, + "github-repository-identity-not-utf8", + )? + .trim(); + let repository_parts = repository.split('/').collect::>(); + if repository_parts.len() != 2 + || repository_parts.iter().any(|part| part.is_empty()) + || !repository + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_./".contains(&byte)) + { + return Err("github-repository-identity-invalid".into()); + } + + let registered_heads = list_worktrees(repository_root, options)? + .into_iter() + .map(|worktree| worktree.head) + .collect::>(); + let exact = retain_registered_pull_request_membership(exact, ®istered_heads); + let heads = registered_heads + .into_iter() + .filter(|head| !exact.completed.contains(head) && !exact.open.contains_key(head)) + .collect::>(); + let mut membership = exact; + let mut discovered = Vec::new(); + for (index, head) in heads.iter().enumerate() { + let timeout_ms = remaining(); + if timeout_ms == 0 { + return Err("github-pr-commit-search-timeout".into()); + } + let args = [ + OsString::from("api"), + OsString::from("-X"), + OsString::from("GET"), + OsString::from("search/issues"), + OsString::from("-f"), + OsString::from(format!("q={head} repo:{repository} is:pr")), + OsString::from("-f"), + OsString::from("per_page=100"), + ]; + let result = run_bounded_command("gh", &args, repository_root, timeout_ms)?; + if result.timed_out { + return Err("github-pr-commit-search-timeout".into()); + } + if result.stdout_truncated || result.stderr_truncated { + return Err("github-pr-commit-search-output-truncated".into()); + } + if result.status_code != Some(0) { + return Err("github-pr-commit-search-failed".into()); + } + for candidate in parse_pull_request_rest_search(&result.stdout, repository)? { + discovered.push((head.clone(), candidate)); + } + if index + 1 < heads.len() { + let delay_ms = remaining().min(GITHUB_SEARCH_INTERVAL_MS); + if delay_ms < GITHUB_SEARCH_INTERVAL_MS { + return Err("github-pr-commit-search-timeout".into()); + } + thread::sleep(Duration::from_millis(delay_ms)); + } + } + let mut pull_requests = BTreeMap::<(u64, bool), BTreeSet>::new(); + for (head, pull_request) in discovered { + pull_requests.entry(pull_request).or_default().insert(head); + } + for ((number, open), heads) in pull_requests { + let commits = run( + &[ + OsString::from("api"), + OsString::from("--paginate"), + OsString::from(format!( + "repos/{repository}/pulls/{number}/commits?per_page=100" + )), + OsString::from("--jq"), + OsString::from(".[].sha"), + ], + "github-pr-commits", + )?; + for head in heads { + if pull_request_contains_commit(&commits.stdout, &head)? { + if open { + membership.open.entry(head).or_default().insert(number); + } else { + membership.completed.insert(head); + } + } + } + } + Ok(membership) +} + +/// Resolve exact head OIDs for same-repository open pull requests created before an explicit cutoff. +/// +/// The cutoff is supplied by the operator; DiskSage never chooses an age threshold implicitly. +/// GitHub state, repository identity, branch name, head OID, and creation timestamp are all +/// refreshed before a plan and before each removal. +pub fn github_stale_open_pull_request_heads( + repository_root: &Path, + cutoff_ms: u64, + timeout_ms: u64, +) -> Result { + let result = + github_pull_request_heads_result(repository_root, timeout_ms, "github-open-pr-list")?; + parse_stale_open_pull_request_heads(&result.stdout, cutoff_ms) +} + +#[cfg(test)] fn git_admin_metadata_blocker( status: &crate::provider_sync::FileProviderItemStatus, ) -> Option<&'static str> { @@ -486,13 +1040,13 @@ fn check_file_provider_git_metadata(path: &Path) -> Result, .map_err(|_| "git-worktree-admin-metadata-stat-failed".to_string())?; let output = match crate::provider_sync::file_providerctl_status(&path.to_string_lossy()) { Ok(output) => output, - // A regular local file is not a File Provider item; Git can inspect it normally. Err(error) if error == "file-provider-status-command-failed" => return Ok(None), Err(error) => return Err(format!("git-worktree-admin-metadata-{error}")), }; - let status = crate::provider_sync::parse_file_providerctl_item_status(&output, metadata.len()) - .map_err(|error| format!("git-worktree-admin-metadata-{error}"))?; - Ok(git_admin_metadata_blocker(&status)) + let local_current = + crate::provider_sync::parse_file_providerctl_local_current(&output, metadata.len()) + .map_err(|error| format!("git-worktree-admin-metadata-{error}"))?; + Ok((!local_current).then_some("git-worktree-admin-metadata-not-local-current")) } #[cfg(all(target_os = "macos", not(coverage)))] @@ -691,14 +1245,12 @@ fn skipped_active_use(reason: &str) -> GitWorktreeActiveUseEvidence { } #[cfg(unix)] -pub(crate) fn active_use_evidence( +pub fn active_use_evidence( path: &Path, timeout_ms: u64, max_pids: usize, recursive: bool, ) -> GitWorktreeActiveUseEvidence { - // Running lsof with its own CWD inside the audited tree would make the probe observe itself. - // A canonical worktree has an existing parent, which is outside the candidate directory. let command_cwd = path.parent().unwrap_or(path); let method = if recursive { "lsof-recursive-pid" @@ -762,9 +1314,6 @@ pub(crate) fn active_use_evidence( } let mut pids = BTreeSet::new(); for field in result.stdout.split(|byte| *byte == 0) { - // `lsof -F0` terminates each field with NUL and each process/file set with NL. Therefore - // every PID field after the first may begin with that set-separator newline. Strip exactly - // the protocol separator before interpreting the field; do not trim arbitrary bytes. let field = field.strip_prefix(b"\n").unwrap_or(field); let Some(raw_pid) = field.strip_prefix(b"p") else { continue; @@ -796,10 +1345,63 @@ pub(crate) fn active_use_evidence( } pids.insert(pid); } + let path_text = match path.to_str() { + Some(path) => path, + None => { + return GitWorktreeActiveUseEvidence { + method: "lsof-recursive-pid+ps-argv".into(), + assessed: true, + evidence_complete: false, + active: false, + observed_pids: Vec::new(), + results_truncated: false, + error: Some("active-use-path-not-utf8".into()), + }; + } + }; + let ps_args = [ + OsString::from("-ww"), + OsString::from("-axo"), + OsString::from("pid=,command="), + ]; + let ps = match run_bounded_command("ps", &ps_args, command_cwd, timeout_ms) { + Ok(result) + if !result.timed_out + && !result.stdout_truncated + && !result.stderr_truncated + && result.status_code == Some(0) => + { + result + } + _ => { + return GitWorktreeActiveUseEvidence { + method: "lsof-recursive-pid+ps-argv".into(), + assessed: true, + evidence_complete: false, + active: false, + observed_pids: Vec::new(), + results_truncated: false, + error: Some("active-use-process-argv-unavailable".into()), + }; + } + }; + for line in String::from_utf8_lossy(&ps.stdout).lines() { + let trimmed = line.trim_start(); + let Some((raw_pid, command)) = trimmed.split_once(char::is_whitespace) else { + continue; + }; + if command.contains(path_text) { + if let Ok(pid) = raw_pid.parse::() { + if pid != ps.child_pid && pid != std::process::id() { + pids.insert(pid); + } + } + } + } let results_truncated = pids.len() > max_pids; let observed_pids: Vec<_> = pids.into_iter().take(max_pids).collect(); GitWorktreeActiveUseEvidence { - method: method.into(), + method: format!("{method}+ps-argv"), assessed: true, evidence_complete: !results_truncated, active: !observed_pids.is_empty(), @@ -857,10 +1459,17 @@ fn candidate_blockers(input: &ClassificationInput) -> Vec { Some(false) => blockers.push("worktree-dirty".into()), None => blockers.push("git-status-evidence-incomplete".into()), } - match input.contained_in_reference { - Some(true) => {} - Some(false) => blockers.push("reference-does-not-contain-head".into()), - None => blockers.push("reference-containment-evidence-incomplete".into()), + if input.open_pull_request_commit { + blockers.push("open-pull-request-commit".into()); + } + match ( + input.contained_in_reference, + input.closed_pull_request_head || input.completed_pull_request_commit, + input.stale_open_pull_request_head, + ) { + (Some(true), _, _) | (_, true, _) | (_, _, true) => {} + (Some(false), false, false) => blockers.push("reference-does-not-contain-head".into()), + (None, false, false) => blockers.push("reference-containment-evidence-incomplete".into()), } if input.head_is_retained_tip { blockers.push("head-is-retained-tip".into()); @@ -928,6 +1537,77 @@ fn retention_reference_set_fingerprint(references: &[GitWorktreeReferenceBinding hasher.finalize().to_hex().to_string() } +fn removal_authority_fingerprint( + retention_fingerprint: &str, + closed_pull_request_heads: &ClosedPullRequestHeads, +) -> String { + removal_authority_fingerprint_with_open( + retention_fingerprint, + closed_pull_request_heads, + &BTreeMap::new(), + &BTreeSet::new(), + &BTreeMap::new(), + None, + ) +} + +fn removal_authority_fingerprint_with_open( + retention_fingerprint: &str, + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + completed_pull_request_commits: &PullRequestCommits, + open_pull_request_commits: &BTreeMap>, + stale_open_pull_request_cutoff_ms: Option, +) -> String { + let mut hasher = blake3::Hasher::new(); + if stale_open_pull_request_heads.is_empty() + && completed_pull_request_commits.is_empty() + && open_pull_request_commits.is_empty() + && stale_open_pull_request_cutoff_ms.is_none() + { + hasher.update(b"disksage.git-worktree-removal-authority\0v1\0"); + hash_field(&mut hasher, retention_fingerprint); + for (branch_ref, oid) in closed_pull_request_heads { + hash_field(&mut hasher, branch_ref); + hash_field(&mut hasher, oid); + } + return hasher.finalize().to_hex().to_string(); + } + hasher.update(b"disksage.git-worktree-removal-authority\0v2\0"); + hash_field(&mut hasher, retention_fingerprint); + hash_field( + &mut hasher, + &stale_open_pull_request_cutoff_ms + .map(|value| value.to_string()) + .unwrap_or_default(), + ); + for (branch_ref, oid) in closed_pull_request_heads { + hash_field(&mut hasher, "closed"); + hash_field(&mut hasher, branch_ref); + hash_field(&mut hasher, oid); + } + for ((branch_ref, oid), pull_request_numbers) in stale_open_pull_request_heads { + hash_field(&mut hasher, "stale-open"); + hash_field(&mut hasher, branch_ref); + hash_field(&mut hasher, oid); + for pull_request_number in pull_request_numbers { + hash_field(&mut hasher, &pull_request_number.to_string()); + } + } + for oid in completed_pull_request_commits { + hash_field(&mut hasher, "completed-commit"); + hash_field(&mut hasher, oid); + } + for (oid, pull_request_numbers) in open_pull_request_commits { + hash_field(&mut hasher, "open-commit-veto"); + hash_field(&mut hasher, oid); + for pull_request_number in pull_request_numbers { + hash_field(&mut hasher, &pull_request_number.to_string()); + } + } + hasher.finalize().to_hex().to_string() +} + fn entry_fingerprint( common_dir: &str, reference_set_fingerprint: &str, @@ -947,6 +1627,10 @@ fn entry_fingerprint( hasher.update(&[ u8::from(entry.status_clean == Some(true)), u8::from(entry.contained_in_reference == Some(true)), + u8::from(entry.closed_pull_request_head), + u8::from(entry.completed_pull_request_commit), + u8::from(entry.open_pull_request_commit), + u8::from(entry.stale_open_pull_request_head), u8::from(entry.head_is_retained_tip), u8::from(entry.actor_cwd_inside == Some(true)), u8::from(entry.locked), @@ -1330,8 +2014,101 @@ pub fn audit_git_worktrees( retention_references: &[String], options: GitWorktreeAuditOptions, generated_at_ms: u64, +) -> Result { + audit_git_worktrees_with_closed_pull_request_heads( + repository_root, + retention_references, + &BTreeSet::new(), + options, + generated_at_ms, + ) +} + +/// Audit worktrees while accepting exact head OIDs from authoritatively closed pull requests as +/// removal authority. Callers must refresh this evidence immediately before execution. +pub fn audit_git_worktrees_with_closed_pull_request_heads( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + audit_git_worktrees_with_pull_request_heads( + repository_root, + retention_references, + closed_pull_request_heads, + &BTreeMap::new(), + None, + options, + generated_at_ms, + ) +} + +/// Audit worktrees with exact same-repository closed and explicitly stale-open PR head evidence. +pub fn audit_git_worktrees_with_pull_request_heads( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + audit_git_worktrees_with_pull_request_membership( + repository_root, + retention_references, + closed_pull_request_heads, + stale_open_pull_request_heads, + &PullRequestCommitMembership::default(), + stale_open_pull_request_cutoff_ms, + options, + generated_at_ms, + ) +} + +/// Audit worktrees with exact PR-head evidence plus exact commit membership. +pub fn audit_git_worktrees_with_pull_request_membership( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + pull_request_commits: &PullRequestCommitMembership, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, ) -> Result { validate_options(options)?; + if closed_pull_request_heads.len() > 10_000 + || closed_pull_request_heads + .iter() + .any(|(branch_ref, oid)| validate_reference(branch_ref).is_err() || !is_oid(oid)) + { + return Err("git-worktree-closed-pull-request-heads-invalid".into()); + } + if stale_open_pull_request_heads.len() > 10_000 + || stale_open_pull_request_heads + .iter() + .any(|((branch_ref, oid), pull_request_numbers)| { + validate_reference(branch_ref).is_err() + || !is_oid(oid) + || pull_request_numbers.is_empty() + || pull_request_numbers.contains(&0) + }) + || (!stale_open_pull_request_heads.is_empty() + && stale_open_pull_request_cutoff_ms.is_none()) + { + return Err("git-worktree-stale-open-pull-request-heads-invalid".into()); + } + if pull_request_commits.completed.len() > options.max_worktrees + || pull_request_commits.open.len() > options.max_worktrees + || pull_request_commits + .completed + .iter() + .chain(pull_request_commits.open.keys()) + .any(|oid| !is_oid(oid)) + { + return Err("git-worktree-pull-request-commits-invalid".into()); + } if !repository_root.is_absolute() { return Err("git-worktree-repository-root-not-absolute".into()); } @@ -1343,7 +2120,15 @@ pub fn audit_git_worktrees( retention_references, options.command_timeout_ms, )?; - let reference_set_fingerprint = retention_reference_set_fingerprint(&retention_references); + let retention_fingerprint = retention_reference_set_fingerprint(&retention_references); + let authority_fingerprint = removal_authority_fingerprint_with_open( + &retention_fingerprint, + closed_pull_request_heads, + stale_open_pull_request_heads, + &pull_request_commits.completed, + &pull_request_commits.open, + stale_open_pull_request_cutoff_ms, + ); let retained_tip_oids: BTreeSet<_> = retention_references .iter() .map(|binding| binding.reference_oid.as_str()) @@ -1355,9 +2140,6 @@ pub fn audit_git_worktrees( )?; let (raw_worktrees, fallback_issues) = match list_worktrees(&repository_root, options) { Ok(raw_worktrees) => (raw_worktrees, Vec::new()), - // `run_git` appends `-timeout` to the operation reason. Only that typed-by-contract - // condition permits the read-only admin fallback; malformed output and spawn failures - // remain hard errors. Err(error) if error == GIT_WORKTREE_LIST_TIMEOUT => { admin_fallback_worktrees(&common_dir, options) } @@ -1384,6 +2166,22 @@ pub fn audit_git_worktrees( (None, None) }; let contained_in_reference = containment_observation(&raw.head, &reachable_commits); + let closed_pull_request_head = raw.branch.as_ref().is_some_and(|branch_ref| { + closed_pull_request_heads.contains(&(branch_ref.clone(), raw.head.clone())) + }); + let stale_open_pull_request_numbers = raw.branch.as_ref().and_then(|branch_ref| { + stale_open_pull_request_heads.get(&(branch_ref.clone(), raw.head.clone())) + }); + let stale_open_pull_request_head = stale_open_pull_request_numbers.is_some(); + let completed_pull_request_commit = pull_request_commits.completed.contains(&raw.head); + let open_pull_request_commit = + pull_request_commits + .open + .get(&raw.head) + .is_some_and(|pull_request_numbers| { + stale_open_pull_request_numbers + .is_none_or(|stale_numbers| !pull_request_numbers.is_subset(stale_numbers)) + }); let head_is_retained_tip = retained_tip_oids.contains(raw.head.as_str()); let size = if path_valid { size_evidence( @@ -1411,6 +2209,10 @@ pub fn audit_git_worktrees( path_valid, status_clean, contained_in_reference, + closed_pull_request_head, + completed_pull_request_commit, + open_pull_request_commit, + stale_open_pull_request_head, head_is_retained_tip, actor_cwd_inside, size_complete: size.evidence_complete, @@ -1459,6 +2261,10 @@ pub fn audit_git_worktrees( status_clean, status_entry_count, contained_in_reference, + closed_pull_request_head, + completed_pull_request_commit, + open_pull_request_commit, + stale_open_pull_request_head, head_is_retained_tip, actor_cwd_inside, size, @@ -1468,7 +2274,7 @@ pub fn audit_git_worktrees( entry_fingerprint: String::new(), }; entry.entry_fingerprint = - entry_fingerprint(&common_dir_string, &reference_set_fingerprint, &entry); + entry_fingerprint(&common_dir_string, &authority_fingerprint, &entry); if entry.disposition == GitWorktreeDisposition::EvidenceGap { issues.extend( entry @@ -1499,29 +2305,34 @@ pub fn audit_git_worktrees( .iter() .filter(|entry| entry.disposition == GitWorktreeDisposition::EvidenceGap) .count(); + let evidence_complete = issues.is_empty() && evidence_gap_count == 0; let removal_plan_fingerprint = - removal_plan_fingerprint(&common_dir_string, &reference_set_fingerprint, &entries); - let exact_approval_phrase = (removal_candidate_count > 0).then(|| { - format!( - "DiskSage stale worktree {removal_candidate_count} {removal_candidate_allocated_bytes} 승인 {removal_plan_fingerprint}" + removal_plan_fingerprint(&common_dir_string, &authority_fingerprint, &entries); + let exact_approval_phrase = (removal_candidate_count > 0 && evidence_complete).then(|| { + exact_removal_approval_phrase( + removal_candidate_count, + removal_candidate_allocated_bytes, + &removal_plan_fingerprint, ) }); Ok(GitWorktreeAuditReport { schema_kind: GIT_WORKTREE_AUDIT_SCHEMA_KIND.into(), - version: 2, + version: 4, repository_root: repository_root.to_string_lossy().into_owned(), common_dir: common_dir_string, generated_at_ms, + stale_open_pull_request_cutoff_ms, retention_references, - retention_reference_set_fingerprint: reference_set_fingerprint, + retention_reference_set_fingerprint: retention_fingerprint, + removal_authority_fingerprint: authority_fingerprint, retention_reachable_commit_count: reachable_commits.len(), worktree_count: entries.len(), removal_candidate_count, removal_candidate_allocated_bytes, preserved_count, evidence_gap_count, - evidence_complete: issues.is_empty(), + evidence_complete, removal_plan_fingerprint, exact_approval_phrase, entries, @@ -1535,8 +2346,10 @@ pub fn public_summary(report: &GitWorktreeAuditReport) -> GitWorktreeAuditPublic schema_kind: report.schema_kind.clone(), version: report.version, generated_at_ms: report.generated_at_ms, + stale_open_pull_request_cutoff_ms: report.stale_open_pull_request_cutoff_ms, retention_reference_count: report.retention_references.len(), retention_reference_set_fingerprint: report.retention_reference_set_fingerprint.clone(), + removal_authority_fingerprint: report.removal_authority_fingerprint.clone(), retention_reachable_commit_count: report.retention_reachable_commit_count, worktree_count: report.worktree_count, removal_candidate_count: report.removal_candidate_count, @@ -1584,11 +2397,12 @@ fn exact_removal_approval_phrase( fn validate_audit_for_removal(report: &GitWorktreeAuditReport) -> Result<(), String> { if report.schema_kind != GIT_WORKTREE_AUDIT_SCHEMA_KIND - || report.version != 2 + || report.version != 4 || report.filesystem_mutation_executed || !Path::new(&report.repository_root).is_absolute() || !Path::new(&report.common_dir).is_absolute() || !valid_hex64(&report.retention_reference_set_fingerprint) + || !valid_hex64(&report.removal_authority_fingerprint) || !valid_hex64(&report.removal_plan_fingerprint) { return Err("git-worktree-removal-audit-integrity-invalid".into()); @@ -1608,7 +2422,7 @@ fn validate_audit_for_removal(report: &GitWorktreeAuditReport) -> Result<(), Str || entry.entry_fingerprint != entry_fingerprint( &report.common_dir, - &report.retention_reference_set_fingerprint, + &report.removal_authority_fingerprint, entry, ) { @@ -1626,7 +2440,11 @@ fn validate_audit_for_removal(report: &GitWorktreeAuditReport) -> Result<(), Str || entry.prunable || entry.status_clean != Some(true) || entry.status_entry_count != Some(0) - || entry.contained_in_reference != Some(true) + || (entry.contained_in_reference != Some(true) + && !entry.closed_pull_request_head + && !entry.completed_pull_request_commit + && !entry.stale_open_pull_request_head) + || entry.open_pull_request_commit || entry.head_is_retained_tip || entry.actor_cwd_inside != Some(false) || !entry.size.evidence_complete @@ -1649,7 +2467,7 @@ fn validate_audit_for_removal(report: &GitWorktreeAuditReport) -> Result<(), Str || report.evidence_complete != (report.issues.is_empty() && evidence_gaps == 0) || removal_plan_fingerprint( &report.common_dir, - &report.retention_reference_set_fingerprint, + &report.removal_authority_fingerprint, &report.entries, ) != report.removal_plan_fingerprint { @@ -1680,6 +2498,7 @@ fn removal_approval_id_for( for value in [ report.removal_plan_fingerprint.as_str(), report.retention_reference_set_fingerprint.as_str(), + report.removal_authority_fingerprint.as_str(), report.exact_approval_phrase.as_deref().unwrap_or_default(), approved_by, rationale, @@ -1723,6 +2542,7 @@ pub fn approve_stale_worktree_removal( approval_id: removal_approval_id_for(report, approved_at_ms, approved_by, rationale), removal_plan_fingerprint: report.removal_plan_fingerprint.clone(), retention_reference_set_fingerprint: report.retention_reference_set_fingerprint.clone(), + removal_authority_fingerprint: report.removal_authority_fingerprint.clone(), removal_candidate_count: report.removal_candidate_count, removal_candidate_allocated_bytes: report.removal_candidate_allocated_bytes, exact_approval_phrase: expected.into(), @@ -1743,6 +2563,7 @@ fn validate_removal_approval( || approval.removal_plan_fingerprint != report.removal_plan_fingerprint || approval.retention_reference_set_fingerprint != report.retention_reference_set_fingerprint + || approval.removal_authority_fingerprint != report.removal_authority_fingerprint || approval.removal_candidate_count != report.removal_candidate_count || approval.removal_candidate_allocated_bytes != report.removal_candidate_allocated_bytes || approval.exact_approval_phrase @@ -1771,6 +2592,7 @@ fn live_audit_matches_approved( if live.common_dir != approved.common_dir || live.repository_root != approved.repository_root || live.retention_reference_set_fingerprint != approved.retention_reference_set_fingerprint + || live.removal_authority_fingerprint != approved.removal_authority_fingerprint || live.removal_plan_fingerprint != approved.removal_plan_fingerprint || live.removal_candidate_count != approved.removal_candidate_count || live.removal_candidate_allocated_bytes != approved.removal_candidate_allocated_bytes @@ -1871,6 +2693,46 @@ pub fn execute_stale_worktree_removal( confirmation_exact_approval_phrase: &str, options: GitWorktreeAuditOptions, requested_at_ms: u64, +) -> Result { + execute_stale_worktree_removal_with_github_closed_pull_requests( + approved_report, + approval, + confirmation_exact_approval_phrase, + false, + options, + requested_at_ms, + ) +} + +/// Execute with freshly queried GitHub closed-PR evidence bound once before mutation. +pub fn execute_stale_worktree_removal_with_github_closed_pull_requests( + approved_report: &GitWorktreeAuditReport, + approval: &GitWorktreeRemovalApproval, + confirmation_exact_approval_phrase: &str, + include_closed_pull_requests: bool, + options: GitWorktreeAuditOptions, + requested_at_ms: u64, +) -> Result { + execute_stale_worktree_removal_with_github_pull_requests( + approved_report, + approval, + confirmation_exact_approval_phrase, + include_closed_pull_requests, + None, + options, + requested_at_ms, + ) +} + +/// Execute with freshly queried same-repository closed and stale-open PR evidence bound once. +pub fn execute_stale_worktree_removal_with_github_pull_requests( + approved_report: &GitWorktreeAuditReport, + approval: &GitWorktreeRemovalApproval, + confirmation_exact_approval_phrase: &str, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + requested_at_ms: u64, ) -> Result { validate_options(options)?; validate_removal_approval( @@ -1887,8 +2749,25 @@ pub fn execute_stale_worktree_removal( .iter() .map(|binding| binding.reference_ref.clone()) .collect(); - let initial_live = - audit_git_worktrees(&repository_root, &reference_names, options, requested_at_ms)?; + let evidence = crate::git_worktree_github_evidence::collect( + &repository_root, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + )?; + let audit_live = |observed_at_ms| { + audit_git_worktrees_with_pull_request_membership( + &repository_root, + &reference_names, + &evidence.closed_heads, + &evidence.stale_open_heads, + &evidence.pull_request_commits, + stale_open_pull_request_cutoff_ms, + options, + observed_at_ms, + ) + }; + let initial_live = audit_live(requested_at_ms)?; live_audit_matches_approved(approved_report, &initial_live)?; let mut candidates: Vec<_> = initial_live @@ -1905,12 +2784,7 @@ pub fn execute_stale_worktree_removal( let live = if index == 0 { initial_live.clone() } else { - match audit_git_worktrees( - &repository_root, - &reference_names, - options, - current_unix_ms(), - ) { + match audit_live(current_unix_ms()) { Ok(report) => report, Err(error) => { let mut item = pending_item(candidate); @@ -2029,6 +2903,7 @@ pub fn execute_stale_worktree_removal( retention_reference_set_fingerprint: approved_report .retention_reference_set_fingerprint .clone(), + removal_authority_fingerprint: approved_report.removal_authority_fingerprint.clone(), requested_at_ms, completed_at_ms, planned_candidate_count: approved_report.removal_candidate_count, @@ -2194,10 +3069,137 @@ pub fn write_immutable_worktree_record( mod tests { use super::*; + #[cfg(unix)] + #[test] + fn active_use_detects_closed_script_path_in_process_arguments() { + let temporary = tempfile::tempdir().unwrap(); + let artifact = temporary.path().join("node_modules"); + std::fs::create_dir(&artifact).unwrap(); + let mut child = std::process::Command::new("sh") + .args([ + "-c", + "while :; do sleep 1; done", + artifact.to_str().unwrap(), + ]) + .spawn() + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let evidence = active_use_evidence(&artifact, 5_000, 64, true); + let _ = child.kill(); + let _ = child.wait(); + + assert!(evidence.evidence_complete, "{evidence:?}"); + assert!(evidence.active, "{evidence:?}"); + assert!(evidence.observed_pids.contains(&child.id()), "{evidence:?}"); + } + fn oid(character: char) -> String { std::iter::repeat_n(character, 40).collect() } + #[test] + fn closed_pull_request_evidence_binds_same_repository_branch_and_head() { + let json = format!( + r#"[ + {{"headRefName":"closed-local","headRefOid":"{}","isCrossRepository":false,"state":"CLOSED"}}, + {{"headRefName":"merged","headRefOid":"{}","isCrossRepository":false,"state":"MERGED"}}, + {{"headRefName":"forked","headRefOid":"{}","isCrossRepository":true,"state":"CLOSED"}} + ]"#, + oid('a'), + oid('b'), + oid('c'), + ); + assert_eq!( + parse_closed_pull_request_heads(json.as_bytes()).unwrap(), + BTreeSet::from([ + ("refs/heads/closed-local".into(), oid('a')), + ("refs/heads/merged".into(), oid('b')), + ]) + ); + } + + #[test] + fn pull_request_commit_discovery_is_repository_bound_and_exact() { + assert!(pull_request_contains_commit( + format!("{}\n{}\n", oid('a'), oid('b')).as_bytes(), + &oid('b') + ) + .unwrap()); + + let rest = br#"{"total_count":1,"items":[{"number":1370,"state":"open","repository_url":"https://api.github.com/repos/ContextualWisdomLab/disksage"}]}"#; + assert_eq!( + parse_pull_request_rest_search(rest, "ContextualWisdomLab/disksage").unwrap(), + vec![(1370, true)] + ); + } + + #[test] + fn merged_pull_request_evidence_binds_exact_branch_and_head() { + let json = format!( + r#"[{{"number":1,"headRefName":"merged-local","headRefOid":"{}","isCrossRepository":false,"state":"MERGED"}}]"#, + oid('a') + ); + assert_eq!( + parse_closed_pull_request_heads(json.as_bytes()).unwrap(), + BTreeSet::from([("refs/heads/merged-local".into(), oid('a'))]) + ); + let exact = parse_exact_pull_request_commit_membership(json.as_bytes()).unwrap(); + assert_eq!(exact.completed, BTreeSet::from([oid('a')])); + assert!(exact.open.is_empty()); + + let relevant = retain_registered_pull_request_membership( + PullRequestCommitMembership { + completed: BTreeSet::from([oid('a'), oid('b')]), + open: BTreeMap::from([(oid('c'), BTreeSet::from([3]))]), + }, + &BTreeSet::from([oid('a'), oid('c')]), + ); + assert_eq!(relevant.completed, BTreeSet::from([oid('a')])); + assert_eq!( + relevant.open, + BTreeMap::from([(oid('c'), BTreeSet::from([3]))]) + ); + } + + #[test] + fn stale_open_pull_request_evidence_requires_valid_timestamp_and_filters_explicit_cutoff() { + let json = format!( + r#"[ + {{"number":1,"headRefName":"old-local","headRefOid":"{}","isCrossRepository":false,"state":"OPEN","createdAt":"2026-01-01T00:00:00Z"}}, + {{"number":2,"headRefName":"new-local","headRefOid":"{}","isCrossRepository":false,"state":"OPEN","createdAt":"2026-08-28T00:00:00Z"}}, + {{"number":3,"headRefName":"forked","headRefOid":"{}","isCrossRepository":true,"state":"OPEN","createdAt":"2020-01-01T00:00:00Z"}}, + {{"number":4,"headRefName":"closed","headRefOid":"{}","isCrossRepository":false,"state":"CLOSED","createdAt":"2020-01-01T00:00:00Z"}} + ]"#, + oid('a'), + oid('b'), + oid('c'), + oid('d'), + ); + let cutoff = parse_github_timestamp_ms("2026-08-01T00:00:00Z").unwrap(); + assert_eq!( + parse_stale_open_pull_request_heads(json.as_bytes(), cutoff).unwrap(), + BTreeMap::from([( + ("refs/heads/old-local".into(), oid('a')), + BTreeSet::from([1]) + )]) + ); + assert!(parse_github_timestamp_ms("2026-02-30T00:00:00Z").is_none()); + assert!(parse_github_timestamp_ms("2026-01-01T00:00:00+00:00").is_none()); + } + + #[test] + fn stale_open_pull_request_evidence_fails_closed_when_timestamp_is_missing() { + let json = format!( + r#"[{{"headRefName":"old-local","headRefOid":"{}","isCrossRepository":false,"state":"OPEN"}}]"#, + oid('a') + ); + assert_eq!( + parse_stale_open_pull_request_heads(json.as_bytes(), u64::MAX).unwrap_err(), + "github-open-pr-created-at-missing" + ); + } + fn executable_report() -> GitWorktreeAuditReport { let common_dir = "/tmp/repository/.git".to_string(); let references = vec![GitWorktreeReferenceBinding { @@ -2205,6 +3207,8 @@ mod tests { reference_oid: oid('a'), }]; let reference_fingerprint = retention_reference_set_fingerprint(&references); + let authority_fingerprint = + removal_authority_fingerprint(&reference_fingerprint, &BTreeSet::new()); let mut entry = GitWorktreeAuditEntry { path: "/tmp/secondary".into(), path_fingerprint: path_fingerprint(&common_dir, "/tmp/secondary"), @@ -2221,6 +3225,10 @@ mod tests { status_clean: Some(true), status_entry_count: Some(0), contained_in_reference: Some(true), + closed_pull_request_head: false, + completed_pull_request_commit: false, + open_pull_request_commit: false, + stale_open_pull_request_head: false, head_is_retained_tip: false, actor_cwd_inside: Some(false), size: GitWorktreeSizeEvidence { @@ -2244,18 +3252,20 @@ mod tests { blockers: Vec::new(), entry_fingerprint: String::new(), }; - entry.entry_fingerprint = entry_fingerprint(&common_dir, &reference_fingerprint, &entry); + entry.entry_fingerprint = entry_fingerprint(&common_dir, &authority_fingerprint, &entry); let entries = vec![entry]; let plan_fingerprint = - removal_plan_fingerprint(&common_dir, &reference_fingerprint, &entries); + removal_plan_fingerprint(&common_dir, &authority_fingerprint, &entries); GitWorktreeAuditReport { schema_kind: GIT_WORKTREE_AUDIT_SCHEMA_KIND.into(), - version: 2, + version: 4, repository_root: "/tmp/repository".into(), common_dir, generated_at_ms: 10, + stale_open_pull_request_cutoff_ms: None, retention_references: references, retention_reference_set_fingerprint: reference_fingerprint, + removal_authority_fingerprint: authority_fingerprint, retention_reachable_commit_count: 2, worktree_count: 1, removal_candidate_count: 1, @@ -2309,6 +3319,147 @@ mod tests { (temp, repository, secondary) } + #[cfg(all(unix, not(coverage)))] + #[test] + fn exact_closed_pull_request_branch_and_head_authorize_clean_unmerged_worktree() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + let secondary = temp.path().join("closed-pr"); + fs::create_dir(&repository).unwrap(); + git(&repository, &["init", "-b", "main"]); + fs::write(repository.join("evidence.txt"), b"main\n").unwrap(); + git(&repository, &["add", "evidence.txt"]); + git(&repository, &["commit", "-m", "main"]); + git(&repository, &["branch", "closed-pr"]); + git( + &repository, + &["worktree", "add", secondary.to_str().unwrap(), "closed-pr"], + ); + fs::write(secondary.join("closed.txt"), b"closed\n").unwrap(); + git(&secondary, &["add", "closed.txt"]); + git(&secondary, &["commit", "-m", "closed-only"]); + let head = command_text( + &run_git( + &secondary, + &[OsString::from("rev-parse"), OsString::from("HEAD")], + 5_000, + "test-rev-parse", + ) + .unwrap() + .stdout, + "test-head-not-utf8", + ) + .unwrap() + .trim() + .to_string(); + let closed = BTreeSet::from([("refs/heads/closed-pr".into(), head)]); + + let report = audit_git_worktrees_with_closed_pull_request_heads( + &repository, + &["refs/heads/main".into()], + &closed, + GitWorktreeAuditOptions::default(), + 42, + ) + .unwrap(); + let entry = report + .entries + .iter() + .find(|entry| entry.branch.as_deref() == Some("refs/heads/closed-pr")) + .unwrap(); + assert_eq!(entry.contained_in_reference, Some(false)); + assert!(entry.closed_pull_request_head); + assert_eq!(entry.disposition, GitWorktreeDisposition::RemovalCandidate); + } + #[cfg(all(unix, not(coverage)))] + #[test] + fn detached_intermediate_completed_commit_is_candidate_unless_an_open_pr_contains_it() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + let secondary = temp.path().join("detached-pr"); + fs::create_dir(&repository).unwrap(); + git(&repository, &["init", "-b", "main"]); + fs::write(repository.join("main.txt"), b"main\n").unwrap(); + git(&repository, &["add", "main.txt"]); + git(&repository, &["commit", "-m", "main"]); + git(&repository, &["branch", "pull-request"]); + git( + &repository, + &[ + "worktree", + "add", + secondary.to_str().unwrap(), + "pull-request", + ], + ); + fs::write(secondary.join("first.txt"), b"first\n").unwrap(); + git(&secondary, &["add", "first.txt"]); + git(&secondary, &["commit", "-m", "first"]); + let intermediate = command_text( + &run_git( + &secondary, + &[OsString::from("rev-parse"), OsString::from("HEAD")], + 5_000, + "test-rev-parse", + ) + .unwrap() + .stdout, + "test-head-not-utf8", + ) + .unwrap() + .trim() + .to_string(); + fs::write(secondary.join("second.txt"), b"second\n").unwrap(); + git(&secondary, &["add", "second.txt"]); + git(&secondary, &["commit", "-m", "second"]); + git(&secondary, &["checkout", "--detach", &intermediate]); + + let completed = PullRequestCommitMembership { + completed: BTreeSet::from([intermediate.clone()]), + open: BTreeMap::new(), + ..PullRequestCommitMembership::default() + }; + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &BTreeSet::new(), + &BTreeMap::new(), + &completed, + None, + GitWorktreeAuditOptions::default(), + 42, + ) + .unwrap(); + let entry = report.entries.iter().find(|entry| entry.detached).unwrap(); + assert_eq!(entry.contained_in_reference, Some(false)); + assert!(entry.completed_pull_request_commit); + assert_eq!(entry.disposition, GitWorktreeDisposition::RemovalCandidate); + + let open_veto = PullRequestCommitMembership { + completed: BTreeSet::from([intermediate.clone()]), + open: BTreeMap::from([(intermediate, BTreeSet::from([1]))]), + ..PullRequestCommitMembership::default() + }; + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &BTreeSet::new(), + &BTreeMap::new(), + &open_veto, + None, + GitWorktreeAuditOptions::default(), + 43, + ) + .unwrap(); + let entry = report.entries.iter().find(|entry| entry.detached).unwrap(); + assert!(entry.open_pull_request_commit); + assert!(entry + .blockers + .iter() + .any(|value| value == "open-pull-request-commit")); + assert_eq!(entry.disposition, GitWorktreeDisposition::Preserve); + } + #[test] fn parses_nul_porcelain_and_preserves_lock_and_prunable_reasons() { let encoded = format!( @@ -2373,7 +3524,11 @@ mod tests { fs::create_dir_all(&worktree).unwrap(); let admin = common_dir.join("worktrees").join("linked"); fs::create_dir_all(&admin).unwrap(); - fs::write(admin.join("gitdir"), format!("{}/.git\n", worktree.display())).unwrap(); + fs::write( + admin.join("gitdir"), + format!("{}/.git\n", worktree.display()), + ) + .unwrap(); fs::write(admin.join("HEAD"), format!("{}\n", oid('a'))).unwrap(); let (entries, _) = @@ -2455,6 +3610,10 @@ mod tests { path_valid: true, status_clean: Some(true), contained_in_reference: Some(true), + closed_pull_request_head: false, + completed_pull_request_commit: false, + open_pull_request_commit: false, + stale_open_pull_request_head: false, head_is_retained_tip: false, actor_cwd_inside: Some(false), size_complete: true, @@ -2464,6 +3623,20 @@ mod tests { }; assert!(candidate_blockers(&safe).is_empty()); + let closed_unmerged = ClassificationInput { + contained_in_reference: Some(false), + closed_pull_request_head: true, + ..safe + }; + assert!(candidate_blockers(&closed_unmerged).is_empty()); + + let stale_open = ClassificationInput { + contained_in_reference: Some(false), + stale_open_pull_request_head: true, + ..safe + }; + assert!(candidate_blockers(&stale_open).is_empty()); + let dirty = ClassificationInput { status_clean: Some(false), ..safe @@ -2534,6 +3707,10 @@ mod tests { status_clean: Some(true), status_entry_count: Some(0), contained_in_reference: Some(true), + closed_pull_request_head: false, + completed_pull_request_commit: false, + open_pull_request_commit: false, + stale_open_pull_request_head: false, head_is_retained_tip: false, actor_cwd_inside: Some(false), size, @@ -2557,15 +3734,17 @@ mod tests { fn public_summary_redacts_local_identity_and_denies_execution_claims() { let report = GitWorktreeAuditReport { schema_kind: GIT_WORKTREE_AUDIT_SCHEMA_KIND.into(), - version: 2, + version: 4, repository_root: "/private/repo".into(), common_dir: "/private/repo/.git".into(), generated_at_ms: 1, + stale_open_pull_request_cutoff_ms: None, retention_references: vec![GitWorktreeReferenceBinding { reference_ref: "origin/develop".into(), reference_oid: oid('a'), }], retention_reference_set_fingerprint: "r".repeat(64), + removal_authority_fingerprint: "a".repeat(64), retention_reachable_commit_count: 1, worktree_count: 1, removal_candidate_count: 0, @@ -2641,6 +3820,32 @@ mod tests { .is_err()); } + #[cfg(all(unix, not(coverage)))] + #[test] + fn incomplete_audit_with_candidates_never_issues_approval_phrase() { + let (temp, repository, _secondary) = temporary_repository(); + let missing = temp.path().join("missing"); + git(&repository, &["branch", "missing", "HEAD~1"]); + git( + &repository, + &["worktree", "add", missing.to_str().unwrap(), "missing"], + ); + fs::remove_dir_all(&missing).unwrap(); + + let report = audit_git_worktrees( + &repository, + &["main".into()], + GitWorktreeAuditOptions::default(), + current_unix_ms(), + ) + .unwrap(); + + assert_eq!(report.removal_candidate_count, 1, "{report:#?}"); + assert!(report.evidence_gap_count > 0, "{report:#?}"); + assert!(!report.evidence_complete); + assert_eq!(report.exact_approval_phrase, None); + } + #[cfg(all(unix, not(coverage)))] #[test] fn execution_removes_only_clean_merged_worktree_and_retains_branch() { @@ -2687,6 +3892,50 @@ mod tests { git(&repository, &["show-ref", "--verify", "refs/heads/merged"]); } + #[cfg(all(unix, not(coverage)))] + #[test] + fn execution_reaudits_and_removes_multiple_approved_candidates() { + let (temp, repository, secondary) = temporary_repository(); + let third = temp.path().join("third"); + git(&repository, &["branch", "merged-two", "HEAD~1"]); + git( + &repository, + &["worktree", "add", third.to_str().unwrap(), "merged-two"], + ); + let generated_at = current_unix_ms(); + let report = audit_git_worktrees( + &repository, + &["main".into()], + GitWorktreeAuditOptions::default(), + generated_at, + ) + .unwrap(); + assert_eq!(report.removal_candidate_count, 2, "{report:#?}"); + let phrase = report.exact_approval_phrase.clone().unwrap(); + let approval = approve_stale_worktree_removal( + &report, + &phrase, + generated_at + 1, + "human:local:test", + "two merged worktrees reviewed for removal", + ) + .unwrap(); + + let result = execute_stale_worktree_removal( + &report, + &approval, + &phrase, + GitWorktreeAuditOptions::default(), + generated_at + 2, + ) + .unwrap(); + + assert!(result.verification_complete, "{result:#?}"); + assert_eq!(result.removed_count, 2); + assert!(!secondary.exists()); + assert!(!third.exists()); + } + #[cfg(all(unix, not(coverage)))] #[test] fn execution_fails_closed_when_candidate_becomes_dirty() { diff --git a/src-tauri/src/git_worktree_github_evidence.rs b/src-tauri/src/git_worktree_github_evidence.rs new file mode 100644 index 000000000..15fc794e2 --- /dev/null +++ b/src-tauri/src/git_worktree_github_evidence.rs @@ -0,0 +1,129 @@ +//! One-deadline acquisition of GitHub pull-request evidence for Git worktree decisions. +//! +//! The forge-evidence phase owns a separate whole-operation budget. `command_timeout_ms` remains a +//! local child-process deadline and is never reused as the aggregate budget. Callers reuse the +//! returned evidence for one audit or one live re-audit and keep filesystem scanning on its own +//! separately bounded option. + +use std::path::Path; +use std::time::Instant; + +use crate::git_worktree::{ + self, ClosedPullRequestHeads, GitWorktreeAuditOptions, PullRequestCommitMembership, + StaleOpenPullRequestHeads, MAX_LOCAL_COMMAND_TIMEOUT_MS, +}; + +/// Maximum wall-clock budget for the complete GitHub evidence phase. +/// +/// Individual `gh`/Git subprocesses stay independently bounded by +/// [`MAX_LOCAL_COMMAND_TIMEOUT_MS`], so this budget can cover several sequential API queries +/// without granting any one local child the whole phase deadline. +pub const GITHUB_EVIDENCE_OPERATION_TIMEOUT_MS: u64 = 600_000; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GitHubPullRequestEvidence { + pub closed_heads: ClosedPullRequestHeads, + pub stale_open_heads: StaleOpenPullRequestHeads, + pub pull_request_commits: PullRequestCommitMembership, +} + +fn remaining_local_options( + options: GitWorktreeAuditOptions, + started: Instant, +) -> Result { + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + let remaining_operation_ms = GITHUB_EVIDENCE_OPERATION_TIMEOUT_MS.saturating_sub(elapsed_ms); + if remaining_operation_ms == 0 { + return Err("github-pr-evidence-timeout".into()); + } + Ok(GitWorktreeAuditOptions { + command_timeout_ms: options + .command_timeout_ms + .min(MAX_LOCAL_COMMAND_TIMEOUT_MS) + .min(remaining_operation_ms), + ..options + }) +} + +/// Collect every requested GitHub PR evidence stream under one aggregate wall-clock budget while +/// retaining a distinct, shorter deadline for each local subprocess. +pub fn collect( + repository_root: &Path, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, +) -> Result { + if !include_closed_pull_requests && stale_open_pull_request_cutoff_ms.is_none() { + return Ok(GitHubPullRequestEvidence::default()); + } + + let started = Instant::now(); + let closed_heads = if include_closed_pull_requests { + git_worktree::github_closed_pull_request_heads_with_options( + repository_root, + remaining_local_options(options, started)?, + )? + } else { + Default::default() + }; + + let exact = git_worktree::github_exact_pull_request_commit_membership( + repository_root, + remaining_local_options(options, started)?.command_timeout_ms, + )?; + let mut pull_request_commits = git_worktree::github_pull_request_commit_membership_with_exact( + repository_root, + remaining_local_options(options, started)?, + exact, + )?; + if !include_closed_pull_requests { + pull_request_commits.completed.clear(); + } + + let stale_open_heads = if let Some(cutoff_ms) = stale_open_pull_request_cutoff_ms { + let remaining = remaining_local_options(options, started)?; + git_worktree::github_stale_open_pull_request_heads( + repository_root, + cutoff_ms, + remaining.command_timeout_ms, + )? + } else { + Default::default() + }; + + Ok(GitHubPullRequestEvidence { + closed_heads, + stale_open_heads, + pull_request_commits, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aggregate_evidence_budget_never_becomes_one_local_command_deadline() { + let options = GitWorktreeAuditOptions { + command_timeout_ms: 3_600_000, + ..GitWorktreeAuditOptions::default() + }; + let local = remaining_local_options(options, Instant::now()).unwrap(); + assert_eq!(local.command_timeout_ms, MAX_LOCAL_COMMAND_TIMEOUT_MS); + assert!(local.command_timeout_ms < GITHUB_EVIDENCE_OPERATION_TIMEOUT_MS); + } + + #[test] + fn caller_local_deadline_is_preserved_when_below_the_cap() { + let options = GitWorktreeAuditOptions { + command_timeout_ms: 100, + ..GitWorktreeAuditOptions::default() + }; + let started = Instant::now(); + let first = remaining_local_options(options, started).unwrap(); + assert!(first.command_timeout_ms <= 100); + std::thread::sleep(std::time::Duration::from_millis(5)); + let later = remaining_local_options(options, started).unwrap(); + assert!(later.command_timeout_ms <= first.command_timeout_ms); + } +} diff --git a/src-tauri/src/git_worktree_public.rs b/src-tauri/src/git_worktree_public.rs new file mode 100644 index 000000000..596428438 --- /dev/null +++ b/src-tauri/src/git_worktree_public.rs @@ -0,0 +1,295 @@ +//! Public Git worktree safety boundary with a hard local-subprocess deadline. +//! +//! The implementation accepts a caller-selected command budget because GitHub evidence collection +//! also uses that value as a whole-operation budget. This facade prevents that aggregate budget +//! from becoming a one-hour `git`, `gh`, `lsof`, or `ps` child-process deadline. Higher-level +//! orchestration may keep a longer total budget, but every call into the local implementation is +//! bounded independently before any subprocess can start. + +pub use crate::git_worktree_impl::{ + approve_stale_worktree_removal, prepare_worktree_record_directory, public_summary, + validate_reference, write_immutable_worktree_record, ClosedPullRequestHeads, + GitWorktreeActiveUseEvidence, GitWorktreeAuditEntry, GitWorktreeAuditOptions, + GitWorktreeAuditPublicSummary, GitWorktreeAuditReport, GitWorktreeDisposition, + GitWorktreeReferenceBinding, GitWorktreeRemovalApproval, GitWorktreeRemovalItemResult, + GitWorktreeRemovalResult, GitWorktreeSizeEvidence, PullRequestCommitMembership, + PullRequestCommits, StaleOpenPullRequestHeads, GIT_WORKTREE_AUDIT_SCHEMA_KIND, + MAX_REFERENCE_BYTES, +}; + +use std::path::Path; + +/// Maximum wall-clock time one local Git-worktree subprocess may inherit from a caller. +/// +/// Two minutes bounds a single command independently while the higher-level GitHub evidence phase +/// may budget several sequential calls. +pub const MAX_LOCAL_COMMAND_TIMEOUT_MS: u64 = 120_000; + +fn validate_local_command_timeout(timeout_ms: u64) -> Result<(), String> { + if timeout_ms == 0 || timeout_ms > MAX_LOCAL_COMMAND_TIMEOUT_MS { + return Err("git-worktree-command-timeout-out-of-bounds".into()); + } + Ok(()) +} + +fn validate_local_options(options: GitWorktreeAuditOptions) -> Result<(), String> { + validate_local_command_timeout(options.command_timeout_ms) +} + +/// Probe active use only with a bounded local process deadline. +#[cfg(unix)] +pub fn active_use_evidence( + path: &Path, + timeout_ms: u64, + max_pids: usize, + recursive: bool, +) -> GitWorktreeActiveUseEvidence { + if validate_local_command_timeout(timeout_ms).is_err() { + return GitWorktreeActiveUseEvidence { + method: if recursive { + "lsof-recursive-pid" + } else { + "lsof-file-pid" + } + .into(), + assessed: false, + evidence_complete: false, + active: false, + observed_pids: Vec::new(), + results_truncated: false, + error: Some("git-worktree-command-timeout-out-of-bounds".into()), + }; + } + crate::git_worktree_impl::active_use_evidence(path, timeout_ms, max_pids, recursive) +} + +#[cfg(not(unix))] +pub(crate) fn active_use_evidence( + path: &Path, + timeout_ms: u64, + max_pids: usize, + recursive: bool, +) -> GitWorktreeActiveUseEvidence { + if validate_local_command_timeout(timeout_ms).is_err() { + return GitWorktreeActiveUseEvidence { + method: if recursive { + "process-observation-recursive" + } else { + "process-observation-file" + } + .into(), + assessed: false, + evidence_complete: false, + active: false, + observed_pids: Vec::new(), + results_truncated: false, + error: Some("git-worktree-command-timeout-out-of-bounds".into()), + }; + } + crate::git_worktree_impl::active_use_evidence(path, timeout_ms, max_pids, recursive) +} + +/// Resolve closed PR heads without allowing an aggregate caller budget to become one `gh` timeout. +pub fn github_closed_pull_request_heads( + repository_root: &Path, + timeout_ms: u64, +) -> Result { + validate_local_command_timeout(timeout_ms)?; + crate::git_worktree_impl::github_closed_pull_request_heads(repository_root, timeout_ms) +} + +/// Resolve closed PR heads under locally bounded command options. +pub fn github_closed_pull_request_heads_with_options( + repository_root: &Path, + options: GitWorktreeAuditOptions, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::github_closed_pull_request_heads_with_options(repository_root, options) +} + +/// Resolve PR commit membership under locally bounded command options. +pub fn github_pull_request_commit_membership( + repository_root: &Path, + options: GitWorktreeAuditOptions, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::github_pull_request_commit_membership(repository_root, options) +} + +pub(crate) fn github_exact_pull_request_commit_membership( + repository_root: &Path, + timeout_ms: u64, +) -> Result { + validate_local_command_timeout(timeout_ms)?; + crate::git_worktree_impl::github_exact_pull_request_commit_membership( + repository_root, + timeout_ms, + ) +} + +pub(crate) fn github_pull_request_commit_membership_with_exact( + repository_root: &Path, + options: GitWorktreeAuditOptions, + exact: PullRequestCommitMembership, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::github_pull_request_commit_membership_with_exact( + repository_root, + options, + exact, + ) +} + +/// Resolve stale-open PR heads with a bounded local `gh` deadline. +pub fn github_stale_open_pull_request_heads( + repository_root: &Path, + cutoff_ms: u64, + timeout_ms: u64, +) -> Result { + validate_local_command_timeout(timeout_ms)?; + crate::git_worktree_impl::github_stale_open_pull_request_heads( + repository_root, + cutoff_ms, + timeout_ms, + ) +} + +/// Audit linked worktrees only after bounding every local subprocess deadline. +pub fn audit_git_worktrees( + repository_root: &Path, + retention_references: &[String], + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::audit_git_worktrees( + repository_root, + retention_references, + options, + generated_at_ms, + ) +} + +/// Audit with closed-PR authority only after bounding every local subprocess deadline. +pub fn audit_git_worktrees_with_closed_pull_request_heads( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::audit_git_worktrees_with_closed_pull_request_heads( + repository_root, + retention_references, + closed_pull_request_heads, + options, + generated_at_ms, + ) +} + +/// Audit with closed and stale-open PR authority under bounded local command options. +pub fn audit_git_worktrees_with_pull_request_heads( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::audit_git_worktrees_with_pull_request_heads( + repository_root, + retention_references, + closed_pull_request_heads, + stale_open_pull_request_heads, + stale_open_pull_request_cutoff_ms, + options, + generated_at_ms, + ) +} + +/// Audit exact PR membership under bounded local command options. +pub fn audit_git_worktrees_with_pull_request_membership( + repository_root: &Path, + retention_references: &[String], + closed_pull_request_heads: &ClosedPullRequestHeads, + stale_open_pull_request_heads: &StaleOpenPullRequestHeads, + pull_request_commits: &PullRequestCommitMembership, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + generated_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::audit_git_worktrees_with_pull_request_membership( + repository_root, + retention_references, + closed_pull_request_heads, + stale_open_pull_request_heads, + pull_request_commits, + stale_open_pull_request_cutoff_ms, + options, + generated_at_ms, + ) +} + +/// Execute stale-worktree removal only with bounded local command deadlines. +pub fn execute_stale_worktree_removal( + approved_report: &GitWorktreeAuditReport, + approval: &GitWorktreeRemovalApproval, + confirmation_exact_approval_phrase: &str, + options: GitWorktreeAuditOptions, + requested_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::execute_stale_worktree_removal( + approved_report, + approval, + confirmation_exact_approval_phrase, + options, + requested_at_ms, + ) +} + +/// Execute with fresh closed-PR evidence while keeping each local child process bounded. +pub fn execute_stale_worktree_removal_with_github_closed_pull_requests( + approved_report: &GitWorktreeAuditReport, + approval: &GitWorktreeRemovalApproval, + confirmation_exact_approval_phrase: &str, + include_closed_pull_requests: bool, + options: GitWorktreeAuditOptions, + requested_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::execute_stale_worktree_removal_with_github_closed_pull_requests( + approved_report, + approval, + confirmation_exact_approval_phrase, + include_closed_pull_requests, + options, + requested_at_ms, + ) +} + +/// Execute with fresh PR evidence while keeping each local child process bounded. +pub fn execute_stale_worktree_removal_with_github_pull_requests( + approved_report: &GitWorktreeAuditReport, + approval: &GitWorktreeRemovalApproval, + confirmation_exact_approval_phrase: &str, + include_closed_pull_requests: bool, + stale_open_pull_request_cutoff_ms: Option, + options: GitWorktreeAuditOptions, + requested_at_ms: u64, +) -> Result { + validate_local_options(options)?; + crate::git_worktree_impl::execute_stale_worktree_removal_with_github_pull_requests( + approved_report, + approval, + confirmation_exact_approval_phrase, + include_closed_pull_requests, + stale_open_pull_request_cutoff_ms, + options, + requested_at_ms, + ) +} diff --git a/src-tauri/src/icloud_provider_recovery.rs b/src-tauri/src/icloud_provider_recovery.rs new file mode 100644 index 000000000..8cdd6e7a2 --- /dev/null +++ b/src-tauri/src/icloud_provider_recovery.rs @@ -0,0 +1,480 @@ +//! Evidence-bound recovery for the current user's system-managed iCloud File Provider daemon. +//! +//! This module never edits provider databases, cloud objects, or user files. It permits only one +//! graceful SIGTERM after fresh, complete stalled-sync evidence and exact daemon identity checks. + +use crate::icloud_sync_health::{ + validate_icloud_sync_health_evidence_snapshot, IcloudSyncHealthEvidenceSnapshot, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::Path; + +#[cfg(target_os = "macos")] +use std::process::{Command, Stdio}; +#[cfg(target_os = "macos")] +use std::thread; +#[cfg(target_os = "macos")] +use std::time::{Duration, Instant}; + +pub const RECOVERY_SCHEMA_VERSION: u32 = 1; +pub const FILE_PROVIDER_SERVICE_LABEL: &str = "com.apple.FileProvider"; +pub const FILE_PROVIDER_EXECUTABLE: &str = + "/System/Library/Frameworks/FileProvider.framework/Support/fileproviderd"; +const MAX_EVIDENCE_AGE_MS: u64 = 5 * 60 * 1_000; +const MIN_STALE_AGE_MS: u64 = 15 * 60 * 1_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IcloudFileProviderDaemonIdentity { + pub uid: u32, + pub pid: i32, + pub service_label: String, + pub executable_path: String, + pub executable_object_id: String, + pub apple_signature_valid: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IcloudFileProviderRecoveryPlan { + pub schema_version: u32, + pub observed_at_ms: u64, + pub health_evidence_fingerprint_sha256: String, + pub stale_error_count: u64, + pub oldest_stale_error_age_ms: u64, + pub daemon: IcloudFileProviderDaemonIdentity, + pub blockers: Vec, + pub eligible: bool, + pub plan_fingerprint_sha256: String, + pub exact_approval_phrase: String, + pub mutation_performed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IcloudFileProviderRecoveryResult { + pub schema_version: u32, + pub plan_fingerprint_sha256: String, + pub pre_daemon: IcloudFileProviderDaemonIdentity, + pub post_daemon: IcloudFileProviderDaemonIdentity, + pub graceful_sigterm_sent: bool, + pub launchd_respawn_observed: bool, + pub cloud_write_executed: bool, + pub source_eviction_executed: bool, + pub provider_database_mutated: bool, + /// A respawn is not proof that the provider stall cleared; a fresh full health probe is required. + pub recovery_verified: bool, + pub sync_health_recheck_required: bool, +} + +fn valid_hex64(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn plan_fingerprint(plan: &IcloudFileProviderRecoveryPlan) -> String { + let mut unsigned = plan.clone(); + unsigned.plan_fingerprint_sha256.clear(); + unsigned.exact_approval_phrase.clear(); + let encoded = serde_json::to_vec(&unsigned).expect("recovery plan is serializable"); + let digest = Sha256::digest(encoded); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn daemon_identity_valid(daemon: &IcloudFileProviderDaemonIdentity, current_uid: u32) -> bool { + daemon.uid == current_uid + && daemon.pid > 1 + && daemon.service_label == FILE_PROVIDER_SERVICE_LABEL + && daemon.executable_path == FILE_PROVIDER_EXECUTABLE + && valid_hex64(&daemon.executable_object_id) + && daemon.apple_signature_valid +} + +pub fn plan_icloud_file_provider_recovery( + health: &IcloudSyncHealthEvidenceSnapshot, + daemon: IcloudFileProviderDaemonIdentity, + current_uid: u32, + now_ms: u64, +) -> IcloudFileProviderRecoveryPlan { + let mut blockers = Vec::new(); + if validate_icloud_sync_health_evidence_snapshot(health).is_err() { + blockers.push("icloud-recovery-health-evidence-invalid".into()); + } + if now_ms < health.observed_at_ms + || now_ms.saturating_sub(health.observed_at_ms) > MAX_EVIDENCE_AGE_MS + { + blockers.push("icloud-recovery-health-evidence-stale".into()); + } + let activity = health.file_provider_activity.as_ref(); + if !health.evidence_complete + || activity.is_none_or(|value| { + !value.command_succeeded || value.timed_out || value.output_truncated + }) + { + blockers.push("icloud-recovery-activity-evidence-incomplete".into()); + } + if activity + .is_some_and(|value| value.active_upload_count > 0 || value.active_download_count > 0) + { + blockers.push("icloud-recovery-active-transfer-observed".into()); + } + let stale_error_count = activity.map_or(0, |value| value.stale_error_count); + let oldest_stale_error_age_ms = activity + .and_then(|value| value.oldest_stale_error_age_ms) + .unwrap_or(0); + if stale_error_count == 0 || oldest_stale_error_age_ms < MIN_STALE_AGE_MS { + blockers.push("icloud-recovery-stall-not-proven".into()); + } + if !daemon_identity_valid(&daemon, current_uid) { + blockers.push("icloud-recovery-daemon-identity-invalid".into()); + } + blockers.sort(); + blockers.dedup(); + let mut plan = IcloudFileProviderRecoveryPlan { + schema_version: RECOVERY_SCHEMA_VERSION, + observed_at_ms: now_ms, + health_evidence_fingerprint_sha256: health.evidence_fingerprint_sha256.clone(), + stale_error_count, + oldest_stale_error_age_ms, + daemon, + eligible: blockers.is_empty(), + blockers, + plan_fingerprint_sha256: String::new(), + exact_approval_phrase: String::new(), + mutation_performed: false, + }; + plan.plan_fingerprint_sha256 = plan_fingerprint(&plan); + plan.exact_approval_phrase = format!( + "DiskSage iCloud File Provider 복구 승인 {}", + plan.plan_fingerprint_sha256 + ); + plan +} + +fn validate_plan(plan: &IcloudFileProviderRecoveryPlan) -> Result<(), String> { + if plan.schema_version != RECOVERY_SCHEMA_VERSION + || plan.mutation_performed + || plan.eligible != plan.blockers.is_empty() + || !valid_hex64(&plan.health_evidence_fingerprint_sha256) + || plan.plan_fingerprint_sha256 != plan_fingerprint(plan) + || plan.exact_approval_phrase + != format!( + "DiskSage iCloud File Provider 복구 승인 {}", + plan.plan_fingerprint_sha256 + ) + { + return Err("icloud-recovery-plan-invalid".into()); + } + Ok(()) +} + +pub fn authorize_icloud_file_provider_recovery( + plan: &IcloudFileProviderRecoveryPlan, + fresh_health: &IcloudSyncHealthEvidenceSnapshot, + fresh_daemon: &IcloudFileProviderDaemonIdentity, + current_uid: u32, + now_ms: u64, + confirmation: &str, + rationale: &str, +) -> Result<(), String> { + validate_plan(plan)?; + if !plan.eligible { + return Err("icloud-recovery-plan-blocked".into()); + } + if confirmation != plan.exact_approval_phrase { + return Err("icloud-recovery-approval-mismatch".into()); + } + if rationale.trim() != rationale + || rationale.is_empty() + || rationale.chars().count() > 1_000 + || rationale.chars().any(char::is_control) + { + return Err("icloud-recovery-rationale-invalid".into()); + } + if now_ms < plan.observed_at_ms + || now_ms.saturating_sub(plan.observed_at_ms) > MAX_EVIDENCE_AGE_MS + { + return Err("icloud-recovery-plan-stale".into()); + } + let fresh_plan = + plan_icloud_file_provider_recovery(fresh_health, fresh_daemon.clone(), current_uid, now_ms); + if !fresh_plan.eligible { + return Err("icloud-recovery-revalidation-blocked".into()); + } + if fresh_daemon != &plan.daemon { + return Err("icloud-recovery-daemon-changed".into()); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn executable_object_id(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "icloud-recovery-daemon-metadata-unavailable".to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("icloud-recovery-daemon-object-unsafe".into()); + } + let mut hasher = Sha256::new(); + hasher.update(metadata.dev().to_le_bytes()); + hasher.update(metadata.ino().to_le_bytes()); + hasher.update(metadata.len().to_le_bytes()); + hasher.update(metadata.mtime().to_le_bytes()); + hasher.update(metadata.mtime_nsec().to_le_bytes()); + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(target_os = "macos")] +fn launchd_pid(uid: u32) -> Result { + let service = format!("gui/{uid}/{FILE_PROVIDER_SERVICE_LABEL}"); + let output = Command::new("/bin/launchctl") + .args(["print", &service]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map_err(|_| "icloud-recovery-launchd-observation-unavailable".to_string())?; + if !output.status.success() || output.stdout.len() > 64 * 1024 { + return Err("icloud-recovery-launchd-observation-unavailable".into()); + } + let text = std::str::from_utf8(&output.stdout) + .map_err(|_| "icloud-recovery-launchd-observation-invalid".to_string())?; + let pids = text + .lines() + .filter_map(|line| line.trim().strip_prefix("pid = ")) + .map(str::parse::) + .collect::, _>>() + .map_err(|_| "icloud-recovery-launchd-pid-invalid".to_string())?; + match pids.as_slice() { + [pid] if *pid > 1 => Ok(*pid), + _ => Err("icloud-recovery-launchd-pid-ambiguous".into()), + } +} + +#[cfg(target_os = "macos")] +fn process_path(pid: i32) -> Result { + let mut buffer = vec![0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + let length = unsafe { + libc::proc_pidpath( + pid, + buffer.as_mut_ptr().cast(), + buffer.len().try_into().unwrap_or(u32::MAX), + ) + }; + if length <= 0 { + return Err("icloud-recovery-daemon-path-unavailable".into()); + } + buffer.truncate(length as usize); + std::str::from_utf8(&buffer) + .map(str::to_owned) + .map_err(|_| "icloud-recovery-daemon-path-invalid".into()) +} + +#[cfg(target_os = "macos")] +pub fn observe_icloud_file_provider_daemon() -> Result { + let uid = unsafe { libc::getuid() }; + let pid = launchd_pid(uid)?; + let executable_path = process_path(pid)?; + if executable_path != FILE_PROVIDER_EXECUTABLE { + return Err("icloud-recovery-daemon-path-mismatch".into()); + } + let signature = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", FILE_PROVIDER_EXECUTABLE]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|_| "icloud-recovery-daemon-signature-unavailable".to_string())?; + if !signature.success() { + return Err("icloud-recovery-daemon-signature-invalid".into()); + } + Ok(IcloudFileProviderDaemonIdentity { + uid, + pid, + service_label: FILE_PROVIDER_SERVICE_LABEL.into(), + executable_path, + executable_object_id: executable_object_id(Path::new(FILE_PROVIDER_EXECUTABLE))?, + apple_signature_valid: true, + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn observe_icloud_file_provider_daemon() -> Result { + Err("icloud-recovery-platform-unsupported".into()) +} + +#[cfg(target_os = "macos")] +pub fn execute_icloud_file_provider_recovery( + plan: &IcloudFileProviderRecoveryPlan, + fresh_health: &IcloudSyncHealthEvidenceSnapshot, + now_ms: u64, + confirmation: &str, + rationale: &str, +) -> Result { + let pre_daemon = observe_icloud_file_provider_daemon()?; + authorize_icloud_file_provider_recovery( + plan, + fresh_health, + &pre_daemon, + unsafe { libc::getuid() }, + now_ms, + confirmation, + rationale, + )?; + // Revalidate immediately before signaling; PID reuse or executable replacement fails closed. + if observe_icloud_file_provider_daemon()? != pre_daemon { + return Err("icloud-recovery-daemon-changed".into()); + } + if unsafe { libc::kill(pre_daemon.pid, libc::SIGTERM) } != 0 { + return Err("icloud-recovery-sigterm-failed".into()); + } + let deadline = Instant::now() + Duration::from_secs(20); + let post_daemon = loop { + if Instant::now() >= deadline { + return Err("icloud-recovery-launchd-respawn-timeout".into()); + } + if let Ok(observed) = observe_icloud_file_provider_daemon() { + if observed.pid != pre_daemon.pid { + break observed; + } + } + thread::sleep(Duration::from_millis(100)); + }; + Ok(IcloudFileProviderRecoveryResult { + schema_version: RECOVERY_SCHEMA_VERSION, + plan_fingerprint_sha256: plan.plan_fingerprint_sha256.clone(), + pre_daemon, + post_daemon, + graceful_sigterm_sent: true, + launchd_respawn_observed: true, + cloud_write_executed: false, + source_eviction_executed: false, + provider_database_mutated: false, + recovery_verified: false, + sync_health_recheck_required: true, + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn execute_icloud_file_provider_recovery( + _plan: &IcloudFileProviderRecoveryPlan, + _fresh_health: &IcloudSyncHealthEvidenceSnapshot, + _now_ms: u64, + _confirmation: &str, + _rationale: &str, +) -> Result { + Err("icloud-recovery-platform-unsupported".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::icloud_sync_health::{ + health_evidence_snapshot_from_report, IcloudFileProviderActivityEvidence, + IcloudSyncHealthReport, IcloudUploadQueueSummary, ManagedDatabaseFileEvidence, + ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION, ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, + }; + + fn health(active: bool) -> IcloudSyncHealthEvidenceSnapshot { + let activity = IcloudFileProviderActivityEvidence { + schema_version: ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION, + observed_at_ms: 100, + command_succeeded: true, + timed_out: false, + output_truncated: false, + no_progress_fetch_count: 0, + no_progress_create_count: 0, + materialization_failure_count: 0, + staged_item_missing_count: 0, + stale_error_count: 2, + oldest_stale_error_age_ms: Some(3_600_000), + sync_excluded_filename_count: 0, + sync_excluded_root_count: 0, + active_upload_count: u64::from(active), + active_download_count: 0, + active_upload_progress_millionths: active.then_some(500_000), + active_download_progress_millionths: None, + notices: vec!["icloud-file-provider-stale-error-observed".into()], + }; + health_evidence_snapshot_from_report(&IcloudSyncHealthReport { + schema_version: ICLOUD_SYNC_HEALTH_SCHEMA_VERSION, + output_mode: "icloud-local-sync-health".into(), + observed_at_ms: 100, + provider: "icloud".into(), + evidence_kind: "supplementary-local-cloud-docs-private-schema".into(), + evidence_complete: true, + database_snapshot_includes_wal: true, + database_sidecar_write_permitted: false, + managed_database_files: vec![ManagedDatabaseFileEvidence { + role: "client.db".into(), + present: true, + logical_bytes: 1, + allocated_bytes: 1, + modified_ms: Some(1), + }], + managed_database_allocated_bytes: 1, + upload_queue: IcloudUploadQueueSummary::default(), + native_status: None, + file_provider_activity: Some(activity), + sync_backlog_present: true, + new_copy_admission_state: "blocked".into(), + new_copy_admission_blockers: vec!["icloud-file-provider-stalled".into()], + blockers: vec!["icloud-file-provider-stalled".into()], + notices: vec![], + paths_redacted: true, + user_filenames_read: false, + user_file_contents_read: false, + remote_capacity_verified: false, + provider_sync_attested: false, + local_eviction_authorized: false, + mutation_performed: false, + }) + .unwrap() + } + + fn daemon() -> IcloudFileProviderDaemonIdentity { + IcloudFileProviderDaemonIdentity { + uid: 501, + pid: 42, + service_label: FILE_PROVIDER_SERVICE_LABEL.into(), + executable_path: FILE_PROVIDER_EXECUTABLE.into(), + executable_object_id: "a".repeat(64), + apple_signature_valid: true, + } + } + + #[test] + fn only_fresh_complete_idle_stall_and_exact_identity_authorize_recovery() { + let plan = plan_icloud_file_provider_recovery(&health(false), daemon(), 501, 200); + assert!(plan.eligible, "{:?}", plan.blockers); + assert!(authorize_icloud_file_provider_recovery( + &plan, + &health(false), + &daemon(), + 501, + 300, + &plan.exact_approval_phrase, + "정체된 시스템 데몬의 안전한 재시작" + ) + .is_ok()); + + let active = plan_icloud_file_provider_recovery(&health(true), daemon(), 501, 200); + assert!(!active.eligible); + assert!(active + .blockers + .contains(&"icloud-recovery-active-transfer-observed".into())); + assert!(authorize_icloud_file_provider_recovery( + &plan, + &health(false), + &daemon(), + 501, + 300, + "wrong", + "근거" + ) + .is_err()); + } +} diff --git a/src-tauri/src/icloud_sync_health.rs b/src-tauri/src/icloud_sync_health.rs index e0bf643ea..85c1bde22 100644 --- a/src-tauri/src/icloud_sync_health.rs +++ b/src-tauri/src/icloud_sync_health.rs @@ -26,9 +26,9 @@ const CP_PATH: &str = "/bin/cp"; const PROBE_TIMEOUT: Duration = Duration::from_secs(5); const SNAPSHOT_COPY_TIMEOUT: Duration = Duration::from_secs(5); const SNAPSHOT_ATTEMPTS: usize = 3; -// CloudDocs' managed SQLite database can grow to many GiB. Never clone a database larger than -// this bounded amount during a read-only health probe: the immutable fallback below is slower and -// less complete, but it cannot unexpectedly consume the user's remaining disk while planning. +// Non-macOS snapshots perform a real byte copy and must remain bounded. macOS uses `cp -c`, whose +// required copy-on-write clone fails instead of falling back to a space-consuming full copy. +#[cfg(not(target_os = "macos"))] const MAX_SNAPSHOT_SOURCE_BYTES: u64 = 512 * 1024 * 1024; const MAX_STDOUT_BYTES: usize = 16 * 1024; const MAX_STDERR_BYTES: usize = 4 * 1024; @@ -40,18 +40,18 @@ const FILEPROVIDERCTL_PATH: &str = "/usr/bin/fileproviderctl"; #[cfg(target_os = "macos")] // fileproviderctl prints global sync-engine progress after the per-item detail section. Keep the // probe bounded, but allow enough time to observe that active-transfer evidence before failing. -const FILEPROVIDER_DUMP_TIMEOUT: Duration = Duration::from_secs(30); +const FILEPROVIDER_DUMP_TIMEOUT: Duration = Duration::from_secs(90); #[cfg(target_os = "macos")] // Keep the sync summary and a larger bounded provider-error window together; iCloud places // filename/root exclusion diagnostics after the aggregate summary in large dumps. -const MAX_FILEPROVIDER_DUMP_BYTES: usize = 1024 * 1024; +const MAX_FILEPROVIDER_DUMP_BYTES: usize = 4 * 1024 * 1024; const ITEM_ERROR_AGE_NOTICE_MS: u64 = 86_400_000; const FILE_PROVIDER_STALE_ERROR_AGE_MS: u64 = 15 * 60 * 1_000; static SNAPSHOT_NONCE: AtomicU64 = AtomicU64::new(0); pub const ICLOUD_SYNC_HEALTH_SCHEMA_VERSION: u32 = 5; pub const ICLOUD_NATIVE_STATUS_SCHEMA_VERSION: u32 = 1; -pub const ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION: u32 = 3; +pub const ICLOUD_FILE_PROVIDER_ACTIVITY_SCHEMA_VERSION: u32 = 4; pub const ICLOUD_SYNC_HEALTH_EVIDENCE_SCHEMA_VERSION: u32 = 1; pub const ICLOUD_SYNC_HEALTH_EVIDENCE_DIRECTORY: &str = "icloud-sync-health-evidence"; const MAX_PERSISTED_HEALTH_SNAPSHOTS: usize = 128; @@ -150,6 +150,12 @@ pub struct IcloudFileProviderActivityEvidence { pub materialization_failure_count: u64, #[serde(default)] pub staged_item_missing_count: u64, + /// Aggregate stale provider operation errors; no item identifiers or paths are retained. + #[serde(default)] + pub stale_error_count: u64, + /// Oldest relative age among `stale_error_count`, bounded by the native dump sample. + #[serde(default)] + pub oldest_stale_error_age_ms: Option, /// Aggregate provider errors where iCloud excludes an item because of its filename. #[serde(default)] pub sync_excluded_filename_count: u64, @@ -183,6 +189,10 @@ pub fn validate_file_provider_activity_evidence( || evidence .active_download_progress_millionths .is_some_and(|value| value > 1_000_000) + || ((evidence.stale_error_count == 0) != evidence.oldest_stale_error_age_ms.is_none()) + || evidence + .oldest_stale_error_age_ms + .is_some_and(|age| age < FILE_PROVIDER_STALE_ERROR_AGE_MS) { return Err("icloud-file-provider-activity-shape-invalid".into()); } @@ -972,9 +982,6 @@ fn parse_file_provider_activity_output( || lower.contains("create-item") || lower.contains("createitembasedontemplate") }; - let is_stale_age = |line: &&str| { - relative_age_ms(line).is_some_and(|age| age >= FILE_PROVIDER_STALE_ERROR_AGE_MS) - }; let is_provider_error = |line: &&str| { let lower = line.to_ascii_lowercase(); lower.contains("error:") @@ -988,14 +995,22 @@ fn parse_file_provider_activity_output( let lower = line.to_ascii_lowercase(); lower.contains("docid(") || is_provider_operation(line) }; - let stale_error_observed = provider_lines.iter().any(|line| { - is_provider_operation(line) && is_stale_age(line) && is_provider_error(line) - }) || provider_lines.windows(2).any(|record| { - is_provider_operation(&record[0]) + let mut stale_error_ages_ms = provider_lines + .iter() + .filter(|line| is_provider_operation(line) && is_provider_error(line)) + .filter_map(|line| relative_age_ms(line)) + .filter(|age| *age >= FILE_PROVIDER_STALE_ERROR_AGE_MS) + .collect::>(); + stale_error_ages_ms.extend(provider_lines.windows(2).filter_map(|record| { + (is_provider_operation(&record[0]) && !is_provider_record_start(&record[1]) - && is_stale_age(&record[1]) - && (is_provider_error(&record[0]) || is_provider_error(&record[1])) - }); + && (is_provider_error(&record[0]) || is_provider_error(&record[1]))) + .then(|| relative_age_ms(record[1])) + .flatten() + .filter(|age| *age >= FILE_PROVIDER_STALE_ERROR_AGE_MS) + })); + let stale_error_count = stale_error_ages_ms.len() as u64; + let oldest_stale_error_age_ms = stale_error_ages_ms.into_iter().max(); let sync_excluded_filename_count = output .lines() .filter(|line| { @@ -1010,16 +1025,16 @@ fn parse_file_provider_activity_output( .contains("excluded from sync under root") }) .count() as u64; - let active_upload_count = output - .lines() - .filter(|line| line.to_ascii_lowercase().contains("upload progress:")) - .count() as u64; - let active_download_count = output - .lines() - .filter(|line| line.to_ascii_lowercase().contains("download progress:")) - .count() as u64; let active_upload_progress_millionths = progress_millionths(output, "upload progress:"); let active_download_progress_millionths = progress_millionths(output, "download progress:"); + // fileproviderctl always emits aggregate progress-object headers, including while idle. Only a + // provider-reported incomplete fraction is evidence of an active transfer. + let active_upload_count = u64::from( + active_upload_progress_millionths.is_some_and(|progress| progress < 1_000_000), + ); + let active_download_count = u64::from( + active_download_progress_millionths.is_some_and(|progress| progress < 1_000_000), + ); let mut notices = if command_succeeded { vec!["icloud-file-provider-dump-observed".into()] } else { @@ -1046,7 +1061,7 @@ fn parse_file_provider_activity_output( if item_locked { notices.push("icloud-file-provider-item-locked-observed".into()); } - if stale_error_observed { + if stale_error_count > 0 { notices.push("icloud-file-provider-stale-error-observed".into()); } if sync_excluded_filename_count > 0 { @@ -1071,6 +1086,8 @@ fn parse_file_provider_activity_output( no_progress_create_count, materialization_failure_count, staged_item_missing_count, + stale_error_count, + oldest_stale_error_age_ms, sync_excluded_filename_count, sync_excluded_root_count, active_upload_count, @@ -1579,7 +1596,7 @@ fn create_temporary_snapshot_directory() -> Result Result<(), String> { - ensure_snapshot_file_within_limit(source)?; + source_file_identity(source, true)?; let cp_metadata = fs::symlink_metadata(CP_PATH) .map_err(|_| "icloud-sync-health-clone-command-unavailable".to_string())?; if cp_metadata.file_type().is_symlink() || !cp_metadata.is_file() { @@ -1650,13 +1667,27 @@ fn ensure_snapshot_file_within_limit(path: &Path) -> Result<(), String> { let identity = source_file_identity(path, true)?; if identity .as_ref() - .is_some_and(|identity| identity.logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES) + .is_some_and(snapshot_source_exceeds_copy_limit) { return Err("icloud-sync-health-snapshot-source-too-large".into()); } Ok(()) } +#[cfg(target_os = "macos")] +fn snapshot_bytes_exceed_copy_limit(_logical_bytes: u64) -> bool { + false +} + +#[cfg(not(target_os = "macos"))] +fn snapshot_bytes_exceed_copy_limit(logical_bytes: u64) -> bool { + logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES +} + +fn snapshot_source_exceeds_copy_limit(identity: &SourceFileIdentity) -> bool { + snapshot_bytes_exceed_copy_limit(identity.logical_bytes) +} + fn ensure_snapshot_file_with_cleanup(path: &Path) -> Result<(), String> { match ensure_snapshot_file_within_limit(path) { Ok(()) => Ok(()), @@ -1680,14 +1711,14 @@ fn clone_client_database_snapshot(db_dir: &Path) -> Result MAX_SNAPSHOT_SOURCE_BYTES) + .is_some_and(snapshot_source_exceeds_copy_limit) { return Err("icloud-sync-health-snapshot-source-too-large".into()); } let before_wal = source_file_identity(&source_wal, false)?; if before_wal .as_ref() - .is_some_and(|identity| identity.logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES) + .is_some_and(snapshot_source_exceeds_copy_limit) { return Err("icloud-sync-health-snapshot-source-too-large".into()); } @@ -1716,9 +1747,10 @@ fn clone_client_database_snapshot(db_dir: &Path) -> Result Option { - let identity = source_file_identity(&db_dir.join("client.db"), true).ok().flatten()?; - (identity.logical_bytes <= MAX_SNAPSHOT_SOURCE_BYTES) - .then(|| probe_native_status(observed_at_ms)) + source_file_identity(&db_dir.join("client.db"), true) + .ok() + .flatten() + .map(|_| probe_native_status(observed_at_ms)) } fn run_consistent_snapshot_queue_probe(db_dir: &Path) -> Result<(String, bool), String> { @@ -2126,9 +2158,9 @@ pub fn probe_icloud_sync_health( let native_status = bounded_native_status(db_dir, observed_at_ms); #[cfg(not(target_os = "macos"))] let native_status = Some(probe_native_status(observed_at_ms)); - let source_database_too_large = managed_database_files - .iter() - .any(|file| file.role == "client.db" && file.logical_bytes > MAX_SNAPSHOT_SOURCE_BYTES); + let source_database_too_large = managed_database_files.iter().any(|file| { + file.role == "client.db" && snapshot_bytes_exceed_copy_limit(file.logical_bytes) + }); match run_consistent_snapshot_queue_probe(db_dir) { Ok((output, includes_wal)) => { let mut report = build_report( @@ -2372,6 +2404,22 @@ mod tests { assert!(validate_file_provider_activity_evidence(&evidence).is_ok()); } + #[test] + fn file_provider_parser_does_not_treat_idle_progress_headers_as_active() { + let evidence = parse_file_provider_activity_output( + "+ upload progress: \n\ + + download progress: \n", + 42, + true, + false, + false, + ); + assert_eq!(evidence.active_upload_count, 0); + assert_eq!(evidence.active_download_count, 0); + assert_eq!(evidence.active_upload_progress_millionths, None); + assert_eq!(evidence.active_download_progress_millionths, None); + } + #[test] fn file_provider_parser_records_materialization_failures_without_paths() { let evidence = parse_file_provider_activity_output( @@ -2424,6 +2472,8 @@ mod tests { assert!(evidence .notices .contains(&"icloud-file-provider-stale-error-observed".to_string())); + assert_eq!(evidence.stale_error_count, 2); + assert_eq!(evidence.oldest_stale_error_age_ms, Some(14_940_000)); let mut report = build_report(1, vec![], IcloudUploadQueueSummary::default(), true, true) .unwrap(); report.file_provider_activity = Some(evidence); @@ -2446,6 +2496,15 @@ mod tests { assert!(evidence .notices .contains(&"icloud-file-provider-stale-error-observed".to_string())); + assert_eq!(evidence.stale_error_count, 1); + assert_eq!(evidence.oldest_stale_error_age_ms, Some(14_940_000)); + } + + #[test] + fn file_provider_activity_rejects_inconsistent_stale_error_aggregate() { + let mut evidence = parse_file_provider_activity_output("", 42, true, false, false); + evidence.stale_error_count = 1; + assert!(validate_file_provider_activity_evidence(&evidence).is_err()); } #[test] @@ -2707,6 +2766,7 @@ mod tests { ); } + #[cfg(not(target_os = "macos"))] #[test] fn oversized_cloud_docs_database_fails_closed_before_snapshot_copy() { let source = tempfile::tempdir().unwrap(); @@ -2738,13 +2798,20 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn oversized_cloud_docs_database_skips_expensive_native_status_probe() { + fn oversized_cloud_docs_database_uses_copy_on_write_clone() { + let oversized_bytes = 512 * 1024 * 1024 + 1; let source = tempfile::tempdir().unwrap(); - fs::File::create(source.path().join("client.db")) + let client_db = source.path().join("client.db"); + fs::File::create(&client_db) .unwrap() - .set_len(MAX_SNAPSHOT_SOURCE_BYTES + 1) + .set_len(oversized_bytes) .unwrap(); - assert!(bounded_native_status(source.path(), 1).is_none()); + + let snapshot = clone_client_database_snapshot(source.path()).unwrap(); + assert_eq!( + fs::metadata(snapshot.client_db).unwrap().len(), + oversized_bytes + ); } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad9481876..a82ca7710 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,10 @@ mod dupes; #[cfg_attr(coverage, allow(dead_code))] mod commands; #[cfg_attr(coverage, allow(dead_code))] +mod runtime_storage_commands; +#[cfg_attr(coverage, allow(dead_code))] +mod container_orphan_commands; +#[cfg_attr(coverage, allow(dead_code))] mod generic_cleanup; #[cfg_attr(coverage, allow(dead_code))] mod node_navigation; @@ -20,6 +24,9 @@ mod userrules; mod settings; #[cfg_attr(coverage, allow(dead_code))] mod safety; +pub use safety::{bind_retained_ontology_class, filesystem_object_id, is_protected}; +#[cfg(all(test, unix))] +mod safety_non_utf8_tests; #[cfg(all(test, target_os = "macos"))] mod macos_temp_guard_tests; #[cfg(all(test, unix))] @@ -59,15 +66,32 @@ pub mod cloud_eviction; pub mod cloud_review; pub mod cloud_transfer; pub mod content_digest; +/// Read-only, identity-bound orphan reclamation across docker/podman/colima runtimes. +pub mod container_orphan_reclaim; +/// Privacy-safe public serialization boundary for container orphan plans and prune receipts. +pub mod container_orphan_public; +#[path = "duplicate_audit.rs"] +mod duplicate_audit_implementation; +/// Public exact-duplicate boundary, including fail-closed legacy-report safety policy. +#[path = "duplicate_audit_public.rs"] pub mod duplicate_audit; pub mod icloud_sync_health; +pub mod icloud_provider_recovery; pub mod judge_calibration; pub mod incomplete_download; pub mod incomplete_download_materialization; pub mod incomplete_download_materialization_destination; pub mod incomplete_download_materialization_execution; pub mod incomplete_download_recovery; +#[path = "git_worktree.rs"] +mod git_worktree_impl; +/// Public Git-worktree API that keeps aggregate operation budgets from becoming local subprocess deadlines. +#[path = "git_worktree_public.rs"] pub mod git_worktree; +/// One-deadline GitHub PR evidence acquisition shared by worktree CLI and desktop surfaces. +pub mod git_worktree_github_evidence; +/// Exact-head, identity-bound reclamation for standalone clones left on stale PR branches. +pub mod git_clone_reclaim; pub mod maven_cache; pub mod multipart_archive; pub mod naruon_capacity; @@ -75,14 +99,22 @@ pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; /// Path-free ontology organization lineage handoff for Naruon/semantic-data-portal. pub mod organization_lineage; +/// Privacy-safe desktop projection of read-only Podman reclaim evidence. +pub mod podman_desktop; +/// Distinct IPC registration for the privacy-safe Podman evidence contract. +pub mod podman_desktop_bridge; /// Read-only evidence plus exact-identity-bound Podman reclaim execution authority. #[path = "podman_reclaim_public.rs"] pub mod podman_reclaim; +/// Read-only VM-backed storage inspection plus explicit guest trim for Podman and Colima. +pub mod runtime_storage; pub mod provider_api_client; pub mod provider_api_write; pub mod provider_capacity; pub mod provider_client_runtime; pub mod provider_recovery; +/// Preserves provider-client running/stopped state across temporary maintenance stops. +pub mod provider_runtime_state; pub mod provider_evidence; pub mod provider_oauth; pub mod provider_global_sync; @@ -138,7 +170,13 @@ pub fn run() { commands::reason_unknown_extensions, commands::plan_brew_cleanup, commands::inspect_podman_reclaim, + podman_desktop_bridge::inspect_podman_desktop_evidence, commands::execute_podman_dangling_image_prune, + runtime_storage_commands::inspect_runtime_storage, + commands::execute_runtime_storage_trim, + commands::execute_runtime_storage_recovery, + container_orphan_commands::inspect_container_orphans, + container_orphan_commands::execute_container_orphan_prune, commands::judge_brew_cleanup, commands::validate_judge_calibration, commands::execute_brew_cleanup, @@ -148,6 +186,8 @@ pub fn run() { commands::evict_icloud_local_copy, commands::plan_stale_git_worktrees, commands::remove_stale_git_worktrees, + commands::plan_stale_git_clone, + commands::remove_stale_git_clone, commands::list_cloud_provider_connections, commands::verify_cloud_provider_capacity, commands::inspect_cloud_provider_client_runtime, diff --git a/src-tauri/src/ontology.rs b/src-tauri/src/ontology.rs index 5aa03998f..7c22ac611 100644 --- a/src-tauri/src/ontology.rs +++ b/src-tauri/src/ontology.rs @@ -14,6 +14,7 @@ const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const RDFS_SUBCLASS: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf"; const RDFS_LABEL: &str = "http://www.w3.org/2000/01/rdf-schema#label"; const DM_TARGET: &str = "https://disksage.app/ontology#targetFolder"; +const DM_DELETION_POLICY: &str = "https://disksage.app/ontology#deletionPolicy"; const OWL_EQUIVALENT_CLASS: &str = "http://www.w3.org/2002/07/owl#equivalentClass"; const OWL_DISJOINT_WITH: &str = "http://www.w3.org/2002/07/owl#disjointWith"; @@ -25,6 +26,7 @@ pub struct OntoClass { pub equivalents: Vec, pub disjoints: Vec, pub target_folder: Option, + pub deletion_policy: Option, } #[derive(Debug, Clone, serde::Serialize)] @@ -40,6 +42,7 @@ pub fn parse_ttl(turtle_src: &str) -> Result { let mut disjoints: BTreeMap> = BTreeMap::new(); let mut labels: BTreeMap = BTreeMap::new(); let mut targets: BTreeMap = BTreeMap::new(); + let mut deletion_policies: BTreeMap = BTreeMap::new(); // 명명 노드 오브젝트만 채택, 순서 보존, 동일 오브젝트 중복 무시. let push = |map: &mut BTreeMap>, s: String, o: &Term| { @@ -83,6 +86,11 @@ pub fn parse_ttl(turtle_src: &str) -> Result { targets.insert(s, lit.value().to_string()); } } + DM_DELETION_POLICY => { + if let Term::Literal(lit) = &triple.object { + deletion_policies.insert(s, lit.value().to_string()); + } + } _ => {} } } @@ -95,6 +103,7 @@ pub fn parse_ttl(turtle_src: &str) -> Result { equivalents: equivalents.get(&id).cloned().unwrap_or_default(), disjoints: disjoints.get(&id).cloned().unwrap_or_default(), target_folder: targets.get(&id).cloned(), + deletion_policy: deletion_policies.get(&id).cloned(), id, }) .collect(); @@ -303,6 +312,38 @@ impl Ontology { } best.map(|(_, _, t)| t) } + + pub fn requires_retention(&self, class_id: &str) -> Result { + let reasoner = self.reasoner(); + if reasoner.rep_of(class_id).is_none() { + return Err("ontology-retention-class-unknown".into()); + } + let mut retained = false; + for ancestor in reasoner.ancestors(class_id) { + let Some(policy) = self + .classes + .iter() + .find(|class| class.id == ancestor) + .and_then(|class| class.deletion_policy.as_deref()) + else { + continue; + }; + if policy != "retain" { + return Err("ontology-retention-policy-unknown".into()); + } + retained = true; + } + Ok(retained) + } +} + +pub fn bundled_class_requires_retention(class_id: &str) -> Result { + static ONTOLOGY: std::sync::OnceLock> = std::sync::OnceLock::new(); + ONTOLOGY + .get_or_init(|| parse_ttl(include_str!("../resources/ontology/default.ttl"))) + .as_ref() + .map_err(Clone::clone)? + .requires_retention(class_id) } #[cfg(test)] @@ -458,6 +499,27 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . assert_eq!(onto.resolve_target(&installer).as_deref(), Some("~/Installers")); } + #[test] + fn default_asset_retention_policy_is_inherited_by_crm_and_keeps_vm_packages() { + let namespace = "https://disksage.app/ontology#"; + assert!(bundled_class_requires_retention(&format!( + "{namespace}CustomerRelationshipManagementData" + )) + .unwrap()); + assert!(bundled_class_requires_retention(&format!("{namespace}VirtualMachinePackage")) + .unwrap()); + assert!(!bundled_class_requires_retention(&format!("{namespace}Installer")).unwrap()); + assert!(bundled_class_requires_retention(&format!("{namespace}Missing")).is_err()); + + let unknown = parse_ttl(&format!( + "{PRE}dm:Business a owl:Class ; dm:deletionPolicy \"invented\" ." + )) + .unwrap(); + assert!(unknown + .requires_retention(&format!("{namespace}Business")) + .is_err()); + } + #[test] fn resolve_target_none_when_parent_chain_cycles() { // targetFolder가 없는 상호 순환 subClassOf — 최대 깊이 방어가 None으로 종료되어야 함 diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index fe7b0e638..8d1993141 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -122,10 +122,13 @@ fn plan_moves_impl( Some(c) => c, None => match pick(&f.path, &candidates) { Some(picked) => picked, - None => match classify(&f.path) { + None if lineage_probe.is_none() => match classify(&f.path) { Some(c) => c.to_string(), None => continue, }, + // A metadata-aware plan must have an explicit rule or content-aware picker + // decision; extension/name-only classification is not movement authority. + None => continue, }, }; let Some(class) = onto.classes.iter().find(|c| local_name(&c.id) == local) else { continue }; @@ -354,7 +357,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . home, 1_800_000_000_000, &[], - &|_, _| None, + &|_, _| Some("Image".to_string()), &|_| Some(lineage.clone()), ); assert_eq!(plans.len(), 1); @@ -364,6 +367,22 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . assert_eq!(validate_move_source(&plans[0]), Err("organize-source-size-changed".into())); } + #[test] + fn metadata_aware_plan_skips_name_only_fallback() { + let onto = parse_ttl(ONTO).unwrap(); + let files = vec![fe("/downloads/pic.png", 1)]; + let plans = plan_moves_with_metadata( + &files, + &onto, + Path::new("/home/u"), + 1_800_000_000_000, + &[], + &|_, _| None, + &|_| Some(LineageMetadata::default()), + ); + assert!(plans.is_empty()); + } + #[test] fn metadata_probe_is_bounded_per_plan() { let onto = parse_ttl(ONTO).unwrap(); @@ -377,7 +396,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . Path::new("/home/u"), 1_800_000_000_000, &[], - &|_, _| None, + &|_, _| Some("Image".to_string()), &|_| { probes.set(probes.get() + 1); Some(LineageMetadata::default()) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs new file mode 100644 index 000000000..94da9f1a8 --- /dev/null +++ b/src-tauri/src/podman_desktop.rs @@ -0,0 +1,547 @@ +//! Desktop-safe projection of read-only Podman reclaim evidence. +//! +//! The headless `podman_reclaim` module intentionally gathers more local detail than the +//! desktop needs. This module converts that report into a bounded, privacy-safe contract +//! that contains measurements and stable issue codes, but never machine names, paths, +//! image identifiers, tags, or shell command text. + +#![deny(missing_docs)] + +use crate::podman_reclaim::{ + probe_podman_reclaim, PodmanReclaimPlan, PodmanRecommendedActionKind, DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, +}; +use serde::Serialize; +use std::path::Path; + +/// Stable schema identifier for the desktop-safe Podman evidence response. +pub const PODMAN_DESKTOP_SCHEMA_KIND: &str = "disksage.podman-desktop-evidence"; + +/// Capacity observations displayed independently so logical size is never confused with +/// host allocation or verified physical reclaimability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCapacityEvidence { + /// Podman machine disk capacity configured by the operator, when available. + pub configured_disk_bytes: Option, + /// Logical length of the VM raw image file, when available. + pub raw_logical_bytes: Option, + /// Host blocks currently allocated to the VM raw image, when supported by the host. + pub host_allocated_bytes: Option, + /// Total bytes reported by the guest root filesystem. + pub guest_total_bytes: Option, + /// Used bytes reported by the guest root filesystem. + pub guest_used_bytes: Option, + /// Available bytes reported by the guest root filesystem. + pub guest_available_bytes: Option, + /// Bytes Podman reports as allocated to its graph root inside the guest. + pub graph_root_allocated_bytes: Option, + /// Bytes Podman reports as used in its graph root inside the guest. + pub graph_root_used_bytes: Option, +} + +/// Logical cleanup candidates reported by Podman without exposing local identifiers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCandidateEvidence { + /// Logical image candidate bytes reported by `podman system df`. + pub image_candidate_bytes: Option, + /// Logical stopped-container candidate bytes reported by `podman system df`. + pub stopped_container_candidate_bytes: Option, + /// Logical local-volume candidate bytes reported by `podman system df`. + pub volume_candidate_bytes: Option, + /// Count of exact image records with no container references. + pub unused_image_records: Option, + /// Count of stopped containers observed in the Podman store. + pub stopped_container_records: Option, + /// SHA-256 commitment to exact unused image identifiers, tags, and sizes. + pub image_candidate_set_sha256: Option, +} + +/// Separate review boundaries for image, stopped-container, and volume decisions. +/// +/// These booleans are advisory only. They do not authorize or execute any mutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopReviewBoundaries { + /// Whether image candidates require an independent human review decision. + pub image_review_required: bool, + /// Whether stopped-container candidates require an independent human review decision. + pub stopped_container_review_required: bool, + /// Whether volume candidates require an independent human review decision. + pub volume_review_required: bool, +} + +/// Privacy-safe, read-only Podman evidence returned to the desktop frontend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopEvidence { + /// Stable schema identifier used by frontend validation. + pub schema_kind: &'static str, + /// Schema version for compatibility checks. + pub schema_version: u32, + /// Operating-system family that produced the evidence. + pub platform: &'static str, + /// True only when the probe is complete and no projected issue invalidates the evidence. + pub evidence_complete: bool, + /// Bounded probe duration in milliseconds. + pub elapsed_ms: u64, + /// Capacity observations kept in distinct semantic categories. + pub capacity: PodmanDesktopCapacityEvidence, + /// Logical candidate observations kept separate by Podman object class. + pub candidates: PodmanDesktopCandidateEvidence, + /// Separate human-review boundaries for images, stopped containers, and volumes. + pub review_boundaries: PodmanDesktopReviewBoundaries, + /// Verified host physical reclaimability; intentionally `None` until before/after proof exists. + pub physically_reclaimable_bytes: Option, + /// Sum of Podman-reported logical candidate bytes, not physical reclaim proof. + pub podman_reported_reclaimable_bytes: Option, + /// Observed host-allocation minus guest-used gap, not physical reclaim proof. + pub raw_allocated_minus_guest_used_bytes: Option, + /// Stable assessment status such as `unverified`. + pub assessment_status: String, + /// Stable, non-sensitive assessment reason codes. + pub reason_codes: Vec, + /// Stable, non-sensitive probe issue codes with dynamic details removed. + pub issue_codes: Vec, + /// User-facing safety statements that define the evidence boundary. + pub notices: Vec, +} + +/// Return true only for a canonical lowercase hexadecimal SHA-256 encoding. +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +/// Return the bounded kebab-case prefix of an untrusted diagnostic code when it is safe. +fn stable_code_prefix(value: &str) -> Option { + let code = value.split(':').next().unwrap_or_default(); + let valid = !code.is_empty() + && code.len() <= 96 + && code + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + valid.then(|| code.to_string()) +} + +/// Reduce untrusted local diagnostic text to a bounded kebab-case issue code. +/// +/// The prefix before the first colon is accepted only when it starts with a lowercase ASCII +/// letter, contains lowercase ASCII letters, digits, or hyphens, and is at most 96 bytes. Paths, +/// socket names, whitespace, uppercase text, Unicode, underscores, and empty prefixes fall back to +/// one stable generic code rather than crossing the desktop IPC boundary. +fn stable_issue_code(value: &str) -> String { + stable_code_prefix(value).unwrap_or_else(|| "podman-evidence-error".to_string()) +} + +/// Return whether a matching recommended action requires independent human approval. +fn has_action(plan: &PodmanReclaimPlan, kind: PodmanRecommendedActionKind) -> bool { + plan.assessment + .recommended_actions + .iter() + .any(|action| action.kind == kind && action.requires_human_approval) +} + +/// Convert a detailed headless Podman plan into the desktop-safe contract. +/// +/// The conversion removes machine names, all local paths, graph-root locations, image IDs, +/// tags, command output, and dynamic error details. Invalid candidate fingerprints, assessment +/// codes, unverified physical-reclaim claims, or any projected issue fail closed by clearing +/// unsafe data and marking the response incomplete. Positive candidates conservatively force the +/// corresponding review boundary even if an upstream recommended-action record is missing. +pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvidence { + let mut issue_codes = plan + .issues + .iter() + .map(|issue| stable_issue_code(issue)) + .collect::>(); + + let candidate_fingerprint = plan + .unused_images + .as_ref() + .map(|images| images.candidate_set_sha256.clone()); + let fingerprint_valid = candidate_fingerprint.as_deref().is_none_or(valid_sha256); + if !fingerprint_valid { + issue_codes.push("podman-desktop-invalid-candidate-fingerprint".to_string()); + } + + let assessment_status_valid = plan.assessment.status == "unverified"; + let assessment_status = if assessment_status_valid { + plan.assessment.status.clone() + } else { + "unverified".to_string() + }; + let mut assessment_codes_valid = assessment_status_valid; + let mut reason_codes = plan + .assessment + .reason_codes + .iter() + .map(|reason| { + stable_code_prefix(reason).unwrap_or_else(|| { + assessment_codes_valid = false; + "podman-assessment-error".to_string() + }) + }) + .collect::>(); + reason_codes.sort(); + reason_codes.dedup(); + if !assessment_codes_valid { + issue_codes.push("podman-desktop-invalid-assessment-code".to_string()); + } + + let physical_reclaim_claim_valid = plan.assessment.physically_reclaimable_bytes.is_none(); + let physically_reclaimable_bytes = if physical_reclaim_claim_valid { + plan.assessment.physically_reclaimable_bytes + } else { + issue_codes.push("podman-desktop-unverified-physical-reclaim-claim".to_string()); + None + }; + + issue_codes.sort(); + issue_codes.dedup(); + let issues_absent = issue_codes.is_empty(); + + let capacity = PodmanDesktopCapacityEvidence { + configured_disk_bytes: plan + .machine + .as_ref() + .and_then(|machine| machine.configured_disk_bytes), + raw_logical_bytes: plan.raw_image.as_ref().map(|image| image.logical_bytes), + host_allocated_bytes: plan + .raw_image + .as_ref() + .and_then(|image| image.allocated_bytes), + guest_total_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.total_bytes), + guest_used_bytes: plan.guest_filesystem.as_ref().map(|guest| guest.used_bytes), + guest_available_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.available_bytes), + graph_root_allocated_bytes: plan + .store + .as_ref() + .map(|store| store.graph_root_allocated_bytes), + graph_root_used_bytes: plan.store.as_ref().map(|store| store.graph_root_used_bytes), + }; + + let candidates = PodmanDesktopCandidateEvidence { + image_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.images.reclaimable_bytes), + stopped_container_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.containers.reclaimable_bytes), + volume_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.local_volumes.reclaimable_bytes), + unused_image_records: plan + .unused_images + .as_ref() + .map(|images| images.unused_records), + stopped_container_records: plan.store.as_ref().map(|store| store.containers_stopped), + image_candidate_set_sha256: candidate_fingerprint.filter(|_| fingerprint_valid), + }; + + let image_review_required = has_action(&plan, PodmanRecommendedActionKind::ReviewUnusedImages) + || candidates + .image_candidate_bytes + .is_some_and(|bytes| bytes > 0) + || candidates + .unused_image_records + .is_some_and(|records| records > 0); + let stopped_container_review_required = + has_action(&plan, PodmanRecommendedActionKind::ReviewStoppedContainers) + || candidates + .stopped_container_candidate_bytes + .is_some_and(|bytes| bytes > 0) + || candidates + .stopped_container_records + .is_some_and(|records| records > 0); + let volume_review_required = + has_action(&plan, PodmanRecommendedActionKind::ReviewUnusedVolumes) + || candidates + .volume_candidate_bytes + .is_some_and(|bytes| bytes > 0); + + PodmanDesktopEvidence { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: plan.platform, + evidence_complete: plan.evidence_complete + && fingerprint_valid + && assessment_codes_valid + && physical_reclaim_claim_valid + && issues_absent, + elapsed_ms: plan.elapsed_ms, + capacity, + candidates, + review_boundaries: PodmanDesktopReviewBoundaries { + image_review_required, + stopped_container_review_required, + volume_review_required, + }, + physically_reclaimable_bytes, + podman_reported_reclaimable_bytes: plan.assessment.podman_reported_reclaimable_bytes, + raw_allocated_minus_guest_used_bytes: plan + .assessment + .raw_allocated_minus_guest_used_bytes, + assessment_status, + reason_codes, + issue_codes, + notices: vec![ + "Podman-reported logical candidates are not verified host physical reclaimability." + .to_string(), + "This desktop surface exposes no prune, remove, machine lifecycle, TRIM, or raw-image mutation command." + .to_string(), + ], + } +} + +/// Run the bounded read-only Podman probe and return only the desktop-safe projection. +/// +/// The command passes an argument vector directly to `std::process::Command` through the +/// headless probe. It never constructs a shell command and never executes a mutation. +pub fn inspect_podman_reclaim() -> PodmanDesktopEvidence { + redact_podman_reclaim_plan(probe_podman_reclaim( + Path::new("podman"), + DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, + PodmanRecommendedAction, PodmanStoreEvidence, PodmanSystemDfCategoryEvidence, + PodmanSystemDfEvidence, PodmanUnusedImageEvidence, RawImageEvidence, + PODMAN_RECLAIM_SCHEMA_KIND, + }; + + /// Build a deterministic Podman `system df` category fixture with one active record. + fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } + } + + /// Build a complete privacy-sensitive headless plan used by redaction regression tests. + fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "a".repeat(64), + }), + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: true, + rationale: "image review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: true, + rationale: "volume review".to_string(), + }, + ], + }, + issues: vec![], + } + } + + /// Verify that the desktop contract keeps capacity categories separate and redacts local data. + #[test] + fn projection_keeps_measurements_separate_and_removes_private_context() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.evidence_complete); + assert_eq!(evidence.capacity.configured_disk_bytes, Some(1000)); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); + assert_eq!(evidence.capacity.host_allocated_bytes, Some(700)); + assert_eq!(evidence.capacity.guest_used_bytes, Some(500)); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + assert_eq!( + evidence.candidates.stopped_container_candidate_bytes, + Some(30) + ); + assert_eq!(evidence.candidates.volume_candidate_bytes, Some(70)); + assert_eq!( + evidence.candidates.image_candidate_set_sha256, + Some("a".repeat(64)) + ); + let json = serde_json::to_string(&evidence).unwrap(); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/private")); + assert!(!json.contains("/var/home/private")); + } + + /// Verify that image, stopped-container, and volume review decisions never authorize each other. + #[test] + fn image_container_and_volume_reviews_remain_separate() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); + + let mut plan = complete_plan(); + plan.store = None; + plan.system_df = None; + plan.unused_images = None; + plan.evidence_complete = false; + plan.assessment.recommended_actions = vec![PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: false, + rationale: "diagnostic only".to_string(), + }]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.review_boundaries.image_review_required); + assert!(!evidence.review_boundaries.stopped_container_review_required); + assert!(!evidence.review_boundaries.volume_review_required); + } + + /// Verify that dynamic local diagnostic details are removed and duplicate stable codes collapse. + #[test] + fn dynamic_issue_details_are_redacted_and_deduplicated() { + let mut plan = complete_plan(); + plan.evidence_complete = false; + plan.issues = vec![ + "podman-info-failed:/Users/alice/private.sock".to_string(), + "podman-info-failed:duplicate detail".to_string(), + "podman-images-timeout".to_string(), + ]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!( + evidence.issue_codes, + vec![ + "podman-images-timeout".to_string(), + "podman-info-failed".to_string(), + ] + ); + assert!(!serde_json::to_string(&evidence) + .unwrap() + .contains("Users/alice")); + } + + /// Verify that malformed candidate fingerprints fail closed without discarding safe measurements. + #[test] + fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { + let mut plan = complete_plan(); + plan.unused_images.as_mut().unwrap().candidate_set_sha256 = "BAD".to_string(); + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + } + + /// Verify that missing optional observations remain unknown rather than becoming false zeroes. + #[test] + fn absent_optional_evidence_stays_unknown_instead_of_becoming_zero() { + let mut plan = complete_plan(); + plan.machine = None; + plan.raw_image = None; + plan.guest_filesystem = None; + plan.store = None; + plan.system_df = None; + plan.unused_images = None; + plan.evidence_complete = false; + let evidence = redact_podman_reclaim_plan(plan); + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.guest_total_bytes, None); + assert_eq!(evidence.capacity.guest_used_bytes, None); + assert_eq!(evidence.capacity.guest_available_bytes, None); + assert_eq!(evidence.capacity.graph_root_allocated_bytes, None); + assert_eq!(evidence.capacity.graph_root_used_bytes, None); + assert_eq!(evidence.candidates.image_candidate_bytes, None); + assert_eq!(evidence.candidates.stopped_container_candidate_bytes, None); + assert_eq!(evidence.candidates.volume_candidate_bytes, None); + assert_eq!(evidence.candidates.unused_image_records, None); + assert_eq!(evidence.candidates.stopped_container_records, None); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + } + + /// Verify stable fallback issue codes and canonical lowercase SHA-256 validation. + #[test] + fn issue_code_fallback_and_fingerprint_validation_are_stable() { + assert_eq!(stable_issue_code(""), "podman-evidence-error"); + assert_eq!(stable_issue_code(":private"), "podman-evidence-error"); + assert_eq!( + stable_issue_code("/Users/alice/private-machine.sock"), + "podman-evidence-error" + ); + assert_eq!(stable_issue_code("UPPERCASE"), "podman-evidence-error"); + assert_eq!(stable_issue_code("unsafe_code"), "podman-evidence-error"); + assert_eq!(stable_issue_code("stable:private"), "stable"); + assert!(valid_sha256(&"0".repeat(64))); + assert!(!valid_sha256(&"A".repeat(64))); + assert!(!valid_sha256("short")); + } +} diff --git a/src-tauri/src/podman_desktop_bridge.rs b/src-tauri/src/podman_desktop_bridge.rs new file mode 100644 index 000000000..cec9fbfb5 --- /dev/null +++ b/src-tauri/src/podman_desktop_bridge.rs @@ -0,0 +1,15 @@ +//! Tauri registration boundary for privacy-safe Podman desktop evidence. +//! +//! DiskSage also exposes a separately governed Podman inspection/prune flow. This module keeps +//! the read-only privacy projection on its own command name so the two contracts cannot alias. + +use crate::podman_desktop::PodmanDesktopEvidence; + +/// Return the read-only, privacy-safe Podman evidence projection on a distinct IPC command. +/// +/// The underlying projection performs no mutation. Its schema-bound notices describe this +/// evidence surface; separately governed Podman actions remain outside this command contract. +#[tauri::command] +pub fn inspect_podman_desktop_evidence() -> PodmanDesktopEvidence { + crate::podman_desktop::inspect_podman_reclaim() +} diff --git a/src-tauri/src/provider_client_runtime.rs b/src-tauri/src/provider_client_runtime.rs index caf82aaf6..3ed8dc7ee 100644 --- a/src-tauri/src/provider_client_runtime.rs +++ b/src-tauri/src/provider_client_runtime.rs @@ -319,6 +319,29 @@ pub fn collect_provider_client_runtime( assess_provider_client_runtime(provider, process_names.as_deref(), observed_at_ms) } +/// Observe only the provider's primary desktop process. +/// +/// Provider extensions may remain alive after the desktop app quits, so recovery operations must +/// not use the broader copy-prerequisite observation when waiting to run a vendor maintenance CLI. +#[cfg(not(coverage))] +pub(crate) fn collect_provider_primary_runtime(provider: CloudProvider) -> Option { + if provider == CloudProvider::Icloud { + return Some(true); + } + let expected = match provider { + CloudProvider::Onedrive => "OneDrive", + CloudProvider::GoogleDrive => "Google Drive", + CloudProvider::Icloud => unreachable!(), + }; + collect_macos_process_names().ok().and_then(|names| { + std::str::from_utf8(&names).ok().map(|names| { + names + .lines() + .any(|name| name.trim().eq_ignore_ascii_case(expected)) + }) + }) +} + #[cfg(not(coverage))] pub fn require_provider_client_runtime( provider: CloudProvider, diff --git a/src-tauri/src/provider_recovery.rs b/src-tauri/src/provider_recovery.rs index da09d3885..2329d468a 100644 --- a/src-tauri/src/provider_recovery.rs +++ b/src-tauri/src/provider_recovery.rs @@ -24,6 +24,11 @@ pub struct ProviderRecoveryOutput { pub source_eviction_executed: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OneDriveUnpinOutcome { + pub restart_blockers: Vec, +} + pub fn recovery_supported(provider: CloudProvider) -> bool { matches!( provider, @@ -39,6 +44,71 @@ fn post_runtime_blockers(runtime_observed: Option) -> Vec { } } +fn recovery_output_after_launch( + provider: CloudProvider, + pre_runtime_observed: bool, + allow_graceful_term: bool, + post_runtime_observed: Option, +) -> ProviderRecoveryOutput { + ProviderRecoveryOutput { + schema_version: PROVIDER_RECOVERY_SCHEMA_VERSION, + provider, + action: if allow_graceful_term { + "restart-provider-client-with-graceful-term".into() + } else { + "restart-provider-client".into() + }, + pre_runtime_observed, + quit_requested: true, + launch_requested: true, + post_runtime_observed, + blockers: post_runtime_blockers(post_runtime_observed), + cloud_write_executed: false, + source_eviction_executed: false, + } +} + +fn finish_onedrive_unpin( + operation: Result<(), String>, + restart: Result<(), String>, +) -> Result { + operation?; + Ok(OneDriveUnpinOutcome { + restart_blockers: restart.err().into_iter().collect(), + }) +} + +fn ensure_onedrive_stop_authority( + primary_runtime_observed: bool, + current_runtime_observed: bool, +) -> Result<(), String> { + if !primary_runtime_observed && current_runtime_observed { + Err("provider-recovery-runtime-started-concurrently".into()) + } else { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OneDriveQuitWaitDecision { + Stopped, + ContinueWaiting, + TimedOut, +} + +fn onedrive_quit_wait_decision( + current_runtime_observed: bool, + deadline_reached: bool, +) -> OneDriveQuitWaitDecision { + if !current_runtime_observed { + OneDriveQuitWaitDecision::Stopped + } else if deadline_reached { + OneDriveQuitWaitDecision::TimedOut + } else { + OneDriveQuitWaitDecision::ContinueWaiting + } +} + /// Request Finder to cancel its active copy/materialization dialog without touching any provider /// daemon, cloud object, or source file. The fixed AppleScript sends only Escape; it accepts no /// user-provided script, path, or process identifier. @@ -142,6 +212,13 @@ fn app_path(provider: CloudProvider) -> Result { .ok_or_else(|| "provider-recovery-client-app-not-found".to_string()) } +#[cfg(all(target_os = "macos", not(coverage)))] +pub(crate) fn onedrive_files_on_demand_available() -> bool { + app_path(CloudProvider::Onedrive) + .map(|app| app.join("Contents/MacOS/OneDrive").is_file()) + .unwrap_or(false) +} + #[cfg(not(coverage))] fn run_bounded(program: &Path, args: &[&str]) -> Result { let mut child = Command::new(program) @@ -172,6 +249,135 @@ fn run_bounded(program: &Path, args: &[&str]) -> Result { } } +#[cfg(all(target_os = "macos", not(coverage)))] +fn onedrive_command_succeeded(status_success: bool, output: &[u8]) -> bool { + status_success + && !output + .windows(b"Failed operation=".len()) + .any(|window| window == b"Failed operation=") +} + +#[cfg(all(target_os = "macos", not(coverage)))] +fn run_bounded_output(program: &Path, args: &[&str]) -> Result<(), String> { + use std::io::{Read, Seek, SeekFrom}; + let mut capture = tempfile::tempfile() + .map_err(|_| "provider-recovery-command-output-unavailable".to_string())?; + let stderr = capture + .try_clone() + .map_err(|_| "provider-recovery-command-output-unavailable".to_string())?; + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::from(capture.try_clone().map_err(|_| { + "provider-recovery-command-output-unavailable".to_string() + })?)) + .stderr(Stdio::from(stderr)) + .spawn() + .map_err(|_| "provider-recovery-command-spawn-failed".to_string())?; + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("provider-recovery-command-timeout".into()); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("provider-recovery-command-wait-failed".into()); + } + } + }; + capture + .seek(SeekFrom::Start(0)) + .map_err(|_| "provider-recovery-command-output-unavailable".to_string())?; + let mut output = Vec::new(); + capture + .take(64 * 1024 + 1) + .read_to_end(&mut output) + .map_err(|_| "provider-recovery-command-output-unavailable".to_string())?; + if output.len() > 64 * 1024 { + return Err("provider-recovery-command-output-too-large".into()); + } + if onedrive_command_succeeded(status.success(), &output) { + Ok(()) + } else { + Err("onedrive-files-on-demand-command-failed".into()) + } +} + +#[cfg(all(target_os = "macos", not(coverage)))] +fn launch_provider(path: &Path) -> Result<(), String> { + let path = path + .to_str() + .ok_or_else(|| "provider-recovery-client-path-invalid".to_string())?; + if !run_bounded(Path::new("/usr/bin/open"), &["-a", path])? { + return Err("provider-recovery-launch-failed".into()); + } + Ok(()) +} + +/// Invoke OneDrive's documented Files On-Demand command while its sync app is stopped, then +/// restore the verified app only when it was running before the maintenance operation. +#[cfg(all(target_os = "macos", not(coverage)))] +pub(crate) fn unpin_onedrive_local_copy(path: &Path) -> Result { + let app = app_path(CloudProvider::Onedrive)?; + let executable = app.join("Contents/MacOS/OneDrive"); + if !executable.is_file() { + return Err("onedrive-files-on-demand-command-unavailable".into()); + } + let path = path + .to_str() + .ok_or_else(|| "cloud-local-eviction-path-not-unicode".to_string())?; + let primary_runtime_observed = crate::provider_client_runtime::collect_provider_primary_runtime( + CloudProvider::Onedrive, + ) + .ok_or_else(|| "provider-recovery-runtime-evidence-unavailable".to_string())?; + if primary_runtime_observed { + if request_quit("OneDrive").is_err() { + request_graceful_term("OneDrive")?; + } + } + let operation = (|| { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let current_runtime_observed = require_primary_runtime_observation(CloudProvider::Onedrive)?; + ensure_onedrive_stop_authority(primary_runtime_observed, current_runtime_observed)?; + match onedrive_quit_wait_decision( + current_runtime_observed, + Instant::now() >= deadline, + ) { + OneDriveQuitWaitDecision::Stopped => break, + OneDriveQuitWaitDecision::ContinueWaiting => {} + OneDriveQuitWaitDecision::TimedOut => { + return Err("provider-recovery-quit-timeout".into()); + } + } + std::thread::sleep(Duration::from_millis(250)); + } + run_bounded_output(&executable, &["/unpin", path]) + })(); + let restart = crate::provider_runtime_state::restore_after_temporary_stop( + primary_runtime_observed, + || { + launch_provider(&app).and_then(|_| { + std::thread::sleep(Duration::from_secs(1)); + match runtime_observation(CloudProvider::Onedrive, 0) { + Some(true) => Ok(()), + Some(false) => { + Err("provider-client-runtime-not-observed-after-restart".into()) + } + None => Err("provider-client-runtime-evidence-unavailable-after-restart".into()), + } + }) + }, + ); + finish_onedrive_unpin(operation, restart) +} + #[cfg(not(coverage))] fn runtime_observation(provider: CloudProvider, observed_at_ms: u64) -> Option { crate::provider_client_runtime::collect_provider_client_runtime(provider, observed_at_ms) @@ -187,19 +393,25 @@ fn require_runtime_observation( .ok_or_else(|| "provider-recovery-runtime-evidence-unavailable".to_string()) } +#[cfg(not(coverage))] +fn require_primary_runtime_observation(provider: CloudProvider) -> Result { + crate::provider_client_runtime::collect_provider_primary_runtime(provider) + .ok_or_else(|| "provider-recovery-runtime-evidence-unavailable".to_string()) +} + #[cfg(not(coverage))] fn request_quit(app: &str) -> Result<(), String> { // The app name is selected from the fixed provider map above; no user path or shell is parsed. let script = format!("tell application \"{app}\" to quit"); let ok = run_bounded(Path::new("/usr/bin/osascript"), &["-e", script.as_str()])?; - // AppleScript returns non-zero when the app was already absent. The subsequent runtime - // observation is authoritative; unavailable evidence must never be treated as process absence. + // AppleScript can return non-zero after the primary app has already disappeared. Extensions + // may legitimately remain, so only the exact desktop process is authoritative here. let provider = if app == "OneDrive" { CloudProvider::Onedrive } else { CloudProvider::GoogleDrive }; - if !ok && require_runtime_observation(provider, 0)? { + if !ok && require_primary_runtime_observation(provider)? { return Err("provider-recovery-quit-request-failed".into()); } Ok(()) @@ -215,7 +427,7 @@ fn request_graceful_term(app: &str) -> Result<(), String> { } else { CloudProvider::GoogleDrive }; - if !ok && require_runtime_observation(provider, 0)? { + if !ok && require_primary_runtime_observation(provider)? { return Err("provider-recovery-graceful-term-failed".into()); } Ok(()) @@ -272,31 +484,15 @@ pub fn recover_provider_client_with_options( std::thread::sleep(Duration::from_millis(250)); } - let path_string = path - .to_str() - .ok_or_else(|| "provider-recovery-client-path-invalid".to_string())?; - if !run_bounded(Path::new("/usr/bin/open"), &["-a", path_string])? { - return Err("provider-recovery-launch-failed".into()); - } + launch_provider(&path)?; std::thread::sleep(Duration::from_secs(1)); let post_runtime_observed = runtime_observation(provider, observed_at_ms); - let blockers = post_runtime_blockers(post_runtime_observed); - Ok(ProviderRecoveryOutput { - schema_version: PROVIDER_RECOVERY_SCHEMA_VERSION, + Ok(recovery_output_after_launch( provider, - action: if allow_graceful_term { - "restart-provider-client-with-graceful-term".into() - } else { - "restart-provider-client".into() - }, pre_runtime_observed, - quit_requested: true, - launch_requested: true, + allow_graceful_term, post_runtime_observed, - blockers, - cloud_write_executed: false, - source_eviction_executed: false, - }) + )) } } @@ -341,6 +537,89 @@ mod tests { assert_eq!(json["source_eviction_executed"], false); } + #[test] + fn slow_post_restart_observation_is_structured_recovery_evidence() { + let output = recovery_output_after_launch( + CloudProvider::Onedrive, + true, + false, + Some(false), + ); + assert_eq!(output.post_runtime_observed, Some(false)); + assert_eq!( + output.blockers, + vec!["provider-client-runtime-not-observed-after-restart"] + ); + assert!(output.launch_requested); + assert!(!output.cloud_write_executed); + assert!(!output.source_eviction_executed); + } + + #[test] + fn unavailable_post_restart_observation_is_structured_recovery_evidence() { + let output = recovery_output_after_launch(CloudProvider::GoogleDrive, true, true, None); + assert_eq!(output.post_runtime_observed, None); + assert_eq!( + output.blockers, + vec!["provider-client-runtime-evidence-unavailable-after-restart"] + ); + assert_eq!(output.action, "restart-provider-client-with-graceful-term"); + } + + #[test] + fn successful_unpin_preserves_restart_failure_as_a_blocker() { + let outcome = finish_onedrive_unpin( + Ok(()), + Err("provider-client-runtime-not-observed-after-restart".into()), + ) + .unwrap(); + assert_eq!( + outcome.restart_blockers, + vec!["provider-client-runtime-not-observed-after-restart"] + ); + } + + #[test] + fn failed_unpin_remains_a_hard_operation_failure() { + assert_eq!( + finish_onedrive_unpin( + Err("onedrive-files-on-demand-command-failed".into()), + Err("provider-client-runtime-not-observed-after-restart".into()), + ) + .unwrap_err(), + "onedrive-files-on-demand-command-failed" + ); + } + + #[test] + fn concurrently_started_onedrive_is_not_owned_by_maintenance_stop() { + assert_eq!( + ensure_onedrive_stop_authority(false, true).unwrap_err(), + "provider-recovery-runtime-started-concurrently" + ); + assert!(ensure_onedrive_stop_authority(false, false).is_ok()); + assert!(ensure_onedrive_stop_authority(true, true).is_ok()); + } + + #[test] + fn onedrive_unpin_timeout_never_escalates_name_only_runtime_evidence() { + assert_eq!( + onedrive_quit_wait_decision(true, true), + OneDriveQuitWaitDecision::TimedOut + ); + } + + #[cfg(all(target_os = "macos", not(coverage)))] + #[test] + fn onedrive_command_rejects_failure_text_even_with_zero_exit() { + assert!(onedrive_command_succeeded(true, b"")); + assert!(!onedrive_command_succeeded( + true, + b"Failed operation=2 status=-2" + )); + assert!(!onedrive_command_succeeded(false, b"")); + } + #[cfg(all(not(target_os = "macos"), not(coverage)))] #[test] fn unavailable_runtime_evidence_is_not_process_absence() { diff --git a/src-tauri/src/provider_runtime_state.rs b/src-tauri/src/provider_runtime_state.rs new file mode 100644 index 000000000..b0526c78c --- /dev/null +++ b/src-tauri/src/provider_runtime_state.rs @@ -0,0 +1,19 @@ +//! Preserves provider-client runtime state around temporary maintenance operations. + +/// Restore a provider client only when DiskSage observed it running before the temporary stop. +/// +/// The caller owns the actual restart operation. A client that was already stopped must remain +/// stopped; DiskSage must not create new background activity merely because maintenance completed. +pub fn restore_after_temporary_stop( + was_running: bool, + restart: F, +) -> Result<(), String> +where + F: FnOnce() -> Result<(), String>, +{ + if was_running { + restart() + } else { + Ok(()) + } +} diff --git a/src-tauri/src/provider_sync.rs b/src-tauri/src/provider_sync.rs index a9dc2c466..8b0448241 100644 --- a/src-tauri/src/provider_sync.rs +++ b/src-tauri/src/provider_sync.rs @@ -369,6 +369,20 @@ fn file_provider_identifier_fingerprint(output: &str) -> Result Ok(hasher.finalize().to_hex().to_string()) } +/// Parse only the provider facts needed to prove that local administrative bytes are current. +/// Upload, conflict, and eviction fields are intentionally outside this non-eviction decision. +pub fn parse_file_providerctl_local_current( + output: &str, + observed_bytes: u64, +) -> Result { + if file_provider_status_u64(output, "documentSize")? != observed_bytes { + return Err("file-provider-status-document-size-mismatch".into()); + } + Ok(file_provider_status_bool(output, "isDownloaded")? + && !file_provider_status_bool(output, "isDownloading")? + && file_provider_status_bool(output, "isMostRecentVersionDownloaded")?) +} + /// Parse the status needed for sync and local-cache decisions without retaining the raw item ID. pub fn parse_file_providerctl_item_status( output: &str, @@ -1316,6 +1330,18 @@ mod tests { ); } + #[test] + fn local_current_parser_does_not_require_unrelated_sync_fields() { + let local_only = r#" + documentSize = 42; + isDownloaded = 1; + isDownloading = 0; + isMostRecentVersionDownloaded = 1; + "#; + assert!(parse_file_providerctl_local_current(local_only, 42).unwrap()); + assert!(parse_file_providerctl_local_current(local_only, 41).is_err()); + } + #[test] fn planner_marks_pending_file_provider_item_as_incomplete() { let output = uploaded_file_provider_output().replace("isUploaded = 1", "isUploaded = 0"); diff --git a/src-tauri/src/reclaim.rs b/src-tauri/src/reclaim.rs index 5539314f6..f3eeb6298 100644 --- a/src-tauri/src/reclaim.rs +++ b/src-tauri/src/reclaim.rs @@ -544,7 +544,7 @@ mod tests { .unwrap(); let evidence = plan.paths[0].active_use.as_ref().unwrap(); assert!(evidence.evidence_complete || evidence.error.is_some()); - assert_eq!(evidence.method, "lsof-file-pid"); + assert_eq!(evidence.method, "lsof-file-pid+ps-argv"); assert!(evidence.observed_pids.len() <= ACTIVE_USE_PROBE_MAX_PIDS); } diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 32d0fce10..6bc21a16b 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -30,6 +30,22 @@ impl BaseDirs { } } +/// Returns the platform's shared temporary directory using a stable, real directory path. +#[cfg(target_os = "macos")] +pub(crate) fn shared_temp_root() -> PathBuf { + PathBuf::from("/private/tmp") +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn shared_temp_root() -> PathBuf { + PathBuf::from("/tmp") +} + +#[cfg(not(unix))] +pub(crate) fn shared_temp_root() -> PathBuf { + PathBuf::new() +} + #[derive(Debug, Clone, serde::Serialize)] pub struct CacheCandidate { pub id: String, @@ -93,6 +109,11 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { .unwrap_or_else(|| bases.local_data.join("huggingface")); #[cfg(target_os = "macos")] entries.extend([ + ( + "fileprovider-temporary-items", + "macOS FileProvider 임시 진단 데이터", + bases.temp.join("com.apple.fileproviderd").join("TemporaryItems"), + ), ("uv-cache", "uv 캐시", uv), ("huggingface-cache", "Hugging Face 캐시", huggingface), ("codex-runtimes-cache", "Codex 런타임 캐시", bases.local_data.join("codex-runtimes")), @@ -104,6 +125,15 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { "pnpm 캐시", bases.home.join("Library").join("Caches").join("pnpm"), ), + ( + "playwright-cache", + "Playwright 브라우저 캐시", + bases + .home + .join("Library") + .join("Caches") + .join("ms-playwright"), + ), ( "node-cache", "Node.js 캐시", @@ -139,7 +169,49 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { "Trivy 취약점 스캔 캐시", bases.home.join("Library").join("Caches").join("trivy"), ), + ( + "appmap-download-cache", + "AppMap 다운로드 캐시", + bases.home.join(".appmap").join("lib"), + ), + ( + "superset-network-logs", + "Superset 네트워크 진단 로그", + bases + .home + .join("Library") + .join("Application Support") + .join("Superset") + .join("network-logs"), + ), + ( + "superset-http-cache", + "Superset 임시 웹 콘텐츠", + bases.home.join("Library/Application Support/Superset/Partitions/superset/Cache"), + ), + ( + "superset-code-cache", + "Superset 임시 실행 파일", + bases.home.join("Library/Application Support/Superset/Partitions/superset/Code Cache"), + ), ]); + #[cfg(target_os = "macos")] + if let Some(session_root) = bases.temp.parent() { + entries.push(( + "edge-code-sign-clones", + "Microsoft Edge code-sign 임시 복제본", + session_root + .join("X") + .join("com.microsoft.edgemac.code_sign_clone"), + )); + } + + // `/tmp` is a symlink on macOS; use `/private/tmp` so the root itself is a real directory. + // Shared temporary cleanup is limited to current-user-owned, non-linked trees below. + #[cfg(unix)] + if bases.temp != shared_temp_root() { + entries.push(("shared-temp", "공유 임시 폴더", shared_temp_root())); + } // Windows 진단 캐시 — 조용히 수십 GB로 자라는 것들. RDP 자동 추적(RdClientAutoTrace)의 .etl 로그가 // 대표적: 원격 접속 세션마다 쌓여 재발하므로, os-temp에 묻어두지 않고 명명 항목으로 노출해 @@ -470,24 +542,45 @@ impl CatalogRoot { } } +fn measure_cache_candidate(id: &str, label: &str, path: PathBuf) -> CacheCandidate { + let root = CatalogRoot::open(&path); + let exists = root.is_some(); + let bytes = if id == "shared-temp" { + cache_targets(&path) + .ok() + .map(|targets| { + targets + .into_iter() + .fold(0u64, |total, target| total.saturating_add(target.bytes)) + }) + .unwrap_or(0) + } else { + root.as_ref().map(CatalogRoot::directory_size).unwrap_or(0) + }; + CacheCandidate { + id: id.into(), + label: label.into(), + path: path.to_string_lossy().into_owned(), + bytes, + exists, + } +} + pub fn cache_candidates(bases: &BaseDirs) -> Vec { catalog(bases) .into_iter() - .map(|(id, label, path)| { - let root = CatalogRoot::open(&path); - let exists = root.is_some(); - let bytes = root.as_ref().map(CatalogRoot::directory_size).unwrap_or(0); - CacheCandidate { - id: id.into(), - label: label.into(), - path: path.to_string_lossy().into_owned(), - bytes, - exists, - } - }) + .map(|(id, label, path)| measure_cache_candidate(id, label, path)) .collect() } +/// Measure one fixed catalog root without traversing unrelated caches. +pub fn cache_candidate(bases: &BaseDirs, requested_id: &str) -> Option { + catalog(bases) + .into_iter() + .find(|(id, _, _)| *id == requested_id) + .map(|(id, label, path)| measure_cache_candidate(id, label, path)) +} + /// dir이 현재 카탈로그가 가리키는 경로인지 (expand_clean_targets의 스코프 검증용 — 크기 계산 없음) pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { catalog(bases).iter().any(|(_, _, p)| p == dir) && CatalogRoot::open(dir).is_some() @@ -514,6 +607,7 @@ fn modified_ms(metadata: &std::fs::Metadata) -> u64 { /// The object identity, size, and modification timestamp bind the later mutation to this snapshot. pub fn cache_targets(dir: &Path) -> Result, String> { let root = CatalogRoot::open(dir).ok_or("cache-root-not-current-or-safe")?; + let shared_temp = cfg!(unix) && dir == shared_temp_root(); let paths = root.child_paths(); if paths.len() > MAX_CACHE_TARGETS { return Err("cache-target-limit-exceeded".into()); @@ -525,6 +619,9 @@ pub fn cache_targets(dir: &Path) -> Result, String> { if metadata.file_type().is_symlink() || !(metadata.is_file() || metadata.is_dir()) { continue; } + if shared_temp && !crate::safety::is_user_owned_shared_temp_tree(&path) { + continue; + } let bytes = if metadata.is_dir() { CatalogRoot::open(&path) .ok_or_else(|| "cache-target-directory-unavailable".to_string())? @@ -580,6 +677,10 @@ mod tests { let npm_c = cands.iter().find(|c| c.id == "npm-cache").unwrap(); assert!(npm_c.exists); assert_eq!(npm_c.bytes, 128); + let targeted = cache_candidate(&bases, "npm-cache").unwrap(); + assert_eq!(targeted.path, npm_c.path); + assert_eq!(targeted.bytes, 128); + assert!(cache_candidate(&bases, "not-in-catalog").is_none()); let temp_c = cands.iter().find(|c| c.id == "os-temp").unwrap(); assert!(!temp_c.exists); assert_eq!(temp_c.bytes, 0); @@ -587,6 +688,44 @@ mod tests { assert!(cargo_source.path.ends_with(".cargo/registry/src")); // 카탈로그에 최소 4개 규칙 assert!(cands.len() >= 4); + #[cfg(target_os = "macos")] + assert_eq!( + cands + .iter() + .find(|candidate| candidate.id == "edge-code-sign-clones") + .unwrap() + .path, + tmp.path() + .join("X/com.microsoft.edgemac.code_sign_clone") + .to_string_lossy() + ); + } + + #[cfg(unix)] + #[test] + fn catalog_includes_shared_temp_when_user_temp_is_separate() { + let tmp = tempfile::tempdir().unwrap(); + let bases = fake_bases(tmp.path()); + let shared = shared_temp_root(); + assert_ne!(bases.temp, shared); + let entry = catalog(&bases) + .into_iter() + .find(|(id, _, _)| *id == "shared-temp") + .expect("shared temporary storage must be inspectable"); + assert_eq!(entry.2, shared); + } + + #[cfg(unix)] + #[test] + fn shared_temp_targets_accept_only_current_user_owned_trees() { + let Ok(tmp) = tempfile::tempdir_in(shared_temp_root()) else { + return; + }; + fs::write(tmp.path().join("owned.bin"), b"owned").unwrap(); + let targets = cache_targets(tmp.path()).unwrap(); + assert_eq!(targets.len(), 1); + assert!(targets[0].path.ends_with("owned.bin")); + assert!(crate::safety::is_user_owned_shared_temp_tree(Path::new(&targets[0].path))); } #[cfg(windows)] @@ -625,6 +764,7 @@ mod tests { let candidates = cache_candidates(&bases); for (id, suffix) in [ ("pnpm-cache", "Library/Caches/pnpm"), + ("playwright-cache", "Library/Caches/ms-playwright"), ("node-cache", "local/node"), ("torch-cache", "local/torch"), ("prisma-cache", "local/prisma"), @@ -632,6 +772,19 @@ mod tests { ("adobe-cache", "Library/Caches/Adobe"), ("edge-cache", "Library/Caches/Microsoft Edge"), ("trivy-cache", "Library/Caches/trivy"), + ("appmap-download-cache", ".appmap/lib"), + ( + "superset-network-logs", + "Library/Application Support/Superset/network-logs", + ), + ( + "superset-http-cache", + "Library/Application Support/Superset/Partitions/superset/Cache", + ), + ( + "superset-code-cache", + "Library/Application Support/Superset/Partitions/superset/Code Cache", + ), ] { let candidate = candidates .iter() diff --git a/src-tauri/src/runtime_storage.rs b/src-tauri/src/runtime_storage.rs new file mode 100644 index 000000000..627584ee6 --- /dev/null +++ b/src-tauri/src/runtime_storage.rs @@ -0,0 +1,649 @@ +//! Read-only planning and explicit guest trim for Podman and Colima storage. +//! +//! A VM-backed runtime can report a large logical store while its sparse host image keeps +//! allocated extents. DiskSage therefore separates guest `fstrim` from host-image compaction: +//! trim is an optional, bounded command; raw-image compaction is never guessed or run by the app. + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::io::Read; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const SCHEMA_VERSION: u32 = 1; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const RECOVERY_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_CAPTURE_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeStorageKind { + PodmanMachine, + Colima, +} + +impl RuntimeStorageKind { + pub fn as_str(self) -> &'static str { + match self { + Self::PodmanMachine => "podman-machine", + Self::Colima => "colima", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeStoragePlan { + pub schema_kind: &'static str, + pub schema_version: u32, + pub runtime: RuntimeStorageKind, + pub display_name: String, + pub executable_available: bool, + pub guest_running: Option, + pub guest_reachable: Option, + pub trim_command: Option>, + pub recovery_command: Option>>, + pub host_compaction_supported: bool, + pub host_compaction_blockers: Vec, + pub observed_at_ms: u64, + pub plan_fingerprint: String, + pub exact_approval_phrase: Option, + pub recovery_approval_phrase: Option, + pub evidence_complete: bool, + pub issue: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeStorageExecution { + pub schema_kind: &'static str, + pub schema_version: u32, + pub runtime: RuntimeStorageKind, + pub command: Vec, + pub status_code: i32, + #[serde(skip_serializing)] + pub stdout: String, + #[serde(skip_serializing)] + pub stderr: String, + pub output_truncated: bool, + pub executed: bool, + pub executed_at_ms: u64, + pub rationale: String, + pub volume_comparison: Option, + pub volume_evidence_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeStorageRecoveryExecution { + pub schema_kind: &'static str, + pub schema_version: u32, + pub runtime: RuntimeStorageKind, + pub command: Vec>, + pub stop_status_code: i32, + pub start_status_code: i32, + pub guest_reachable_after_recovery: bool, + pub executed: bool, + pub executed_at_ms: u64, + pub rationale: String, +} + +fn fixed_binary(runtime: RuntimeStorageKind) -> PathBuf { + let (name, candidates): (&str, &[&str]) = match runtime { + RuntimeStorageKind::PodmanMachine => ( + "podman", + &[ + "/opt/homebrew/bin/podman", + "/usr/local/bin/podman", + "/usr/bin/podman", + ], + ), + RuntimeStorageKind::Colima => ( + "colima", + &[ + "/opt/homebrew/bin/colima", + "/usr/local/bin/colima", + "/usr/bin/colima", + ], + ), + }; + candidates + .iter() + .map(PathBuf::from) + .find(|path| std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())) + .unwrap_or_else(|| PathBuf::from(name)) +} + +fn bounded_output(bytes: Vec) -> (String, bool) { + let truncated = bytes.len() > MAX_CAPTURE_BYTES; + let bytes = bytes + .into_iter() + .take(MAX_CAPTURE_BYTES) + .collect::>(); + (String::from_utf8_lossy(&bytes).into_owned(), truncated) +} + +fn drain_bounded(mut reader: R) -> std::io::Result<(Vec, bool)> { + let mut buffer = [0_u8; 8 * 1024]; + let mut captured = Vec::with_capacity(MAX_CAPTURE_BYTES); + let mut truncated = false; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let room = MAX_CAPTURE_BYTES.saturating_sub(captured.len()); + captured.extend_from_slice(&buffer[..read.min(room)]); + truncated |= read > room; + } + Ok((captured, truncated)) +} + +fn run_bounded(binary: &Path, args: &[&str]) -> Result<(i32, String, String, bool), String> { + run_bounded_with_timeout(binary, args, COMMAND_TIMEOUT) +} + +fn run_bounded_with_timeout( + binary: &Path, + args: &[&str], + timeout: Duration, +) -> Result<(i32, String, String, bool), String> { + let mut command = Command::new(binary); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .map_err(|_| "runtime-storage-command-failed".to_string())?; + let child_pid = child.id(); + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err("runtime-storage-stdout-pipe-unavailable".into()); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err("runtime-storage-stderr-pipe-unavailable".into()); + } + }; + let stdout_reader = thread::spawn(move || drain_bounded(stdout)); + let stderr_reader = thread::spawn(move || drain_bounded(stderr)); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() >= timeout => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("runtime-storage-command-timeout".into()); + } + Ok(None) => thread::sleep(Duration::from_millis(25)), + Err(_) => { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("runtime-storage-command-failed".into()); + } + } + }; + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child_pid as libc::pid_t), libc::SIGKILL); + } + let (stdout, stdout_truncated) = stdout_reader + .join() + .map_err(|_| "runtime-storage-stdout-reader-panicked".to_string())? + .map_err(|_| "runtime-storage-stdout-read-failed".to_string())?; + let (stderr, stderr_truncated) = stderr_reader + .join() + .map_err(|_| "runtime-storage-stderr-reader-panicked".to_string())? + .map_err(|_| "runtime-storage-stderr-read-failed".to_string())?; + let (stdout, stdout_truncated_by_utf8) = bounded_output(stdout); + let (stderr, stderr_truncated_by_utf8) = bounded_output(stderr); + Ok(( + status.code().unwrap_or(-1), + stdout, + stderr, + stdout_truncated + || stderr_truncated + || stdout_truncated_by_utf8 + || stderr_truncated_by_utf8, + )) +} + +fn reachability_from_probe(result: Result<(i32, String, String, bool), String>) -> Option { + result.ok().map(|output| output.0 == 0) +} + +fn colima_running_status(stdout: &str) -> Option { + let value = serde_json::from_str::(stdout).ok()?; + if let Some(status) = value.get("status").and_then(serde_json::Value::as_str) { + return Some(status.eq_ignore_ascii_case("running")); + } + let object = value.as_object()?; + ["display_name", "runtime", "driver"] + .iter() + .all(|key| { + object + .get(*key) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()) + }) + .then_some(true) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +fn trim_command(runtime: RuntimeStorageKind) -> Vec { + match runtime { + RuntimeStorageKind::PodmanMachine => vec![ + "podman".into(), + "machine".into(), + "ssh".into(), + "podman-machine-default".into(), + "--".into(), + "sudo".into(), + "fstrim".into(), + "-av".into(), + ], + RuntimeStorageKind::Colima => vec![ + "colima".into(), + "ssh".into(), + "--".into(), + "sudo".into(), + "fstrim".into(), + "-av".into(), + ], + } +} + +fn fingerprint( + runtime: RuntimeStorageKind, + running: Option, + reachable: Option, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"disksage.runtime-storage-plan.v1\0"); + hasher.update(runtime.as_str().as_bytes()); + hasher.update([running.unwrap_or(false) as u8]); + hasher.update([reachable.unwrap_or(false) as u8]); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn inspect_runtime(runtime: RuntimeStorageKind, observed_at_ms: u64) -> RuntimeStoragePlan { + let binary = fixed_binary(runtime); + let version = run_bounded(&binary, &["--version"]); + let executable_available = version.is_ok_and(|(status, _, _, _)| status == 0); + let (guest_running, issue) = if !executable_available { + (None, Some("runtime-storage-executable-unavailable".into())) + } else { + let state = match runtime { + RuntimeStorageKind::PodmanMachine => run_bounded( + &binary, + &[ + "machine", + "inspect", + "podman-machine-default", + "--format", + "{{.State}}", + ], + ), + RuntimeStorageKind::Colima => run_bounded(&binary, &["status", "--json"]), + }; + match state { + Ok((status, stdout, _, _)) if status == 0 => { + let (running, state_valid) = match runtime { + RuntimeStorageKind::PodmanMachine => { + (stdout.trim().eq_ignore_ascii_case("running"), true) + } + RuntimeStorageKind::Colima => { + let running = colima_running_status(&stdout); + (running.unwrap_or(false), running.is_some()) + } + }; + if runtime == RuntimeStorageKind::Colima && !state_valid { + (None, Some("runtime-storage-state-invalid".into())) + } else { + (Some(running), None) + } + } + Ok(_) => (None, Some("runtime-storage-state-unavailable".into())), + Err(error) => (None, Some(error)), + } + }; + let guest_reachable = if guest_running == Some(true) { + let args = match runtime { + RuntimeStorageKind::PodmanMachine => { + ["machine", "ssh", "podman-machine-default", "--", "true"].as_slice() + } + RuntimeStorageKind::Colima => ["ssh", "--", "true"].as_slice(), + }; + // A completed non-zero probe proves the guest is unreachable. Probe failures and + // timeouts are incomplete evidence and must not authorize a restart. + reachability_from_probe(run_bounded(&binary, args)) + } else { + None + }; + let fingerprint = fingerprint(runtime, guest_running, guest_reachable); + let ready = + executable_available && guest_running == Some(true) && guest_reachable == Some(true); + let recovery_ready = + executable_available && guest_running == Some(true) && guest_reachable == Some(false); + let recovery_command = recovery_ready.then(|| match runtime { + RuntimeStorageKind::PodmanMachine => vec![ + vec![ + "podman".into(), + "machine".into(), + "stop".into(), + "podman-machine-default".into(), + ], + vec![ + "podman".into(), + "machine".into(), + "start".into(), + "podman-machine-default".into(), + ], + ], + RuntimeStorageKind::Colima => vec![ + vec!["colima".into(), "stop".into()], + vec!["colima".into(), "start".into()], + ], + }); + RuntimeStoragePlan { + schema_kind: "disksage.runtime-storage-plan", + schema_version: SCHEMA_VERSION, + runtime, + display_name: match runtime { + RuntimeStorageKind::PodmanMachine => "Podman 가상 머신".into(), + RuntimeStorageKind::Colima => "Colima 가상 머신".into(), + }, + executable_available, + guest_running, + guest_reachable, + trim_command: ready.then(|| trim_command(runtime)), + recovery_command, + host_compaction_supported: false, + host_compaction_blockers: vec![ + "host-image-compaction-requires-runtime-native-tool".into(), + "disk-sage-will-not-rewrite-vm-image".into(), + ], + observed_at_ms, + plan_fingerprint: fingerprint.clone(), + exact_approval_phrase: ready.then(|| { + format!( + "DiskSage {} 게스트 정리 승인 {}", + runtime.as_str(), + fingerprint + ) + }), + recovery_approval_phrase: recovery_ready.then(|| { + format!( + "DiskSage {} 연결 복구 승인 {}", + runtime.as_str(), + fingerprint + ) + }), + evidence_complete: executable_available + && guest_running.is_some() + && (guest_running != Some(true) || guest_reachable.is_some()), + issue, + } +} + +/// Restart a runtime only when it reports running but its guest is unreachable. +pub fn execute_recovery( + runtime: RuntimeStorageKind, + confirmation_phrase: &str, + rationale: &str, +) -> Result { + if rationale.trim().is_empty() + || rationale != rationale.trim() + || rationale.chars().count() > 1_000 + || rationale.chars().any(char::is_control) + { + return Err("runtime-storage-rationale-invalid".into()); + } + let plan = inspect_runtime(runtime, now_ms()); + let expected = plan + .recovery_approval_phrase + .as_deref() + .ok_or("runtime-storage-recovery-not-ready")?; + if confirmation_phrase != expected { + return Err("runtime-storage-confirmation-mismatch".into()); + } + let binary = fixed_binary(runtime); + let (stop_args, start_args): (&[&str], &[&str]) = match runtime { + RuntimeStorageKind::PodmanMachine => ( + &["machine", "stop", "podman-machine-default"], + &["machine", "start", "podman-machine-default"], + ), + RuntimeStorageKind::Colima => (&["stop"], &["start"]), + }; + let stop = run_bounded_with_timeout(&binary, stop_args, RECOVERY_TIMEOUT)?; + if stop.0 != 0 { + return Err("runtime-storage-recovery-stop-failed".into()); + } + // Once stop succeeds, the approved operation has already mutated runtime state. Preserve + // that fact as a structured receipt even when the restart command itself fails or times out. + let start = run_bounded_with_timeout(&binary, start_args, RECOVERY_TIMEOUT) + .unwrap_or_else(|_| (-1, String::new(), String::new(), false)); + let reachable = if start.0 == 0 { + inspect_runtime(runtime, now_ms()).guest_reachable == Some(true) + } else { + false + }; + Ok(RuntimeStorageRecoveryExecution { + schema_kind: "disksage.runtime-storage-recovery-execution", + schema_version: SCHEMA_VERSION, + runtime, + command: plan.recovery_command.unwrap_or_default(), + stop_status_code: stop.0, + start_status_code: start.0, + guest_reachable_after_recovery: reachable, + executed: start.0 == 0, + executed_at_ms: now_ms(), + rationale: rationale.into(), + }) +} + +/// Inspect both supported VM-backed runtimes without mutating their stores. +pub fn inspect() -> Vec { + let observed_at_ms = now_ms(); + [ + RuntimeStorageKind::PodmanMachine, + RuntimeStorageKind::Colima, + ] + .into_iter() + .map(|runtime| inspect_runtime(runtime, observed_at_ms)) + .collect() +} + +/// Run guest `fstrim` only after the exact, fresh plan phrase has been approved. +pub fn execute_trim( + runtime: RuntimeStorageKind, + confirmation_phrase: &str, + rationale: &str, +) -> Result { + if rationale.trim().is_empty() + || rationale != rationale.trim() + || rationale.chars().count() > 1_000 + || rationale.chars().any(char::is_control) + { + return Err("runtime-storage-rationale-invalid".into()); + } + let observed_at_ms = now_ms(); + let plan = inspect_runtime(runtime, observed_at_ms); + let expected = plan + .exact_approval_phrase + .as_deref() + .ok_or("runtime-storage-trim-not-ready")?; + if confirmation_phrase != expected { + return Err("runtime-storage-confirmation-mismatch".into()); + } + let args = match runtime { + RuntimeStorageKind::PodmanMachine => [ + "machine", + "ssh", + "podman-machine-default", + "--", + "sudo", + "fstrim", + "-av", + ] + .as_slice(), + RuntimeStorageKind::Colima => ["ssh", "--", "sudo", "fstrim", "-av"].as_slice(), + }; + let home = + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from); + let before = home + .as_deref() + .ok_or_else(|| "runtime-storage-home-unavailable".to_string()) + .and_then(|path| crate::volume_pressure::snapshot_volume(path, now_ms())); + let output = run_bounded_with_timeout(&fixed_binary(runtime), args, RECOVERY_TIMEOUT)?; + let after = home + .as_deref() + .ok_or_else(|| "runtime-storage-home-unavailable".to_string()) + .and_then(|path| crate::volume_pressure::snapshot_volume(path, now_ms())); + let (volume_comparison, volume_evidence_error) = match (before, after) { + (Ok(before), Ok(after)) => { + match crate::volume_pressure::compare_snapshots(&before, &after, None) { + Ok(comparison) => (Some(comparison), None), + Err(error) => (None, Some(error)), + } + } + (Err(error), _) | (_, Err(error)) => (None, Some(error)), + }; + Ok(RuntimeStorageExecution { + schema_kind: "disksage.runtime-storage-execution", + schema_version: SCHEMA_VERSION, + runtime, + command: trim_command(runtime), + status_code: output.0, + stdout: output.1, + stderr: output.2, + output_truncated: output.3, + executed: output.0 == 0, + executed_at_ms: now_ms(), + rationale: rationale.into(), + volume_comparison, + volume_evidence_error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn trim_commands_are_fixed_and_do_not_include_user_input() { + assert_eq!( + trim_command(RuntimeStorageKind::Colima), + vec!["colima", "ssh", "--", "sudo", "fstrim", "-av"] + ); + assert!(trim_command(RuntimeStorageKind::PodmanMachine) + .contains(&"podman-machine-default".into())); + } + + #[test] + fn unavailable_runtime_plan_is_fail_closed() { + let plan = inspect_runtime(RuntimeStorageKind::Colima, 42); + assert!(!plan.host_compaction_supported); + assert!(plan.exact_approval_phrase.is_none() || plan.guest_running == Some(true)); + } + + #[test] + fn colima_status_accepts_legacy_and_current_native_json() { + assert_eq!(colima_running_status(r#"{"status":"Running"}"#), Some(true)); + assert_eq!( + colima_running_status(r#"{"status":"Stopped"}"#), + Some(false) + ); + assert_eq!( + colima_running_status( + r#"{"display_name":"colima","runtime":"docker","driver":"macOS Virtualization.Framework"}"# + ), + Some(true) + ); + assert_eq!(colima_running_status(r#"{"runtime":"docker"}"#), None); + assert_eq!(colima_running_status("not-json"), None); + } + + #[test] + fn reachability_is_bound_into_the_plan_fingerprint() { + assert_ne!( + fingerprint(RuntimeStorageKind::PodmanMachine, Some(true), Some(true)), + fingerprint(RuntimeStorageKind::PodmanMachine, Some(true), Some(false)) + ); + } + + #[test] + fn failed_reachability_probe_remains_incomplete() { + assert_eq!( + reachability_from_probe(Err("runtime-storage-command-timeout".into())), + None + ); + assert_eq!( + reachability_from_probe(Ok((255, String::new(), String::new(), false))), + Some(false) + ); + assert_eq!( + reachability_from_probe(Ok((0, String::new(), String::new(), false))), + Some(true) + ); + } + + #[test] + fn bounded_reader_drains_large_output_without_retaining_it() { + let input = vec![b'x'; MAX_CAPTURE_BYTES + 1]; + let (captured, truncated) = drain_bounded(Cursor::new(input)).expect("reader succeeds"); + assert_eq!(captured.len(), MAX_CAPTURE_BYTES); + assert!(truncated); + } + + #[test] + fn trim_rejects_control_characters_before_runtime_probe() { + assert_eq!( + execute_trim(RuntimeStorageKind::Colima, "", "operator\u{0007}note").unwrap_err(), + "runtime-storage-rationale-invalid" + ); + } +} diff --git a/src-tauri/src/runtime_storage_commands.rs b/src-tauri/src/runtime_storage_commands.rs new file mode 100644 index 000000000..19f11ed71 --- /dev/null +++ b/src-tauri/src/runtime_storage_commands.rs @@ -0,0 +1,11 @@ +#![cfg(not(coverage))] + +use crate::runtime_storage::{self, RuntimeStoragePlan}; + +/// Reads VM-backed runtime storage without occupying Tauri's async worker with bounded subprocesses. +#[tauri::command(async)] +pub async fn inspect_runtime_storage() -> Result, String> { + tauri::async_runtime::spawn_blocking(runtime_storage::inspect) + .await + .map_err(|_| "runtime-storage-inspect-task-failed".to_string()) +} diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index d533e203f..c1aebb12f 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -1,6 +1,92 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// An explicit manual keep boundary inherited by every descendant. +pub const PROTECTED_PATH_MARKER: &str = ".disksage-protected"; +/// Binds a tree to a class IRI in the bundled safety ontology; retained classes veto cleanup. +pub const ONTOLOGY_CLASS_MARKER: &str = ".disksage-ontology-class"; + +fn sidecar(path: &Path, suffix: &str) -> Option { + let mut name = path.file_name()?.to_os_string(); + name.push(suffix); + Some(path.with_file_name(name)) +} + +/// Adds an ontology-backed deletion veto to one existing file or directory. +pub fn bind_retained_ontology_class(path: &Path, class_id: &str) -> Result { + if !path.is_absolute() || class_id.is_empty() || class_id.len() > 2_048 { + return Err("ontology-protection-binding-invalid".into()); + } + if !crate::ontology::bundled_class_requires_retention(class_id) + .map_err(|_| "ontology-protection-class-unknown".to_string())? + { + return Err("ontology-protection-class-not-retained".into()); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "ontology-protection-target-unavailable".to_string())?; + if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { + return Err("ontology-protection-target-unsafe".into()); + } + let marker = if metadata.is_dir() { + path.join(ONTOLOGY_CLASS_MARKER) + } else { + sidecar(path, ONTOLOGY_CLASS_MARKER) + .ok_or_else(|| "ontology-protection-target-unsafe".to_string())? + }; + if marker.exists() { + let existing = std::fs::read_to_string(&marker) + .map_err(|_| "ontology-protection-binding-unreadable".to_string())?; + return (existing.trim() == class_id) + .then_some(marker) + .ok_or_else(|| "ontology-protection-binding-conflict".to_string()); + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + use std::io::Write as _; + let mut file = options + .open(&marker) + .map_err(|_| "ontology-protection-binding-create-failed".to_string())?; + file.write_all(class_id.as_bytes()) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.sync_all()) + .map_err(|_| "ontology-protection-binding-write-failed".to_string())?; + Ok(marker) +} + +pub fn is_explicitly_protected(path: &Path) -> bool { + if sidecar(path, PROTECTED_PATH_MARKER).is_some_and(|marker| marker.is_file()) { + return true; + } + if let Some(binding) = sidecar(path, ONTOLOGY_CLASS_MARKER).filter(|marker| marker.exists()) { + return std::fs::read_to_string(binding) + .map_err(|_| ()) + .and_then(|class_id| { + crate::ontology::bundled_class_requires_retention(class_id.trim()).map_err(|_| ()) + }) + .unwrap_or(true); + } + path.ancestors().any(|ancestor| { + if ancestor.join(PROTECTED_PATH_MARKER).is_file() { + return true; + } + let binding = ancestor.join(ONTOLOGY_CLASS_MARKER); + if !binding.exists() { + return false; + } + std::fs::read_to_string(binding) + .map_err(|_| ()) + .and_then(|class_id| { + crate::ontology::bundled_class_requires_retention(class_id.trim()).map_err(|_| ()) + }) + .unwrap_or(true) + }) +} + #[derive(Debug)] pub enum SafetyError { Protected(PathBuf), @@ -39,6 +125,84 @@ fn is_macos_user_temp_descendant(path: &Path) -> bool { && path.starts_with(temp_root) } +#[cfg(target_os = "macos")] +fn shared_temp_root_path() -> &'static Path { + Path::new("/private/tmp") +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn shared_temp_root_path() -> &'static Path { + Path::new("/tmp") +} + +#[cfg(unix)] +pub(crate) fn is_shared_temp_path(path: &Path) -> bool { + let Ok(root) = std::fs::canonicalize(shared_temp_root_path()) else { + return false; + }; + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() { + return false; + } + let Ok(canonical) = std::fs::canonicalize(path) else { + return false; + }; + canonical != root && canonical.starts_with(root) +} + +#[cfg(not(unix))] +pub(crate) fn is_shared_temp_path(_path: &Path) -> bool { + false +} + +/// Returns true only when every object below a shared temporary child belongs to this user. +/// Symlink roots and unreadable trees fail closed so a shared system directory cannot become a +/// broad deletion authority. Owned symlink children are safe because traversal uses +/// `symlink_metadata` and never descends through them. +#[cfg(unix)] +pub(crate) fn is_user_owned_shared_temp_tree(path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + if !is_shared_temp_path(path) { + return false; + } + const MAX_OWNERSHIP_ENTRIES: usize = 1_000_000; + let expected_uid = unsafe { libc::geteuid() }; + let mut pending = vec![path.to_path_buf()]; + let mut inspected = 0usize; + while let Some(current) = pending.pop() { + inspected = inspected.saturating_add(1); + if inspected > MAX_OWNERSHIP_ENTRIES { + return false; + } + let Ok(metadata) = std::fs::symlink_metadata(¤t) else { + return false; + }; + if metadata.uid() != expected_uid { + return false; + } + if metadata.is_dir() { + let Ok(entries) = std::fs::read_dir(¤t) else { + return false; + }; + for entry in entries { + let Ok(entry) = entry else { + return false; + }; + pending.push(entry.path()); + } + } + } + true +} + +#[cfg(not(unix))] +pub(crate) fn is_user_owned_shared_temp_tree(_path: &Path) -> bool { + false +} + /// 시스템·루트 경로 하드 거부 목록 (스펙 §7-3). /// 안전 계층의 최후 방어선 — 호출자가 무엇을 넘기든 여기서 걸러진다. pub fn is_protected(path: &Path) -> bool { @@ -53,6 +217,9 @@ pub fn is_protected(path: &Path) -> bool { if is_home_root(path, home.as_deref()) { return true; } + if is_explicitly_protected(path) { + return true; + } #[cfg(windows)] { // 컴포넌트 단위 비교: '/'와 '\\' 모두 구분자로 파싱되고(C:/Windows 우회 차단), @@ -90,6 +257,13 @@ pub fn is_protected(path: &Path) -> bool { } #[cfg(unix)] { + if std::fs::canonicalize(shared_temp_root_path()) + .ok() + .zip(std::fs::canonicalize(path).ok()) + .is_some_and(|(root, canonical)| root == canonical) + { + return true; + } // macOS의 사용자별 임시 디렉터리는 /private 아래로 canonicalize된다. 그 하위만 // 허용하되 임시 루트 자체와 그 밖의 /private 트리는 계속 보호한다. 보호 경로를 // 가리키는 심링크는 호출부에서 먼저 canonicalize되므로 이 예외를 우회할 수 없다. @@ -97,14 +271,27 @@ pub fn is_protected(path: &Path) -> bool { if is_macos_user_temp_descendant(path) { return false; } + // Shared system temporary trees stay globally protected. Current-user ownership is a + // purpose-bound deletion authority checked only by the two Trash entry points below; + // it must not widen cloud eviction, clone reclaim, or other callers of this guard. + if is_shared_temp_path(path) { + return true; + } // macOS는 extend로 시스템 경로를 더 넣는다 — 다른 unix에선 그 라인이 cfg-out되어 mut가 // 미사용이므로 allow(unused_mut). Linux 게이트는 macOS 전용 라인을 컴파일하지 않아 커버 불필요. #[allow(unused_mut)] - let mut denied_prefixes: Vec<&str> = - vec!["/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev"]; + let mut denied_prefixes: Vec<&str> = vec![ + "/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev", + ]; #[cfg(target_os = "macos")] denied_prefixes.extend_from_slice(&[ - "/System", "/Library", "/Applications", "/private", "/Volumes", "/cores", "/Network", + "/System", + "/Library", + "/Applications", + "/private", + "/Volumes", + "/cores", + "/Network", ]); let s = path.to_string_lossy(); if denied_prefixes @@ -131,10 +318,6 @@ pub fn object_id_from_metadata(metadata: &std::fs::Metadata) -> Option { } #[cfg(windows)] { - // Windows' `MetadataExt::{volume_serial_number,file_index}` methods are still gated - // behind the unstable `windows_by_handle` feature. Callers that need a Windows identity - // must use `filesystem_object_id`, which keeps the file handle open while deriving the - // same volume/file-index key through the `winapi-util` crate. let _ = metadata; return None; } @@ -148,9 +331,6 @@ pub fn object_id_from_metadata(metadata: &std::fs::Metadata) -> Option { pub fn filesystem_object_id(path: &Path) -> std::io::Result { #[cfg(windows)] { - // `winapi-util` keeps the Windows handle open while querying the stable - // volume/file-index pair. This avoids the unstable `std` metadata accessors and avoids - // reducing the identity to a lossy hash. let handle = winapi_util::Handle::from_path_any(path)?; let info = winapi_util::file::information(&handle)?; return Ok(format!( @@ -181,10 +361,6 @@ pub struct JournalEntry { pub outcome: String, } -/// std::io 오류를 SafetyError::Journal로 감싸는 공용 매퍼. -/// journal_append의 여러 호출부가 동일한 클로저 리터럴을 각자 만들면 그중 실제 I/O 실패로만 -/// 트리거되는 자리(디스크 풀/경합 등)는 단위 테스트로 재현하기 어려워 커버리지 사각이 생긴다. -/// 이름 있는 함수 하나로 모으면 이 함수 자체를 직접 호출해 한 번에 검증할 수 있다. fn journal_io_err(e: std::io::Error) -> SafetyError { SafetyError::Journal(e.to_string()) } @@ -193,7 +369,6 @@ fn journal_serde_err(e: serde_json::Error) -> SafetyError { SafetyError::Journal(e.to_string()) } -/// 파괴적 작업 저널 — 실행 전 "pending"으로 먼저 기록되고 결과로 덧붙는다 (스펙 §7-4) pub fn journal_append(journal_path: &Path, entry: &JournalEntry) -> Result<(), SafetyError> { use std::io::{Read, Seek, SeekFrom, Write}; let line = serde_json::to_string(entry).map_err(journal_serde_err)?; @@ -203,7 +378,6 @@ pub fn journal_append(journal_path: &Path, entry: &JournalEntry) -> Result<(), S .append(true) .open(journal_path) .map_err(journal_io_err)?; - // 크래시로 개행 없이 끊긴 꼬리가 있으면 개행을 먼저 넣어 다음 엔트리와의 병합을 막는다 (자가 치유) let mut healing = String::new(); let len = f.seek(SeekFrom::End(0)).map_err(journal_io_err)?; if len > 0 { @@ -214,13 +388,14 @@ pub fn journal_append(journal_path: &Path, entry: &JournalEntry) -> Result<(), S healing.push('\n'); } } - // 본문+개행을 한 번의 write로 — 두 syscall 사이 크래시로 인한 torn line 방지 f.write_all(format!("{healing}{line}\n").as_bytes()) .map_err(journal_io_err) } pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { - let Ok(content) = std::fs::read_to_string(journal_path) else { return Vec::new() }; + let Ok(content) = std::fs::read_to_string(journal_path) else { + return Vec::new(); + }; let mut entries: Vec = content .lines() .filter_map(|l| serde_json::from_str(l).ok()) @@ -230,13 +405,13 @@ pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { entries } -/// Windows verbatim 접두(\\?\C:\, \\?\UNC\srv\share)를 일반 형태로 재구성한다. -/// 문자열 수술이 아니라 파싱된 Prefix 컴포넌트 기반 — UNC가 상대경로로 망가지지 않는다. #[cfg(windows)] fn strip_verbatim(p: &Path) -> PathBuf { use std::path::{Component, Prefix}; let mut comps = p.components(); - let Some(Component::Prefix(pr)) = comps.next() else { return p.to_path_buf() }; + let Some(Component::Prefix(pr)) = comps.next() else { + return p.to_path_buf(); + }; match pr.kind() { Prefix::VerbatimDisk(d) => { let mut out = PathBuf::from(format!("{}:\\", d as char)); @@ -259,21 +434,15 @@ fn strip_verbatim(p: &Path) -> PathBuf { p.to_path_buf() } -/// 존재하지 않을 수 있는 경로의 보호 여부 판정용 정규화: 가장 가까운 실존 조상을 canonicalize하고 -/// 나머지 미존재 접미부를 붙인다 — dst의 조상이 심링크로 보호 위치를 가리켜도 is_protected가 놓치지 않게. fn normalize_for_guard(p: &Path) -> PathBuf { - // 이미 존재하면 그대로 canonicalize if let Ok(c) = std::fs::canonicalize(p) { return strip_verbatim(&c); } - // 존재하지 않으면: 실존하는 가장 가까운 조상을 찾아 canonicalize + 나머지 접미부 let mut suffix: Vec = Vec::new(); let mut cur = p; loop { match cur.parent() { Some(parent) => { - // parent가 있으면 cur은 루트가 아니므로 file_name은 항상 Some — 그래도 - // extend(Option)로 분기 없이 처리해 도달 불가 else가 커버리지 사각을 만들지 않게 suffix.extend(cur.file_name().map(|n| n.to_os_string())); if let Ok(c) = std::fs::canonicalize(parent) { let mut base = strip_verbatim(&c); @@ -284,26 +453,31 @@ fn normalize_for_guard(p: &Path) -> PathBuf { } cur = parent; } - None => return strip_verbatim(p), // 조상이 하나도 실존하지 않음(드묾) — lexical + None => return strip_verbatim(p), } } } -/// 앱 유일의 삭제 경로 (스펙 §7-1). 영구 삭제 API는 이 크레이트 어디에도 없다. pub fn trash_delete( path: &Path, bytes: u64, journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { - // '..'는 lexical 가드를 우회해 보호 경로 밖으로 보이게 할 수 있음 — 컴포넌트 단위로 먼저 거부 - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(SafetyError::Protected(path.to_path_buf())); + } + let guard_path = + strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + let shared_temp = is_shared_temp_path(&guard_path); + let shared_temp_authorized = shared_temp && is_user_owned_shared_temp_tree(&guard_path); + if shared_temp && !shared_temp_authorized { return Err(SafetyError::Protected(path.to_path_buf())); } - // 가드는 정규화된 경로로 판정. canonicalize 실패(예: 이미 사라진 경로)면 - // lexical 경로로 판정한다 (ParentDir는 위에서 이미 거부됨) — 어느 쪽이든 verbatim은 재구성. - let guard_path = strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); - if is_protected(&guard_path) { + if !shared_temp_authorized && is_protected(&guard_path) { return Err(SafetyError::Protected(path.to_path_buf())); } let mut entry = JournalEntry { @@ -314,8 +488,7 @@ pub fn trash_delete( outcome: "pending".into(), }; journal_append(journal_path, &entry)?; - // fsync 없음(의식적 선택): 삭제는 휴지통 경유라 전원 단절로 pending 기록을 잃어도 복구 가능 - match trash::delete(path) { + match platform_trash_delete(path) { Ok(()) => { entry.outcome = "ok".into(); journal_append(journal_path, &entry)?; @@ -329,29 +502,34 @@ pub fn trash_delete( } } +#[cfg(target_os = "macos")] +fn platform_trash_delete(path: &Path) -> Result<(), trash::Error> { + use trash::macos::{DeleteMethod, TrashContextExtMacos}; + let mut context = trash::TrashContext::new(); + context.set_delete_method(DeleteMethod::NsFileManager); + context.delete(path) +} + +#[cfg(not(target_os = "macos"))] +fn platform_trash_delete(path: &Path) -> Result<(), trash::Error> { + trash::delete(path) +} + static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); fn create_private_staging_dir(path: &Path, now_ms: u64) -> std::io::Result { - let parent = path - .parent() - .unwrap_or_else(|| Path::new(".")); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); let parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); let pid = std::process::id(); for _ in 0..32 { let serial = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); - let candidate = parent.join(format!( - ".disksage-trash-{}-{}-{}", - pid, now_ms, serial - )); + let candidate = parent.join(format!(".disksage-trash-{}-{}-{}", pid, now_ms, serial)); match std::fs::create_dir(&candidate) { Ok(()) => { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - &candidate, - std::fs::Permissions::from_mode(0o700), - )?; + std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o700))?; } return Ok(candidate); } @@ -380,12 +558,8 @@ fn restore_staged_if_source_absent( staged.display() )); } - std::fs::rename(staged, path).map_err(|error| { - format!( - "staged restore failed for {}: {error}", - staged.display() - ) - })?; + std::fs::rename(staged, path) + .map_err(|error| format!("staged restore failed for {}: {error}", staged.display()))?; std::fs::remove_dir(staging_dir).map_err(|error| { format!( "staging directory cleanup failed for {}: {error}", @@ -395,11 +569,24 @@ fn restore_staged_if_source_absent( Ok(()) } -/// Move the exact reviewed filesystem object into a private sibling staging directory before -/// handing it to the OS trash. The initial identity check prevents a stale path from being used; -/// the atomic rename plus a second identity check prevents a replacement that wins the race from -/// being trashed. If either check fails, the object is restored when the original path is free; -/// it is never silently deleted under a different identity. +fn remove_staged_permanently_with( + staged: &Path, + staging_dir: &Path, + remove: F, +) -> Result<(), SafetyError> +where + F: FnOnce(&Path) -> std::io::Result<()>, +{ + if let Err(error) = remove(staged) { + return Err(SafetyError::Trash(format!( + "permanent deletion failed; staged object retained at {}: {error}", + staged.display() + ))); + } + let _ = std::fs::remove_dir(staging_dir); + Ok(()) +} + pub fn trash_delete_if_identity( path: &Path, expected_object_id: &str, @@ -407,13 +594,63 @@ pub fn trash_delete_if_identity( journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + trash_delete_if_identity_with_catalog_root( + path, + None, + expected_object_id, + bytes, + journal_path, + now_ms, + ) +} + +pub(crate) fn trash_delete_if_identity_in_catalog_root( + path: &Path, + catalog_root: &Path, + expected_object_id: &str, + bytes: u64, + journal_path: &Path, + now_ms: u64, +) -> Result<(), SafetyError> { + trash_delete_if_identity_with_catalog_root( + path, + Some(catalog_root), + expected_object_id, + bytes, + journal_path, + now_ms, + ) +} + +fn trash_delete_if_identity_with_catalog_root( + path: &Path, + catalog_root: Option<&Path>, + expected_object_id: &str, + bytes: u64, + journal_path: &Path, + now_ms: u64, +) -> Result<(), SafetyError> { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(SafetyError::Protected(path.to_path_buf())); } - let guard_path = strip_verbatim( - &std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), - ); - if is_protected(&guard_path) { + let guard_path = + strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + let catalog_authorized = catalog_root.is_some_and(|root| { + std::fs::symlink_metadata(root).is_ok_and(|metadata| { + metadata.is_dir() && !metadata.file_type().is_symlink() + }) && std::fs::canonicalize(root) + .is_ok_and(|root| guard_path.parent() == Some(root.as_path())) + && !is_explicitly_protected(&guard_path) + }); + let shared_temp = is_shared_temp_path(&guard_path); + let shared_temp_authorized = shared_temp && is_user_owned_shared_temp_tree(&guard_path); + if shared_temp && !shared_temp_authorized { + return Err(SafetyError::Protected(path.to_path_buf())); + } + if !shared_temp_authorized && !catalog_authorized && is_protected(&guard_path) { return Err(SafetyError::Protected(path.to_path_buf())); } let actual = filesystem_object_id(path) @@ -451,7 +688,9 @@ pub fn trash_delete_if_identity( let moved_id = filesystem_object_id(&staged).map_err(|error| { let restore = restore_staged_if_source_absent(path, &staged, &staging_dir); match restore { - Ok(()) => SafetyError::Trash(format!("staged object identity unavailable: {error}")), + Ok(()) => { + SafetyError::Trash(format!("staged object identity unavailable: {error}")) + } Err(restore_error) => SafetyError::Trash(format!( "staged object identity unavailable: {error}; {restore_error}" )), @@ -467,20 +706,110 @@ pub fn trash_delete_if_identity( ))), }; } - if let Err(error) = trash::delete(&staged) { + if let Err(error) = platform_trash_delete(&staged) { return match restore_staged_if_source_absent(path, &staged, &staging_dir) { Ok(()) => Err(SafetyError::Trash(error.to_string())), + Err(restore_error) => { + Err(SafetyError::Trash(format!("{}; {restore_error}", error))) + } + }; + } + Ok(()) + })(); + entry.outcome = match &result { + Ok(()) => "ok".into(), + Err(error) => format!("error:{error}"), + }; + journal_append(journal_path, &entry)?; + result +} + +/// Permanently remove one unchanged, current-user-owned generated directory. +/// +/// Callers must perform their domain-specific regenerability and active-use checks first. This +/// boundary rechecks path safety and filesystem identity, journals both intent and outcome, and +/// never follows a symbolic-link root. +pub fn permanent_delete_dir_if_identity( + path: &Path, + expected_object_id: &str, + bytes: u64, + journal_path: &Path, + now_ms: u64, +) -> Result<(), SafetyError> { + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(SafetyError::Protected(path.to_path_buf())); + } + let guard_path = + strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + let shared_temp_authorized = + is_shared_temp_path(&guard_path) && is_user_owned_shared_temp_tree(&guard_path); + if !shared_temp_authorized && is_protected(&guard_path) { + return Err(SafetyError::Protected(path.to_path_buf())); + } + let metadata = + std::fs::symlink_metadata(path).map_err(|error| SafetyError::Trash(error.to_string()))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(SafetyError::Trash( + "permanent deletion requires a real generated directory".into(), + )); + } + let actual = filesystem_object_id(path) + .map_err(|error| SafetyError::Trash(format!("object identity unavailable: {error}")))?; + if actual != expected_object_id { + return Err(SafetyError::Trash( + "generated directory identity changed; rescan before deletion".into(), + )); + } + let file_name = path.file_name().ok_or_else(|| { + SafetyError::Trash("generated directory has no file name; rescan before deletion".into()) + })?; + let staging_dir = create_private_staging_dir(path, now_ms) + .map_err(|error| SafetyError::Trash(error.to_string()))?; + let staged = staging_dir.join(file_name); + let mut entry = JournalEntry { + ts_ms: now_ms, + op: "permanent_generated_directory_delete".into(), + path: path.to_string_lossy().into_owned(), + bytes, + outcome: "pending".into(), + }; + if let Err(error) = journal_append(journal_path, &entry) { + let _ = std::fs::remove_dir(&staging_dir); + return Err(error); + } + let result = (|| -> Result<(), SafetyError> { + if let Err(error) = std::fs::rename(path, &staged) { + let _ = std::fs::remove_dir(&staging_dir); + return Err(SafetyError::Trash(format!( + "atomic staging move failed: {error}" + ))); + } + let moved_id = filesystem_object_id(&staged).map_err(|error| { + let restore = restore_staged_if_source_absent(path, &staged, &staging_dir); + match restore { + Ok(()) => SafetyError::Trash(format!( + "staged generated directory identity unavailable: {error}" + )), + Err(restore_error) => SafetyError::Trash(format!( + "staged generated directory identity unavailable: {error}; {restore_error}" + )), + } + })?; + if moved_id != expected_object_id { + return match restore_staged_if_source_absent(path, &staged, &staging_dir) { + Ok(()) => Err(SafetyError::Trash( + "atomic staging move changed the generated directory; nothing was deleted" + .into(), + )), Err(restore_error) => Err(SafetyError::Trash(format!( - "{}; {restore_error}", - error + "atomic staging move changed the generated directory; {restore_error}" ))), }; } - // Keep the empty identity-staging directory after a successful OS-trash move. The trash - // provider records the staged pathname as the undo target; retaining its parent preserves - // that recovery path. A later recovery pass may remove empty staging directories only - // after the corresponding trash item is no longer undoable. - Ok(()) + remove_staged_permanently_with(&staged, &staging_dir, |path| std::fs::remove_dir_all(path)) })(); entry.outcome = match &result { Ok(()) => "ok".into(), @@ -490,18 +819,18 @@ pub fn trash_delete_if_identity( result } -/// 두 경로가 같은 볼륨인지 — rename 가능 판정(순수). 목적지는 아직 없을 수 있어 부모로 판정. pub fn same_volume(src: &Path, dst: &Path) -> bool { let dst_probe = dst.parent().unwrap_or(dst); #[cfg(windows)] { fn drive(p: &Path) -> Option { p.components().next().and_then(|c| match c { - std::path::Component::Prefix(pr) => Some(pr.as_os_str().to_string_lossy().to_lowercase()), + std::path::Component::Prefix(pr) => { + Some(pr.as_os_str().to_string_lossy().to_lowercase()) + } _ => None, }) } - // canonicalize로 상대경로/verbatim 정규화 후 드라이브 비교(best-effort) let s = std::fs::canonicalize(src).unwrap_or_else(|_| src.to_path_buf()); let d = std::fs::canonicalize(dst_probe).unwrap_or_else(|_| dst_probe.to_path_buf()); drive(&s) == drive(&d) @@ -515,21 +844,17 @@ pub fn same_volume(src: &Path, dst: &Path) -> bool { } } -/// 크로스 볼륨 복사 — io 에러는 `?`로 전파(커버리지 규율: happy path에서 map_err 클로저가 -/// 미실행 라인으로 남지 않도록). 목적지는 create_new로 열어 "존재 확인 → 복사" 사이의 TOCTOU -/// 경합에서도 그 사이 생긴 파일을 덮어쓰지 않는다(경합 시 AlreadyExists로 실패). -/// 해시는 Result 그대로 반환 — 실패를 빈 문자열로 뭉개면 "둘 다 실패 → 둘 다 빈 문자열 → 일치"라는 -/// 거짓 검증 통과가 생긴다(blake3 해시는 절대 비지 않으므로 실패는 반드시 실패로 남아야 함). -/// 검증은 별도 순수 함수로 분리해 실패 arm을 직접 단위 테스트한다. fn copy_then_hash( src: &Path, dst: &Path, ) -> std::io::Result<(u64, u64, Result, Result)> { { let mut src_file = std::fs::File::open(src)?; - let mut dst_file = std::fs::OpenOptions::new().write(true).create_new(true).open(dst)?; + let mut dst_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(dst)?; std::io::copy(&mut src_file, &mut dst_file)?; - // 핸들을 여기서 닫아 이후 metadata/hash_full이 경로로 다시 읽을 때 걸리지 않게 함 } let src_len = std::fs::metadata(src)?.len(); let dst_len = std::fs::metadata(dst)?.len(); @@ -538,8 +863,6 @@ fn copy_then_hash( Ok((src_len, dst_len, src_hash, dst_hash)) } -/// 순수 검증 판정 — 크기 일치 + 양쪽 해시가 모두 성공했고 서로 같을 때만 true. -/// 해시 중 하나라도 Err면 무조건 false(fail-closed) — "계산 실패"를 "일치"로 오인하지 않는다. fn hashes_match( src_hash: &Result, dst_hash: &Result, @@ -549,51 +872,36 @@ fn hashes_match( matches!((src_hash, dst_hash), (Ok(s), Ok(d)) if src_len == dst_len && s == d) } -/// 검증 결과에 따라 목적지를 정리하거나 성공 반환. 검증-실패 정리 arm은 복사 성공 후 해시 -/// 불일치라는 정직하게 재현 불가한 상황에서만 도달하므로, 판정을 파라미터로 받아 양 arm을 -/// 직접 단위 테스트한다(원본은 어느 쪽이든 건드리지 않는다). fn finalize_verified_copy(dst: &Path, verified: bool) -> std::io::Result<()> { if verified { Ok(()) } else { - let _ = std::fs::remove_file(dst); // 우리가 만든 목적지이므로 정리 - Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "복사 검증 실패")) + let _ = std::fs::remove_file(dst); + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "복사 검증 실패", + )) } } -/// 검증된 크로스 볼륨 목적지에 원본의 메타데이터(권한 + mtime/atime)를 복원한다. -/// `io::copy`는 바이트만 옮겨 목적지의 mtime을 now로, mode를 umask 기본값(보통 0644)으로 -/// 리셋한다. 이는 앱이 자체 판단에 쓰는 mtime 신호(organize의 age_days, dev_artifacts의 -/// min_age_days, LLM 삭제-안전 프롬프트의 age)를 손상시키고, 실행 비트나 0600 같은 보안-민감 -/// 권한까지 되돌린다. 같은 볼륨 경로(hard_link)는 같은 inode라 메타데이터가 그대로 보존되므로, -/// 두 경로를 메타데이터 측면에서 일치시키기 위한 복원이다. -/// std만 사용한다(`File::set_times`는 1.75+ 안정) — 새 의존성도, unsafe FFI도 없다. -/// 실패 시 io::Error를 전파해 호출측이 목적지를 정리하고 원본을 손상 없이 남기게 한다(fail-closed). fn preserve_source_metadata(src: &Path, dst: &Path) -> std::io::Result<()> { let src_md = std::fs::metadata(src)?; - // mtime(+가능하면 atime)을 **먼저** 복원한다. 목적지는 복사 직후 쓰기 가능(0644)이라 write - // 핸들을 얻을 수 있다. 권한을 먼저 복원하면 원본이 읽기 전용(예: 0400)일 때 목적지도 0400이 - // 되어 set_times용 write 오픈이 실패하므로, 순서를 뒤집으면 읽기 전용 파일의 크로스 볼륨 - // 이동 자체가 막힌다. atime은 noatime 마운트 등에서 못 읽을 수 있어 있을 때만 함께 설정한다. let mut times = std::fs::FileTimes::new().set_modified(src_md.modified()?); if let Ok(accessed) = src_md.accessed() { times = times.set_accessed(accessed); } - std::fs::OpenOptions::new().write(true).open(dst)?.set_times(times)?; - // 권한은 **마지막**에 복원한다(원본이 읽기 전용이어도 위 set_times가 이미 끝난 뒤라 안전). - // set_permissions는 mtime이 아니라 ctime만 바꾸므로 방금 설정한 mtime을 훼손하지 않는다. + std::fs::OpenOptions::new() + .write(true) + .open(dst)? + .set_times(times)?; std::fs::set_permissions(dst, src_md.permissions())?; Ok(()) } -// 크로스 볼륨 복사+검증(내부 io, ? 전파). 복사 도중 실패하든 검증에서 실패하든, 우리가 만든 -// 목적지라면 정리하고 io::Error — 어느 실패든 원본은 절대 건드리지 않는다. fn copy_verified_io(src: &Path, dst: &Path) -> std::io::Result<()> { let (src_len, dst_len, src_hash, dst_hash) = match copy_then_hash(src, dst) { Ok(v) => v, Err(e) => { - // create_new가 AlreadyExists로 실패했다면 dst는 우리가 만든 게 아니다(TOCTOU 경합 - // 상대가 먼저 만든 파일) — 지우면 안 된다. 그 외 실패는 우리가 만든 부분 목적지이므로 정리. if e.kind() != std::io::ErrorKind::AlreadyExists { let _ = std::fs::remove_file(dst); } @@ -601,9 +909,6 @@ fn copy_verified_io(src: &Path, dst: &Path) -> std::io::Result<()> { } }; finalize_verified_copy(dst, hashes_match(&src_hash, &dst_hash, src_len, dst_len))?; - // 내용 검증 성공 후(원본은 아직 존재) 원본 메타데이터를 목적지에 복원한 뒤에야 호출측이 - // 원본을 휴지통으로 보낸다. 복원 실패는 fail-closed: 우리가 만든 목적지를 정리하고 에러를 - // 전파해, 원본이 메타데이터가 손상된 사본으로 대체되는 일을 막는다. if let Err(e) = preserve_source_metadata(src, dst) { let _ = std::fs::remove_file(dst); return Err(e); @@ -611,17 +916,12 @@ fn copy_verified_io(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -/// 분기 결정(same_vol)을 파라미터로 받아 양 경로를 플랫폼 무관하게 테스트 가능하게 한다. -/// 같은 볼륨 이동 io — hard_link(create-only) 후 원본 링크 제거. 두 io 에러 모두 `?`로 -/// 전파(커버리지 규율: happy path에서 map_err 클로저가 미실행 라인으로 남지 않도록). -/// dst가 이미 있으면 hard_link가 AlreadyExists로 실패해 덮어쓰지 않는다. fn hardlink_move_io(src: &Path, dst: &Path) -> std::io::Result<()> { std::fs::hard_link(src, dst)?; std::fs::remove_file(src)?; Ok(()) } -/// move_file이 same_volume()로 실제 결정을 주입한다. fn do_move( src: &Path, dst: &Path, @@ -639,15 +939,8 @@ fn do_move( journal_append(journal_path, &entry)?; let result = if same_vol { - // rename은 dst를 원자적으로 덮어쓴다(REPLACE) → dst.exists() 체크 이후 경합으로 생긴 - // 파일이 휴지통도 안 거치고 영구 소실될 수 있다. hard_link는 create-only라 dst가 이미 - // 있으면 AlreadyExists로 실패(덮어쓰지 않음) — 링크 성공 후 원본 링크만 제거한다. - // 두 단계 사이 크래시 시엔 양쪽이 같은 inode를 가리키는 무해한 중복이 남는다(손실 아님). - // io는 헬퍼가 `?`로 전파 → happy path에서 map_err 클로저가 미실행 라인으로 남지 않는다. - // 단일 경계 map_err은 hard_link 실패 테스트(dest-exists)가 커버한다. hardlink_move_io(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) } else { - // 크로스 볼륨: 복사+검증 후 원본 휴지통 (영구 삭제 없음) copy_verified_io(src, dst) .map_err(|e| SafetyError::Trash(e.to_string())) .and_then(|()| { @@ -664,16 +957,16 @@ fn do_move( result } -/// 앱 유일의 이동 경로 (스펙 §7-2). 영구 삭제 없음 — 원본 제거는 trash_delete 경유. pub fn move_file( src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { - // 보호: src·dst 양쪽, ParentDir 거부, verbatim 정규화 — trash_delete와 동일 리거 for p in [src, dst] { - if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(SafetyError::Protected(p.to_path_buf())); } let guard = normalize_for_guard(p); @@ -681,16 +974,14 @@ pub fn move_file( return Err(SafetyError::Protected(p.to_path_buf())); } } - // 목적지 충돌 금지 (덮어쓰기 방지) if dst.exists() { - return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); + return Err(SafetyError::Trash(format!( + "목적지가 이미 존재: {}", + dst.display() + ))); } - // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 - // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 - // unwrap_or로 두어 라인 커버리지를 유지하면서 방어한다(도달 시 create_dir_all이 에러로 귀결). let dst_parent = dst.parent().unwrap_or(dst); std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; - do_move(src, dst, same_volume(src, dst), journal_path, now_ms) } @@ -723,7 +1014,6 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn protects_macos_system_paths() { - // macOS 전용 시스템 경로 — extend_from_slice 라인을 macOS서 커버. for p in [ "/System", "/System/Library/CoreServices", @@ -740,21 +1030,29 @@ mod tests { #[test] fn safety_error_display_messages() { - assert!(SafetyError::Protected(PathBuf::from("/x")).to_string().contains("보호")); - assert!(SafetyError::Trash("boom".into()).to_string().contains("휴지통")); - assert!(SafetyError::Journal("boom".into()).to_string().contains("저널")); + assert!(SafetyError::Protected(PathBuf::from("/x")) + .to_string() + .contains("보호")); + assert!(SafetyError::Trash("boom".into()) + .to_string() + .contains("휴지통")); + assert!(SafetyError::Journal("boom".into()) + .to_string() + .contains("저널")); } #[test] fn is_home_root_false_when_env_absent() { - // 실제 환경변수를 건드리지 않고 HOME/USERPROFILE 부재 케이스를 검증 assert!(!is_home_root(Path::new("/whatever"), None)); } #[test] fn protects_home_root_but_not_home_children() { - // 한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 영구 미커버로 남는다 - let home = if cfg!(windows) { std::env::var("USERPROFILE").unwrap() } else { std::env::var("HOME").unwrap() }; + let home = if cfg!(windows) { + std::env::var("USERPROFILE").unwrap() + } else { + std::env::var("HOME").unwrap() + }; assert!(is_protected(Path::new(&home))); assert!(!is_protected(&Path::new(&home).join("some-cache-dir"))); } @@ -765,13 +1063,90 @@ mod tests { assert!(!is_protected(&tmp.path().join("node_modules"))); } + #[test] + fn explicit_marker_protects_its_directory_and_descendants_only() { + let tmp = tempfile::tempdir().unwrap(); + let protected = tmp.path().join("crm"); + let sibling = tmp.path().join("cache"); + std::fs::create_dir_all(protected.join("exports")).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(protected.join(PROTECTED_PATH_MARKER), []).unwrap(); + + assert!(is_protected(&protected)); + assert!(is_protected(&protected.join("exports/customer.db"))); + assert!(!is_protected(&sibling)); + } + + #[test] + fn ontology_retention_binding_is_an_inherited_delete_veto() { + let tmp = tempfile::tempdir().unwrap(); + let business = tmp.path().join("business-data"); + std::fs::create_dir_all(&business).unwrap(); + std::fs::write( + business.join(ONTOLOGY_CLASS_MARKER), + "https://disksage.app/ontology#CustomerRelationshipManagementData\n", + ) + .unwrap(); + + assert!(is_explicitly_protected(&business.join("customer.db"))); + assert!(is_protected(&business.join("customer.db"))); + } + + #[test] + fn ontology_sidecar_protects_only_its_bound_file() { + let tmp = tempfile::tempdir().unwrap(); + let export = tmp.path().join("crm-export.sql"); + let unrelated = tmp.path().join("cache.bin"); + std::fs::write(&export, b"crm").unwrap(); + std::fs::write(&unrelated, b"cache").unwrap(); + std::fs::write( + sidecar(&export, ONTOLOGY_CLASS_MARKER).unwrap(), + "https://disksage.app/ontology#CustomerRelationshipManagementData\n", + ) + .unwrap(); + + assert!(is_protected(&export)); + assert!(!is_protected(&unrelated)); + } + + #[cfg(unix)] + #[test] + fn current_user_owned_shared_temp_child_stays_globally_protected() { + let Ok(tmp) = tempfile::tempdir_in(shared_temp_root_path()) else { + return; + }; + let child = tmp.path().join("owned.bin"); + std::fs::write(&child, b"owned").unwrap(); + assert!(is_shared_temp_path(&child)); + assert!(is_user_owned_shared_temp_tree(&child)); + assert!(is_protected(&child)); + assert!(is_protected(shared_temp_root_path())); + } + + #[cfg(unix)] + #[test] + fn owned_symlink_child_does_not_block_exact_shared_temp_tree_authority() { + use std::os::unix::fs::symlink; + + let Ok(tmp) = tempfile::tempdir_in(shared_temp_root_path()) else { + return; + }; + let target = tmp.path().join("target.bin"); + let link = tmp.path().join("runtime-link"); + std::fs::write(&target, b"owned").unwrap(); + symlink(&target, &link).unwrap(); + + assert!(is_user_owned_shared_temp_tree(tmp.path())); + } + #[cfg(windows)] #[test] fn windows_guard_follows_system_root_env() { - // 현재 머신의 실제 SystemRoot는 반드시 보호됨 (C:든 다른 드라이브든) let sysroot = std::env::var("SystemRoot").unwrap(); assert!(is_protected(std::path::Path::new(&sysroot))); - assert!(is_protected(&std::path::Path::new(&sysroot).join("System32"))); + assert!(is_protected( + &std::path::Path::new(&sysroot).join("System32") + )); } #[cfg(windows)] @@ -781,7 +1156,7 @@ mod tests { assert!(is_protected(Path::new("c:/program files/SomeApp"))); assert!(is_protected(Path::new("C:\\Program Files (x86)\\App"))); assert!(!is_protected(Path::new("C:\\WindowsBackup"))); - assert!(!is_protected(Path::new("C:\\Windows.old"))); // 정당한 정리 대상 + assert!(!is_protected(Path::new("C:\\Windows.old"))); } #[test] @@ -803,7 +1178,7 @@ mod tests { } let recent = journal_recent(&jp, 2); assert_eq!(recent.len(), 2); - assert_eq!(recent[0].path, "/x/2"); // 최신이 먼저 + assert_eq!(recent[0].path, "/x/2"); assert_eq!(recent[1].path, "/x/1"); } @@ -816,7 +1191,6 @@ mod tests { #[test] fn journal_append_reports_io_error() { let tmp = tempfile::tempdir().unwrap(); - // 디렉토리를 저널 경로로 주면 열기 실패 let err = journal_append( tmp.path(), &JournalEntry { @@ -849,7 +1223,7 @@ mod tests { let root = if cfg!(windows) { "C:\\Windows" } else { "/usr" }; let err = trash_delete(Path::new(root), 0, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); - assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전에 일어나야 함"); + assert!(journal_recent(&jp, 10).is_empty()); } #[test] @@ -875,12 +1249,80 @@ mod tests { std::fs::rename(&victim, &original).unwrap(); std::fs::create_dir(&replacement).unwrap(); std::fs::rename(&replacement, &victim).unwrap(); - let err = trash_delete_if_identity(&victim, &expected, 0, &jp, 1); assert!(err.is_err()); - assert!(victim.exists(), "대체 객체는 삭제되지 않아야 함"); - assert!(original.exists(), "검토된 원래 객체도 보존되어야 함"); - assert!(journal_recent(&jp, 10).is_empty(), "stale identity는 저널/휴지통 전에 거부"); + assert!(victim.exists()); + assert!(original.exists()); + assert!(journal_recent(&jp, 10).is_empty()); + } + + #[test] + fn catalog_root_authority_never_overrides_an_explicit_protection_marker() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("catalog"); + let victim = root.join("regenerable-cache"); + std::fs::create_dir_all(&victim).unwrap(); + std::fs::write(root.join(PROTECTED_PATH_MARKER), []).unwrap(); + let expected = filesystem_object_id(&victim).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + + let error = trash_delete_if_identity_in_catalog_root( + &victim, &root, &expected, 0, &journal, 1, + ); + + assert!(matches!(error, Err(SafetyError::Protected(_)))); + assert!(victim.exists()); + assert!(journal_recent(&journal, 10).is_empty()); + } + + #[test] + fn permanent_generated_directory_delete_rechecks_identity_and_journals() { + let tmp = tempfile::tempdir().unwrap(); + let generated = tmp.path().join("node_modules"); + std::fs::create_dir(&generated).unwrap(); + std::fs::write(generated.join("generated.bin"), b"generated").unwrap(); + let object_id = filesystem_object_id(&generated).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + + permanent_delete_dir_if_identity(&generated, &object_id, 9, &journal, 1).unwrap(); + + assert!(!generated.exists()); + let entries = journal_recent(&journal, 2); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].op, "permanent_generated_directory_delete"); + assert_eq!(entries[0].outcome, "ok"); + assert_eq!(entries[1].outcome, "pending"); + assert!(std::fs::read_dir(tmp.path()).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".disksage-trash-"))); + } + + #[test] + fn permanent_delete_does_not_restore_a_partially_removed_staged_tree() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("node_modules"); + let staging_dir = tmp.path().join(".disksage-trash"); + let staged = staging_dir.join("node_modules"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("removed.bin"), b"removed").unwrap(); + std::fs::write(source.join("retained.bin"), b"retained").unwrap(); + std::fs::create_dir(&staging_dir).unwrap(); + std::fs::rename(&source, &staged).unwrap(); + + let result = remove_staged_permanently_with(&staged, &staging_dir, |path| { + std::fs::remove_file(path.join("removed.bin")).unwrap(); + Err(std::io::Error::other("simulated recursive delete failure")) + }); + + assert!(result.is_err()); + assert!( + !source.exists(), + "a partial tree must not be restored as live" + ); + assert!(!staged.join("removed.bin").exists()); + assert!(staged.join("retained.bin").exists()); } #[test] @@ -892,7 +1334,6 @@ mod tests { std::fs::write(&source, b"replacement").unwrap(); std::fs::create_dir(&staging_dir).unwrap(); std::fs::write(&staged, b"reviewed").unwrap(); - let error = restore_staged_if_source_absent(&source, &staged, &staging_dir).unwrap_err(); assert!(error.contains(staged.to_string_lossy().as_ref())); assert!(source.exists()); @@ -906,7 +1347,6 @@ mod tests { let staging_dir = tmp.path().join(".disksage-trash-staging"); let staged = staging_dir.join("source"); std::fs::create_dir(&staging_dir).unwrap(); - let error = restore_staged_if_source_absent(&source, &staged, &staging_dir).unwrap_err(); assert!(error.contains(staged.to_string_lossy().as_ref())); assert!(staging_dir.exists()); @@ -920,12 +1360,11 @@ mod tests { let err = trash_delete(&missing, 0, &jp, 1); assert!(matches!(err, Err(SafetyError::Trash(_)))); let recent = journal_recent(&jp, 10); - assert_eq!(recent.len(), 2); // pending + error + assert_eq!(recent.len(), 2); assert!(recent[0].outcome.starts_with("error:")); assert_eq!(recent[1].outcome, "pending"); } - // 실제 휴지통 왕복 (스펙 §9 통합 테스트 1). trash::os_limited는 win/linux 전용. #[cfg(any(windows, target_os = "linux"))] #[test] fn trash_delete_roundtrip_lands_in_trash() { @@ -933,21 +1372,21 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let victim = tmp.path().join("disksage-roundtrip-fixture.bin"); std::fs::write(&victim, vec![0u8; 64]).unwrap(); - trash_delete(&victim, 64, &jp, 42).unwrap(); - - assert!(!victim.exists(), "원본은 사라져야 함"); + assert!(!victim.exists()); let recent = journal_recent(&jp, 10); assert_eq!(recent[0].outcome, "ok"); assert_eq!(recent[0].ts_ms, 42); - - // 휴지통에서 확인 후 테스트 픽스처만 purge (제품 코드가 아닌 테스트 정리) let items: Vec<_> = trash::os_limited::list() .unwrap() .into_iter() - .filter(|i| i.name.to_string_lossy().contains("disksage-roundtrip-fixture")) + .filter(|i| { + i.name + .to_string_lossy() + .contains("disksage-roundtrip-fixture") + }) .collect(); - assert!(!items.is_empty(), "휴지통에 있어야 함"); + assert!(!items.is_empty()); trash::os_limited::purge_all(items).unwrap(); } @@ -966,7 +1405,6 @@ mod tests { fn trash_delete_rejects_verbatim_protected_path() { let tmp = tempfile::tempdir().unwrap(); let jp = tmp.path().join("j.jsonl"); - // 실존하는 보호 경로의 verbatim 형태 — canonicalize가 verbatim을 돌려줘도 가드가 잡아야 함 let err = trash_delete(Path::new(r"\\?\C:\Windows\System32"), 0, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); assert!(journal_recent(&jp, 10).is_empty()); @@ -983,17 +1421,23 @@ mod tests { strip_verbatim(Path::new(r"\\?\UNC\srv\share\dir")), Path::new(r"\\srv\share\dir") ); - assert_eq!(strip_verbatim(Path::new(r"C:\plain")), Path::new(r"C:\plain")); - assert_eq!(strip_verbatim(Path::new("relative/only")), Path::new("relative/only")); - // 재구성된 UNC 공유 루트는 parent가 없어 보호된다 (fail-closed 확인) - assert!(is_protected(&strip_verbatim(Path::new(r"\\?\UNC\srv\share")))); + assert_eq!( + strip_verbatim(Path::new(r"C:\plain")), + Path::new(r"C:\plain") + ); + assert_eq!( + strip_verbatim(Path::new("relative/only")), + Path::new("relative/only") + ); + assert!(is_protected(&strip_verbatim(Path::new( + r"\\?\UNC\srv\share" + )))); } #[test] fn journal_append_heals_torn_tail() { let tmp = tempfile::tempdir().unwrap(); - let jp = tmp.path().join("j.jsonl"); - // 개행 없이 끊긴 꼬리를 시뮬레이션 + let jp = tmp.path().join("journal.jsonl"); std::fs::write(&jp, "{\"torn\":").unwrap(); journal_append( &jp, @@ -1007,7 +1451,7 @@ mod tests { ) .unwrap(); let recent = journal_recent(&jp, 10); - assert_eq!(recent.len(), 1, "치유된 새 엔트리는 온전히 읽혀야 함"); + assert_eq!(recent.len(), 1); assert_eq!(recent[0].path, "/x"); } @@ -1017,16 +1461,27 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let f = tmp.path().join("f.bin"); std::fs::write(&f, b"x").unwrap(); - let protected = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\x" } else { "/usr/x" }); - // 보호된 목적지 - assert!(matches!(move_file(&f, &protected, &jp, 1), Err(SafetyError::Protected(_)))); - // 보호된 출발 - let pf = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\y" } else { "/usr/y" }); - assert!(matches!(move_file(&pf, &tmp.path().join("z"), &jp, 1), Err(SafetyError::Protected(_)))); - assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전"); + let protected = std::path::PathBuf::from(if cfg!(windows) { + "C:\\Windows\\x" + } else { + "/usr/x" + }); + assert!(matches!( + move_file(&f, &protected, &jp, 1), + Err(SafetyError::Protected(_)) + )); + let pf = std::path::PathBuf::from(if cfg!(windows) { + "C:\\Windows\\y" + } else { + "/usr/y" + }); + assert!(matches!( + move_file(&pf, &tmp.path().join("z"), &jp, 1), + Err(SafetyError::Protected(_)) + )); + assert!(journal_recent(&jp, 10).is_empty()); } - // Fix 2 회귀 테스트: 존재하는 경로는 그대로 canonicalize — 기존 가드와 동일한 결과. #[test] fn normalize_for_guard_existing_path_canonicalizes_directly() { let tmp = tempfile::tempdir().unwrap(); @@ -1036,27 +1491,23 @@ mod tests { assert_eq!(normalize_for_guard(&f), expected); } - // Fix 2 회귀 테스트: dst는 보통 존재하지 않는다 — 실존하는 가장 가까운 조상(tmp 자체)까지 - // 걸어 올라가 canonicalize하고, 미존재 접미부("nested/does-not-exist.bin")를 그대로 붙여야 한다. #[test] fn normalize_for_guard_walks_up_to_existing_ancestor_for_missing_path() { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("nested").join("does-not-exist.bin"); let expected_base = strip_verbatim(&std::fs::canonicalize(tmp.path()).unwrap()); - assert_eq!(normalize_for_guard(&missing), expected_base.join("nested").join("does-not-exist.bin")); + assert_eq!( + normalize_for_guard(&missing), + expected_base.join("nested").join("does-not-exist.bin") + ); } - // Fix 2 회귀 테스트: 슬래시 없는 단일 상대 컴포넌트는 조상이 ""까지 내려가고 canonicalize("")도 - // 실패해 `cur.parent() == None` 최종 폴백(조상이 하나도 실존하지 않음)에 도달 — lexical 그대로 반환. #[test] fn normalize_for_guard_no_existing_ancestor_falls_back_to_lexical() { let p = Path::new("disksage-nonexistent-relative-xyz-zzz"); assert_eq!(normalize_for_guard(p), strip_verbatim(p)); } - // Fix 2 회귀 테스트: dst의 조상이 심링크로 보호 위치(/usr)를 가리키면, dst 자신은 존재하지 - // 않아도(그래서 lexical 폴백이 아니라 조상-워크가 심링크를 실제로 resolve해서) is_protected가 - // 우회되지 않고 걸려야 한다. #[cfg(unix)] #[test] fn move_file_rejects_dst_via_symlinked_protected_ancestor() { @@ -1065,12 +1516,12 @@ mod tests { let src = tmp.path().join("src.bin"); std::fs::write(&src, b"x").unwrap(); let link = tmp.path().join("media_link"); - std::os::unix::fs::symlink("/usr", &link).unwrap(); // 사용자가 심어놓은 ~/Media -> /usr 시뮬레이션 - let dst = link.join("evil.bin"); // lexical로는 안전해 보이지만 실제로는 /usr/evil.bin + std::os::unix::fs::symlink("/usr", &link).unwrap(); + let dst = link.join("evil.bin"); let err = move_file(&src, &dst, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); - assert!(src.exists(), "거부 시 원본 보존"); - assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전"); + assert!(src.exists()); + assert!(journal_recent(&jp, 10).is_empty()); } #[test] @@ -1085,9 +1536,6 @@ mod tests { assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } - // Fix 1 회귀 테스트: hard_link는 create-only라 dst가 이미 있으면(TOCTOU 경합으로 그 사이 - // 생긴 파일 시뮬레이션) AlreadyExists로 실패해야 하며, 그 경합 상대의 dst도 원본 src도 - // 절대 건드리면 안 된다 — rename의 REPLACE 시맨틱이었다면 여기서 dst가 파괴됐을 것. #[test] fn do_move_same_volume_hard_link_fails_when_dest_exists() { let tmp = tempfile::tempdir().unwrap(); @@ -1095,15 +1543,11 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"original").unwrap(); - std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 + std::fs::write(&dst, b"pre-existing").unwrap(); let err = do_move(&src, &dst, true, &jp, 1); assert!(matches!(err, Err(SafetyError::Trash(_)))); - assert!(src.exists(), "원본은 실패 시 보존"); - assert_eq!( - std::fs::read(&dst).unwrap(), - b"pre-existing", - "경합 상대의 목적지를 덮어쓰면 안 됨" - ); + assert!(src.exists()); + assert_eq!(std::fs::read(&dst).unwrap(), b"pre-existing"); } #[cfg(any(windows, target_os = "linux"))] @@ -1114,21 +1558,17 @@ mod tests { let src = tmp.path().join("disksage-xvol-fixture.bin"); let dst = tmp.path().join("moved-disksage-xvol-fixture.bin"); std::fs::write(&src, vec![9u8; 40]).unwrap(); - // same_vol=false 강제 → 실제 같은 볼륨이어도 copy+verify+trash 경로 실행 do_move(&src, &dst, false, &jp, 2).unwrap(); - assert!(!src.exists(), "원본은 휴지통으로"); + assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); - // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge - let items: Vec<_> = trash::os_limited::list().unwrap().into_iter() - .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")).collect(); + let items: Vec<_> = trash::os_limited::list() + .unwrap() + .into_iter() + .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")) + .collect(); trash::os_limited::purge_all(items).unwrap(); } - // 회귀: 크로스 볼륨 복사가 mtime과 mode를 보존하는지(내용만이 아니라 메타데이터도). 같은 볼륨 - // 경로(hard_link)는 같은 inode라 자동 보존되지만, 크로스 볼륨은 io::copy가 바이트만 옮겨 - // mtime을 now로, mode를 0644로 리셋했었다 — 앱의 age 신호(organize/dev_artifacts/prompt)와 - // 0600 권한이 소리 없이 손상되던 버그. 복사 단계를 직접 호출하므로 실제 두 볼륨은 필요 없고, - // 플랫폼별 휴지통 열거 API에도 의존하지 않는다. #[test] #[cfg(unix)] fn copy_verified_io_preserves_mtime_and_mode() { @@ -1137,10 +1577,9 @@ mod tests { let src = tmp.path().join("disksage-xvol-meta-fixture.bin"); let dst = tmp.path().join("moved-disksage-xvol-meta-fixture.bin"); std::fs::write(&src, vec![7u8; 32]).unwrap(); - // 원본에 앱의 age 신호가 의존하는 과거 mtime과 비-기본 권한(0600)을 부여 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).unwrap(); let past = - std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_836_800); // 2020-01-01 + std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_836_800); std::fs::OpenOptions::new() .write(true) .open(&src) @@ -1148,27 +1587,13 @@ mod tests { .set_times(std::fs::FileTimes::new().set_modified(past)) .unwrap(); let want_mtime = std::fs::metadata(&src).unwrap().modified().unwrap(); - copy_verified_io(&src, &dst).unwrap(); - - assert!(src.exists(), "검증 복사 단계는 원본을 보존해야 함"); + assert!(src.exists()); let dst_md = std::fs::metadata(&dst).unwrap(); - // mtime이 now로 손상되지 않고 원본(2020) 그대로여야 한다. - assert_eq!( - dst_md.modified().unwrap(), - want_mtime, - "크로스 볼륨 이동은 mtime을 보존해야 함" - ); - // 권한도 0600 그대로(0644로 되돌아가지 않아야 함). - assert_eq!( - dst_md.permissions().mode() & 0o777, - 0o600, - "크로스 볼륨 이동은 mode를 보존해야 함" - ); + assert_eq!(dst_md.modified().unwrap(), want_mtime); + assert_eq!(dst_md.permissions().mode() & 0o777, 0o600); } - // 원본 메타데이터를 못 읽으면(사라진 원본 등) fail-closed로 io::Error를 전파한다 — 이 에러가 - // copy_verified_io에서 목적지를 정리하고 원본을 건드리지 않게 만든다. #[test] fn preserve_source_metadata_errors_on_missing_source() { let tmp = tempfile::tempdir().unwrap(); @@ -1186,9 +1611,7 @@ mod tests { let dst = tmp.path().join("sub").join("a.bin"); std::fs::create_dir(tmp.path().join("sub")).unwrap(); std::fs::write(&src, vec![0u8; 20]).unwrap(); - move_file(&src, &dst, &jp, 7).unwrap(); - assert!(!src.exists()); assert!(dst.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 20); @@ -1204,9 +1627,8 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"aa").unwrap(); - std::fs::write(&dst, b"bb").unwrap(); // 이미 존재 + std::fs::write(&dst, b"bb").unwrap(); assert!(move_file(&src, &dst, &jp, 1).is_err()); - // 원본과 기존 목적지 모두 보존 assert!(src.exists()); assert_eq!(std::fs::read(&dst).unwrap(), b"bb"); } @@ -1225,9 +1647,6 @@ mod tests { fn same_volume_missing_path_is_not_same_volume() { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("no-such-file.tmp"); - // 존재하지 않는 경로 → 볼륨 판정 불가 → false. 플랫폼 무관: - // Windows는 canonicalize 실패 후 drive() 불일치, unix는 metadata Err. - // (unix same_volume의 metadata-Err/false 경로를 리눅스 게이트에서 커버) assert!(!same_volume(&missing, tmp.path())); } @@ -1239,7 +1658,7 @@ mod tests { let dst = tmp.path().join("z.bin"); let err = move_file(&sneaky, &dst, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); - assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전"); + assert!(journal_recent(&jp, 10).is_empty()); } #[test] @@ -1248,14 +1667,13 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let src = tmp.path().join("src.bin"); std::fs::write(&src, b"hi").unwrap(); - // "blocker"를 파일로 만들어 그 이름으로 디렉토리를 만들 수 없게 함 let blocker = tmp.path().join("blocker"); std::fs::write(&blocker, b"not a dir").unwrap(); let dst = blocker.join("nested").join("dst.bin"); let err = move_file(&src, &dst, &jp, 1); assert!(matches!(err, Err(SafetyError::Trash(_)))); - assert!(src.exists(), "부모 생성 실패 시 원본 보존"); - assert!(journal_recent(&jp, 10).is_empty(), "부모 생성 실패는 저널 이전에 실패"); + assert!(src.exists()); + assert!(journal_recent(&jp, 10).is_empty()); } #[test] @@ -1264,12 +1682,11 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let src = tmp.path().join("d"); std::fs::create_dir(&src).unwrap(); - // 디렉토리를 자기 자신의 하위 경로로 이동 시도 — OS가 rename을 거부(EINVAL 계열)한다 let dst = src.join("inner").join("d"); let err = move_file(&src, &dst, &jp, 5); assert!(matches!(err, Err(SafetyError::Trash(_)))); let recent = journal_recent(&jp, 10); - assert_eq!(recent.len(), 2); // pending + error + assert_eq!(recent.len(), 2); assert!(recent[0].outcome.starts_with("error:")); assert_eq!(recent[1].outcome, "pending"); } @@ -1290,21 +1707,18 @@ mod tests { fn hashes_match_detects_size_or_hash_mismatch() { let a = || Ok::("a".into()); let b = || Ok::("b".into()); - assert!(!hashes_match(&a(), &a(), 1, 2)); // 크기 불일치 - assert!(!hashes_match(&a(), &b(), 1, 1)); // 해시 불일치 + assert!(!hashes_match(&a(), &a(), 1, 2)); + assert!(!hashes_match(&a(), &b(), 1, 1)); assert!(hashes_match(&a(), &a(), 1, 1)); } - // Fix 1 회귀 테스트: 해시 계산 자체가 실패하면(예: 읽기 오류로 Err) 절대 "일치"로 읽히면 안 된다. - // 예전 코드는 unwrap_or_default()로 실패를 ""로 뭉개서, 양쪽 다 실패하면 ""=="" → 거짓 검증 - // 통과가 됐었다(blake3 해시는 절대 비지 않으므로 ""는 반드시 실패를 의미해야 한다). #[test] fn hashes_match_fails_closed_when_either_hash_errored() { let ok = || Ok::("same-hash".into()); let err = || Err::("read failed".into()); assert!(!hashes_match(&err(), &ok(), 10, 10)); assert!(!hashes_match(&ok(), &err(), 10, 10)); - assert!(!hashes_match(&err(), &err(), 10, 10), "양쪽 다 실패해도 절대 일치로 읽히면 안 됨"); + assert!(!hashes_match(&err(), &err(), 10, 10)); } #[test] @@ -1313,7 +1727,7 @@ mod tests { let dst = tmp.path().join("partial.bin"); std::fs::write(&dst, b"partial").unwrap(); assert!(finalize_verified_copy(&dst, false).is_err()); - assert!(!dst.exists(), "검증 실패 시 우리가 만든 목적지를 정리"); + assert!(!dst.exists()); } #[test] @@ -1337,7 +1751,6 @@ mod tests { #[test] fn copy_verified_io_cleans_up_and_errors_when_copy_source_missing() { - // 복사 단계 자체가 실패해도(검증 단계가 아니라) 부분 목적지를 정리하고 원본은 그대로 둔다 let tmp = tempfile::tempdir().unwrap(); let missing_src = tmp.path().join("does-not-exist.bin"); let dst = tmp.path().join("never-created.bin"); @@ -1346,27 +1759,16 @@ mod tests { assert!(!dst.exists()); } - // Fix 2 회귀 테스트: dst.exists() 체크와 실제 복사 사이의 TOCTOU 경합 대응. - // create_new(true)라 복사 단계 자체가 "이미 있으면 실패"이므로 경합 상대가 방금 만든 - // 파일을 절대 덮어쓰지 않는다 — 그리고 그 파일을 우리가 만든 게 아니므로 정리 대상도 아니다. #[test] fn copy_verified_io_does_not_overwrite_existing_destination() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("s3.bin"); let dst = tmp.path().join("d3.bin"); std::fs::write(&src, b"new-content").unwrap(); - std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 + std::fs::write(&dst, b"pre-existing").unwrap(); let err = copy_verified_io(&src, &dst); assert!(err.is_err()); - assert_eq!( - std::fs::read(&dst).unwrap(), - b"pre-existing", - "경합 상대의 목적지를 덮어쓰거나 지우면 안 됨" - ); - assert!(src.exists(), "원본은 실패 시에도 그대로 보존"); + assert_eq!(std::fs::read(&dst).unwrap(), b"pre-existing"); + assert!(src.exists()); } - - // 크로스 볼륨 분기(복사+검증+trash_delete)의 결정적 커버리지는 do_move_cross_volume_* - // 테스트가 same_vol=false를 강제해 양 플랫폼에서 담당한다. 실제 두 볼륨에 의존하는 통합 - // 테스트는 어느 단일 볼륨 게이트에서도 본문이 스킵돼 커버리지 갭을 만들므로 두지 않는다. } diff --git a/src-tauri/src/safety_non_utf8_tests.rs b/src-tauri/src/safety_non_utf8_tests.rs new file mode 100644 index 000000000..8e57f7ae3 --- /dev/null +++ b/src-tauri/src/safety_non_utf8_tests.rs @@ -0,0 +1,23 @@ +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; + +#[test] +fn non_utf8_sidecar_marker_still_vetoes_cleanup() { + let tmp = tempfile::tempdir().expect("create safety fixture"); + let invalid_name = OsString::from_vec(vec![b'c', b'r', b'm', 0xff]); + let protected_path = tmp.path().join(&invalid_name); + match std::fs::create_dir(&protected_path) { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(libc::EILSEQ) => return, + Err(error) => panic!("create non-UTF8 fixture directory: {error}"), + } + + let mut marker_name = invalid_name; + marker_name.push(crate::safety::PROTECTED_PATH_MARKER); + std::fs::write(tmp.path().join(marker_name), []).expect("write sidecar keep marker"); + + assert!( + crate::safety::is_explicitly_protected(&protected_path), + "a protection sidecar must remain authoritative even when the protected filename is not UTF-8" + ); +} diff --git a/src-tauri/tests/cli_help_eviction_destination_exit.rs b/src-tauri/tests/cli_help_eviction_destination_exit.rs index d0a0e8938..a807c623a 100644 --- a/src-tauri/tests/cli_help_eviction_destination_exit.rs +++ b/src-tauri/tests/cli_help_eviction_destination_exit.rs @@ -4,7 +4,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; -const BINARIES: [(&str, &str, &str, &str); 3] = [ +const BINARIES: [(&str, &str, &str, &str); 4] = [ ( "disksage-icloud-local-eviction", "usage: disksage-icloud-local-eviction --cloud-root ABSOLUTE_PATH --path ABSOLUTE_FILE [--execute --approved-plan-fingerprint HEX64 --confirm-plan-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", @@ -17,6 +17,12 @@ const BINARIES: [(&str, &str, &str, &str); 3] = [ "incomplete-download-destination-plan-unknown-argument", "incomplete-download-destination-plan-invalid-utf8-argument", ), + ( + "disksage-cloud-local-eviction-batch", + "usage: disksage-cloud-local-eviction-batch --cloud-root ABSOLUTE_PATH --manifest ABSOLUTE_JSON [--execute --approved-batch-fingerprint HEX64 --confirm-batch-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", + "알 수 없는 인자", + "icloud-local-eviction-batch-invalid-utf8-argument", + ), ( "disksage-icloud-local-eviction-batch", "usage: disksage-icloud-local-eviction-batch --cloud-root ABSOLUTE_PATH --manifest ABSOLUTE_JSON [--execute --approved-batch-fingerprint HEX64 --confirm-batch-fingerprint HEX64 --approved-by human:IDENTITY --rationale TEXT --record-dir ABSOLUTE_LOCAL_DIRECTORY]", diff --git a/src-tauri/tests/container_orphan_build_cache_authority.rs b/src-tauri/tests/container_orphan_build_cache_authority.rs new file mode 100644 index 000000000..93476251a --- /dev/null +++ b/src-tauri/tests/container_orphan_build_cache_authority.rs @@ -0,0 +1,39 @@ +#![cfg(unix)] + +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +#[test] +fn build_cache_execution_requires_fresh_runtime_evidence_before_mutation() { + let receipt_dir = tempfile::tempdir().expect("private receipt tempdir"); + std::fs::set_permissions( + receipt_dir.path(), + std::fs::Permissions::from_mode(0o700), + ) + .expect("private receipt permissions"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + PathBuf::from("/definitely/missing/disksage-docker"), + None, + ) + .expect("static docker target"); + + let error = execute_container_orphan_prune( + &target, + OrphanCategory::BuildCache, + "not-authorized", + "reviewed exact candidates", + 1, + receipt_dir.path(), + ) + .expect_err("missing runtime prevents fresh BuildKit evidence"); + + assert_eq!( + error, + "orphan-prune-evidence-incomplete:orphan-list-build_cache-spawn:No such file or directory (os error 2)" + ); +} diff --git a/src-tauri/tests/container_orphan_build_cache_exact_prune.rs b/src-tauri/tests/container_orphan_build_cache_exact_prune.rs new file mode 100644 index 000000000..b9f6d0912 --- /dev/null +++ b/src-tauri/tests/container_orphan_build_cache_exact_prune.rs @@ -0,0 +1,98 @@ +#![cfg(unix)] + +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans_with_receipt_dir, + ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +#[test] +fn build_cache_prune_targets_only_the_freshly_reviewed_record_id() { + let temp = tempfile::tempdir().unwrap(); + let receipt_dir = temp.path().join("receipts"); + std::fs::create_dir(&receipt_dir).unwrap(); + std::fs::set_permissions(&receipt_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let log_path = temp.path().join("docker.log"); + let docker_path = temp.path().join("docker"); + let script = format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' +case "$*" in + "info") exit 0 ;; + *"container ps"*) exit 0 ;; + "images "*) exit 0 ;; + *"volume ls"*) exit 0 ;; + *"network ls"*) exit 0 ;; + *"buildx du"*) + printf '%s\n' '{{"ID":"cache123","Reclaimable":true,"Shared":false,"Mutable":false,"Type":"regular"}}' + exit 0 + ;; + *"buildx prune --all --filter id~=^(cache123)$ --force") exit 0 ;; + *) + printf '%s\n' "unexpected command: $*" >&2 + exit 23 + ;; +esac +"#, + log_path.display() + ); + std::fs::write(&docker_path, script).unwrap(); + std::fs::set_permissions(&docker_path, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + PathBuf::from(&docker_path), + None, + ) + .unwrap(); + + let plan = probe_container_orphans_with_receipt_dir(&target, &receipt_dir); + assert!(plan.evidence_complete, "plan issues: {:?}", plan.issues); + let build_cache = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::BuildCache) + .expect("BuildKit cache category must be audited for Docker"); + assert_eq!( + build_cache + .evidence + .as_ref() + .expect("BuildKit evidence") + .candidate_records, + 1 + ); + + let phrase = build_cache + .approval_phrase + .as_deref() + .expect("fresh exact candidate set must be approvable"); + + let execution = execute_container_orphan_prune( + &target, + OrphanCategory::BuildCache, + phrase, + "Reviewed the exact BuildKit cache record.", + 1_800_000_000_000, + &receipt_dir, + ) + .expect("exact BuildKit record deletion should be supported"); + assert_eq!(execution.status_code, 0); + assert!(execution.executed); + + let log = std::fs::read_to_string(&log_path).unwrap(); + assert!( + log.lines().any(|line| { + line.ends_with("buildx prune --all --filter id~=^(cache123)$ --force") + }), + "exact prune command missing from log: {log}" + ); + assert!( + !log.lines().any(|line| { + line.contains("buildx prune --all --force") + || (line.contains("buildx prune --all") && !line.contains("--filter id~=")) + }), + "unfiltered category-wide prune must never run: {log}" + ); +} diff --git a/src-tauri/tests/container_orphan_capacity_evidence_regression.rs b/src-tauri/tests/container_orphan_capacity_evidence_regression.rs new file mode 100644 index 000000000..c2406d80f --- /dev/null +++ b/src-tauri/tests/container_orphan_capacity_evidence_regression.rs @@ -0,0 +1,85 @@ +#![cfg(unix)] + +use disksage_lib::container_orphan_public::sanitize_execution; +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, + ContainerRuntimeTarget, OrphanCategory, +}; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn public_prune_receipt_does_not_claim_host_capacity_without_runtime_store_volume_authority() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write( + &runtime, + format!( + r#"#!/bin/sh +set -eu +case "${{1:-}}" in + info) + printf '%s\n' '{{}}' + exit 0 + ;; + container) + if [ "${{2:-}}" = "ps" ]; then + printf '%s\n' '{{"ID":"{FULL_ID}","State":"exited","Names":[]}}' + exit 0 + fi + if [ "${{2:-}}" = "inspect" ] && [ "${{3:-}}" = "{FULL_ID}" ]; then + printf '%s\n' '[{{"Id":"{FULL_ID}","Created":"2026-08-30T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]' + exit 0 + fi + if [ "${{2:-}}" = "rm" ] && [ "${{3:-}}" = "{FULL_ID}" ] && [ "${{4:-}}" = "" ]; then + printf '%s\n' '{FULL_ID}' + exit 0 + fi + exit 98 + ;; + *) exit 99 ;; +esac +"# + ), + ) + .expect("write fake runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + + let target = ContainerRuntimeTarget::new(ContainerRuntimeKind::DockerNative, runtime, None) + .expect("valid Docker target"); + let receipts = tempfile::tempdir().unwrap(); + std::fs::set_permissions(receipts.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let plan = probe_container_orphans_with_receipt_dir(&target, receipts.path()); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + let phrase = container + .approval_phrase + .as_deref() + .expect("candidate-bound approval phrase"); + + let execution = execute_container_orphan_prune( + &target, + OrphanCategory::Container, + phrase, + "Remove the exact stopped-container candidate verified by DiskSage.", + 1, + receipts.path(), + ) + .expect("exact candidate removal must succeed"); + assert!(execution.executed); + + let public_receipt = sanitize_execution(execution); + assert_eq!( + public_receipt.before_available_bytes, None, + "the process working-directory volume is not authoritative for runtime-store capacity" + ); + assert_eq!(public_receipt.after_available_bytes, None); + assert_eq!(public_receipt.observed_available_gain_bytes, None); +} diff --git a/src-tauri/tests/container_orphan_cli_authority_binding.rs b/src-tauri/tests/container_orphan_cli_authority_binding.rs new file mode 100644 index 000000000..3387b04ea --- /dev/null +++ b/src-tauri/tests/container_orphan_cli_authority_binding.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +#[test] +fn docker_native_cli_execute_fails_closed_without_authority_binding() { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .args([ + "--runtime", + "docker-native", + "--bin", + "__disksage_test_missing_docker__", + "--execute", + "container", + "--confirm", + "irrelevant-unbound-phrase", + "--rationale", + "Verify Docker authority binding before any destructive CLI execution.", + ]) + .output() + .expect("container orphan CLI must launch"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("docker-native-cli-execution-requires-authority-binding"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn docker_colima_cli_execute_fails_closed_before_context_access() { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .args([ + "--runtime", + "docker-colima-context", + "--scope", + "colima", + "--bin", + "__disksage_test_missing_docker__", + "--execute", + "container", + "--confirm", + "irrelevant-unbound-phrase", + "--rationale", + "Reject mutable Docker context authority before runtime access.", + ]) + .output() + .expect("container orphan CLI must launch"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("docker-context-cli-execution-requires-immutable-authority"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("No such file") && !stderr.contains("not found"), + "CLI must reject authority before touching the Docker binary: {stderr}" + ); +} diff --git a/src-tauri/tests/container_orphan_cli_docker_host_path.rs b/src-tauri/tests/container_orphan_cli_docker_host_path.rs new file mode 100644 index 000000000..a75bc9a05 --- /dev/null +++ b/src-tauri/tests/container_orphan_cli_docker_host_path.rs @@ -0,0 +1,45 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn docker_host_uses_default_docker_from_path_without_explicit_bin() { + let root = std::env::temp_dir().join(format!( + "disksage-docker-host-path-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let docker = root.join("docker"); + fs::write(&docker, "#!/bin/sh\nexit 1\n").unwrap(); + let mut permissions = fs::metadata(&docker).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&docker, permissions).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .args([ + "--runtime", + "docker-native", + "--docker-host", + "unix:///tmp/disksage-test-docker.sock", + ]) + .env("PATH", &root) + .output() + .expect("container orphan CLI must launch"); + + let _ = fs::remove_dir_all(&root); + assert!( + output.status.success(), + "PATH-resolved default docker must reach read-only audit; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("disksage.container-orphan-plan"), + "read-only audit must still emit a sanitized plan" + ); +} diff --git a/src-tauri/tests/container_orphan_cli_unbound_approval.rs b/src-tauri/tests/container_orphan_cli_unbound_approval.rs new file mode 100644 index 000000000..432b45d92 --- /dev/null +++ b/src-tauri/tests/container_orphan_cli_unbound_approval.rs @@ -0,0 +1,68 @@ +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[cfg(unix)] +#[test] +fn docker_native_read_only_cli_does_not_publish_an_unusable_approval_phrase() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + let script = format!( + r#"#!/bin/sh +set -eu +case "${{1:-}}" in + info) exit 0 ;; + container) + case "${{2:-}}" in + ps) printf '%s\n' '{{"ID":"{FULL_ID}","State":"exited","Names":[]}}' ;; + inspect) printf '%s\n' '[{{"Id":"{FULL_ID}","Created":"2026-08-30T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]' ;; + *) exit 91 ;; + esac + ;; + images) exit 0 ;; + volume) + [ "${{2:-}}" = "ls" ] || exit 92 + exit 0 + ;; + network) + [ "${{2:-}}" = "ls" ] || exit 93 + exit 0 + ;; + *) exit 94 ;; +esac +"# + ); + std::fs::write(&runtime, script).expect("write fake Docker runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .arg("--runtime") + .arg("docker-native") + .arg("--bin") + .arg(&runtime) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("machine-readable UTF-8 evidence"); + let document: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON plan"); + let container = document["categories"] + .as_array() + .expect("category array") + .iter() + .find(|category| category["category"] == serde_json::json!("container")) + .expect("container category"); + + assert_eq!( + container["evidence"]["candidate_records"], + serde_json::json!(1) + ); + assert_eq!(container["approval_phrase"], serde_json::Value::Null); +} diff --git a/src-tauri/tests/container_orphan_command_coverage_contract.rs b/src-tauri/tests/container_orphan_command_coverage_contract.rs new file mode 100644 index 000000000..265cd1e4c --- /dev/null +++ b/src-tauri/tests/container_orphan_command_coverage_contract.rs @@ -0,0 +1,50 @@ +use std::fs; +use std::path::PathBuf; + +fn source(path: &str) -> String { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + fs::read_to_string(root.join(path)).expect("repository source must be readable") +} + +#[test] +fn shipped_container_orphan_commands_remain_present_in_coverage_builds() { + let command_owner = source("src/container_orphan_commands.rs"); + let lib = source("src/lib.rs"); + + for command in ["inspect_container_orphans", "execute_container_orphan_prune"] { + let signature = format!("pub fn {command}("); + let start = command_owner + .find(&signature) + .unwrap_or_else(|| panic!("shipped Tauri command {command} must exist")); + let prefix_start = command_owner[..start] + .rfind("\n///") + .unwrap_or_else(|| panic!("{command} must retain a documented command boundary")); + let prefix = &command_owner[prefix_start..start]; + assert!( + !prefix.contains("#[cfg(not(coverage))]"), + "coverage builds must retain the shipped {command} command surface" + ); + assert!( + prefix.contains("#[tauri::command(async)]"), + "{command} must remain a Tauri command" + ); + assert!( + lib.contains(&format!("container_orphan_commands::{command},")), + "the production invoke handler must route {command} through its covered owner" + ); + assert!( + !lib.contains(&format!("\n commands::{command},")), + "the coverage-excluded legacy wrapper must not remain the shipped IPC authority" + ); + } + + assert!( + lib.contains("mod container_orphan_commands;"), + "the covered container-orphan command owner must be compiled with the library" + ); + assert!( + command_owner.contains("container_orphan_reclaim") + && command_owner.contains("podman_reclaim"), + "container orphan command dependencies must remain available when coverage is enabled" + ); +} diff --git a/src-tauri/tests/container_orphan_descendant_pipe_contract.rs b/src-tauri/tests/container_orphan_descendant_pipe_contract.rs new file mode 100644 index 000000000..bfc18213b --- /dev/null +++ b/src-tauri/tests/container_orphan_descendant_pipe_contract.rs @@ -0,0 +1,53 @@ +#[cfg(unix)] +use disksage_lib::container_orphan_reclaim::{ + probe_runtime_health, ContainerRuntimeKind, ContainerRuntimeTarget, +}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +#[cfg(unix)] +#[test] +fn successful_runtime_probe_does_not_wait_for_descendant_holding_capture_pipes() { + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write( + &runtime, + r#"#!/bin/sh +set -eu +if [ "${1:-}" = "info" ]; then + sleep 2 & + exit 0 +fi +exit 0 +"#, + ) + .expect("write fake runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime, + None, + ) + .expect("valid Docker target"); + + let started = Instant::now(); + let health = probe_runtime_health(&target); + let elapsed = started.elapsed(); + + assert!( + health.healthy, + "runtime probe should succeed: {:?}", + health.detail_issue + ); + assert!( + elapsed < Duration::from_secs(1), + "successful direct child exit must not wait for a descendant holding stdout/stderr; elapsed={elapsed:?}" + ); +} diff --git a/src-tauri/tests/container_orphan_docker_names_contract.rs b/src-tauri/tests/container_orphan_docker_names_contract.rs new file mode 100644 index 000000000..4aa8d0a01 --- /dev/null +++ b/src-tauri/tests/container_orphan_docker_names_contract.rs @@ -0,0 +1,69 @@ +#![cfg(unix)] + +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans, ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; +use std::os::unix::fs::PermissionsExt; + +fn assert_docker_names_shape_is_accepted(names: &str) { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + let script = format!( + r#"#!/bin/sh +set -eu +case "${{1:-}}" in + info) exit 0 ;; + container) + case "${{2:-}}" in + ps) + case " $* " in *" --no-trunc "*) ;; *) echo "missing --no-trunc" >&2; exit 92 ;; esac + printf '%s\n' '{{"ID":"{FULL_ID}","State":"exited","Names":"{names}"}}' + ;; + inspect) + printf '%s\n' '{{"Id":"{FULL_ID}","Created":"2026-01-01T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}' + ;; + *) exit 91 ;; + esac + ;; + images|volume|network) exit 0 ;; + *) exit 93 ;; +esac +"# + ); + std::fs::write(&runtime, script).expect("write fake Docker runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime, + None, + ) + .expect("valid Docker target"); + let plan = probe_container_orphans(&target); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + + assert!(container.evidence_complete, "{:?}", container.issue); + let evidence = container.evidence.as_ref().expect("container evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!(evidence.candidate_records, 1); + assert!(container.approval_phrase.is_none()); +} + +#[test] +fn docker_plain_comma_joined_names_do_not_break_stopped_container_audit() { + assert_docker_names_shape_is_accepted("web,worker"); +} + +#[test] +fn docker_plain_single_name_does_not_break_stopped_container_audit() { + assert_docker_names_shape_is_accepted("web"); +} diff --git a/src-tauri/tests/container_orphan_image_reference_contract.rs b/src-tauri/tests/container_orphan_image_reference_contract.rs new file mode 100644 index 000000000..690d7cf23 --- /dev/null +++ b/src-tauri/tests/container_orphan_image_reference_contract.rs @@ -0,0 +1,71 @@ +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans, ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[cfg(unix)] +#[test] +fn docker_image_used_by_a_container_is_not_a_prune_candidate() { + const IMAGE_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const CONTAINER_ID: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let temp = tempfile::tempdir().expect("temporary Docker runtime directory"); + let runtime = temp.path().join("docker"); + let script = format!( + r#"#!/bin/sh +set -eu +case "${{1:-}}" in + info) exit 0 ;; + container) + [ "${{2:-}}" = "ps" ] || exit 91 + case " $* " in + *" --filter ancestor={IMAGE_ID} "*) + printf '%s\n' '{{"ID":"{CONTAINER_ID}","State":"running","Names":["consumer"]}}' + ;; + *) exit 0 ;; + esac + ;; + images) + case " $* " in *" --all "*) ;; *) exit 92 ;; esac + case " $* " in *" --no-trunc "*) ;; *) exit 93 ;; esac + printf '%s\n' '{{"Containers":"N/A","ID":"{IMAGE_ID}","Repository":"","Size":"72.9MB","Tag":""}}' + ;; + volume|network) exit 0 ;; + *) exit 94 ;; +esac +"#, + ); + std::fs::write(&runtime, script).expect("write fake Docker runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake Docker metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake Docker runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime, + None, + ) + .expect("valid Docker target"); + + let plan = probe_container_orphans(&target); + let image = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Image) + .expect("image category"); + + assert!(image.evidence_complete, "{:?}", image.issue); + let evidence = image.evidence.as_ref().expect("image evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!( + evidence.candidate_records, 0, + "an image referenced by a container must not receive deletion authority" + ); + assert!(image.approval_phrase.is_none()); +} diff --git a/src-tauri/tests/container_orphan_network_identity_recreation.rs b/src-tauri/tests/container_orphan_network_identity_recreation.rs new file mode 100644 index 000000000..18b7cbc83 --- /dev/null +++ b/src-tauri/tests/container_orphan_network_identity_recreation.rs @@ -0,0 +1,121 @@ +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, + ContainerRuntimeTarget, OrphanCategory, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[cfg(unix)] +const NETWORK_ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +#[cfg(unix)] +const NETWORK_ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +#[cfg(unix)] +#[test] +fn recreated_network_name_cannot_reuse_an_approval_for_a_different_identity() { + let temp = tempfile::tempdir().expect("temporary Docker runtime directory"); + let runtime = temp.path().join("docker"); + let network_generation = temp.path().join("network-generation"); + let deletion_marker = temp.path().join("network-deleted"); + + let script = r#"#!/bin/sh +set -eu +network_generation="__NETWORK_GENERATION__" +delete_marker="__DELETE_MARKER__" +id_a="__ID_A__" +id_b="__ID_B__" +case "${1:-}" in + info) exit 0 ;; + container) + [ "${2:-}" = "ps" ] || exit 91 + exit 0 + ;; + images|volume) exit 0 ;; + network) + case "${2:-}" in + ls) + generation=$(cat "$network_generation" 2>/dev/null || printf '0') + if [ "$generation" = "0" ]; then + network_id="$id_a" + printf '1' > "$network_generation" + else + network_id="$id_b" + fi + printf '{"ID":"%s","Name":"custom-net","Driver":"bridge"}\n' "$network_id" + ;; + inspect) + printf '[{"Id":"%s","Name":"custom-net","Driver":"bridge","Containers":{},"Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}]\n' "${3:-missing}" + ;; + rm) + printf '%s\n' "${3:-missing}" > "$delete_marker" + ;; + *) exit 92 ;; + esac + ;; + *) exit 93 ;; +esac +"# + .replace( + "__NETWORK_GENERATION__", + &network_generation.display().to_string(), + ) + .replace("__DELETE_MARKER__", &deletion_marker.display().to_string()) + .replace("__ID_A__", NETWORK_ID_A) + .replace("__ID_B__", NETWORK_ID_B); + + std::fs::write(&runtime, script).expect("write fake Docker runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake Docker metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake Docker runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + PathBuf::from(&runtime), + None, + ) + .expect("valid Docker target"); + + let receipts = tempfile::tempdir().unwrap(); + std::fs::set_permissions(receipts.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let plan = probe_container_orphans_with_receipt_dir(&target, receipts.path()); + let network = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Network) + .expect("network category"); + assert!(network.evidence_complete, "{:?}", network.issue); + assert_eq!( + network + .evidence + .as_ref() + .expect("network evidence") + .candidate_records, + 1 + ); + let approval = network + .approval_phrase + .as_deref() + .expect("network approval phrase") + .to_string(); + + let error = execute_container_orphan_prune( + &target, + OrphanCategory::Network, + &approval, + "operator requested exact network cleanup", + 1, + receipts.path(), + ) + .expect_err("a recreated network with the same name must invalidate the approval"); + + assert_eq!(error, "orphan-prune-confirmation-mismatch"); + assert!( + !deletion_marker.exists(), + "the recreated network must not receive deletion authority" + ); +} diff --git a/src-tauri/tests/container_orphan_podman_image_contract.rs b/src-tauri/tests/container_orphan_podman_image_contract.rs new file mode 100644 index 000000000..03fe25915 --- /dev/null +++ b/src-tauri/tests/container_orphan_podman_image_contract.rs @@ -0,0 +1,75 @@ +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans, ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[cfg(unix)] +#[test] +fn podman_image_audit_uses_authoritative_dangling_filter_without_container_count() { + const DANGLING_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let temp = tempfile::tempdir().expect("temporary Podman runtime directory"); + let runtime = temp.path().join("podman"); + let script = format!( + r#"#!/bin/sh +set -eu +[ "${{1:-}}" = "--connection" ] || exit 91 +[ "${{2:-}}" = "machine-a" ] || exit 92 +shift 2 +case "${{1:-}}" in + info) exit 0 ;; + container) + case " $* " in + *" --filter ancestor="*) case " $* " in *" --external "*) ;; *) exit 90 ;; esac ;; + esac + printf '[]\n' + ;; + images) + [ "$#" -eq 6 ] || exit 93 + [ "${{2:-}}" = "--filter" ] || exit 94 + [ "${{3:-}}" = "dangling=true" ] || exit 95 + [ "${{4:-}}" = "--no-trunc" ] || exit 96 + [ "${{5:-}}" = "--format" ] || exit 97 + [ "${{6:-}}" = "json" ] || exit 98 + printf '%s\n' '[{{"id":"{DANGLING_ID}","names":[""],"size":250665}}]' + ;; + volume|network) exit 0 ;; + *) exit 99 ;; +esac +"#, + ); + std::fs::write(&runtime, script).expect("write fake Podman runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake Podman metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake Podman runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::PodmanMachine, + PathBuf::from(&runtime), + Some("machine-a".to_string()), + ) + .expect("valid Podman target"); + + let plan = probe_container_orphans(&target); + let image = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Image) + .expect("image category"); + + assert!(image.evidence_complete, "{:?}", image.issue); + let evidence = image.evidence.as_ref().expect("image evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!(evidence.candidate_records, 1); + assert_eq!(evidence.candidate_size_sum_bytes, Some(250665)); + assert!( + image.approval_phrase.is_none(), + "read-only discovery without a receipt directory must not publish unusable authority" + ); +} diff --git a/src-tauri/tests/container_orphan_podman_network_contract.rs b/src-tauri/tests/container_orphan_podman_network_contract.rs new file mode 100644 index 000000000..61da949a3 --- /dev/null +++ b/src-tauri/tests/container_orphan_podman_network_contract.rs @@ -0,0 +1,119 @@ +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, ContainerRuntimeTarget, + OrphanCategory, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[cfg(unix)] +const NETWORK_ID: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +#[cfg(unix)] +fn podman_network_target(attached: bool) -> (tempfile::TempDir, ContainerRuntimeTarget) { + let temp = tempfile::tempdir().expect("temporary Podman runtime directory"); + let runtime = temp.path().join("podman"); + let filtered_membership = if attached { + r#"[{"Id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]"# + } else { + "[]" + }; + let script = format!( + r#"#!/bin/sh +set -eu +[ "${{1:-}}" = "--connection" ] || exit 91 +[ "${{2:-}}" = "machine-a" ] || exit 92 +shift 2 +case "${{1:-}}" in + info) exit 0 ;; + container) + [ "${{2:-}}" = "ps" ] || exit 93 + case " $* " in + *" --filter network={NETWORK_ID} "*) printf '%s\n' '{filtered_membership}' ;; + *) printf '%s\n' '[]' ;; + esac + ;; + images|volume) printf '%s\n' '[]' ;; + network) + if [ "${{2:-}}" = "ls" ]; then + printf '%s\n' '[{{"driver":"bridge","id":"{NETWORK_ID}","name":"custom-net"}}]' + exit 0 + fi + if [ "${{2:-}}" = "inspect" ]; then + # Current Podman documentation shows valid inspect JSON that can omit Containers + # when no running containers are present. Membership must come from `ps --all`. + printf '%s\n' '[{{"name":"custom-net","id":"{NETWORK_ID}","driver":"bridge","dns_enabled":true,"labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}]' + exit 0 + fi + exit 94 + ;; + *) exit 95 ;; +esac +"#, + ); + std::fs::write(&runtime, script).expect("write fake Podman runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake Podman metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake Podman runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::PodmanMachine, + PathBuf::from(&runtime), + Some("machine-a".to_string()), + ) + .expect("valid Podman target"); + (temp, target) +} + +#[cfg(unix)] +#[test] +fn podman_network_without_any_container_membership_is_a_bounded_candidate() { + let (_temp, target) = podman_network_target(false); + let receipt_dir = tempfile::tempdir().expect("private receipt directory"); + let mut permissions = std::fs::metadata(receipt_dir.path()) + .expect("receipt directory metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(receipt_dir.path(), permissions).expect("private receipt directory"); + let plan = probe_container_orphans_with_receipt_dir(&target, receipt_dir.path()); + let network = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Network) + .expect("network category"); + + assert!(network.evidence_complete, "{:?}", network.issue); + let evidence = network.evidence.as_ref().expect("network evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!(evidence.candidate_records, 1); + assert!(network.approval_phrase.is_some()); +} + +#[cfg(unix)] +#[test] +fn podman_network_with_stopped_container_membership_is_preserved() { + let (_temp, target) = podman_network_target(true); + let receipt_dir = tempfile::tempdir().expect("private receipt directory"); + let mut permissions = std::fs::metadata(receipt_dir.path()) + .expect("receipt directory metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(receipt_dir.path(), permissions).expect("private receipt directory"); + let plan = probe_container_orphans_with_receipt_dir(&target, receipt_dir.path()); + let network = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Network) + .expect("network category"); + + assert!(network.evidence_complete, "{:?}", network.issue); + let evidence = network.evidence.as_ref().expect("network evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!(evidence.candidate_records, 0); + assert!(network.approval_phrase.is_none()); +} diff --git a/src-tauri/tests/container_orphan_podman_state_contract.rs b/src-tauri/tests/container_orphan_podman_state_contract.rs new file mode 100644 index 000000000..38f0794a0 --- /dev/null +++ b/src-tauri/tests/container_orphan_podman_state_contract.rs @@ -0,0 +1,108 @@ +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans, ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::path::PathBuf; + +#[cfg(unix)] +fn podman_target_with_container_json(container_json: &str) -> (tempfile::TempDir, ContainerRuntimeTarget) { + let temp = tempfile::tempdir().expect("temporary Podman runtime directory"); + let runtime = temp.path().join("podman"); + let script = format!( + r#"#!/bin/sh +set -eu +[ "${{1:-}}" = "--connection" ] || exit 91 +[ "${{2:-}}" = "machine-a" ] || exit 92 +shift 2 +case "${{1:-}}" in + info) exit 0 ;; + container) + case "${{2:-}}" in + ps) + printf '%s\n' '{container_json}' + ;; + inspect) + [ -n "${{3:-}}" ] || exit 93 + printf '[{{"Id":"%s","Created":"2026-08-30T00:00:00Z","State":{{"Status":"stopped"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]\n' "${{3}}" + ;; + *) exit 93 ;; + esac + ;; + images|volume|network) exit 0 ;; + *) exit 94 ;; +esac +"#, + ); + std::fs::write(&runtime, script).expect("write fake Podman runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake Podman metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake Podman runtime executable"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::PodmanMachine, + PathBuf::from(&runtime), + Some("machine-a".to_string()), + ) + .expect("valid Podman target"); + (temp, target) +} + +#[cfg(unix)] +#[test] +fn podman_stopped_is_removable_while_known_prestart_and_transitional_states_are_preserved() { + const STOPPED_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const INITIALIZED_ID: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const STOPPING_ID: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const CONFIGURED_ID: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let container_json = format!( + r#"[{{"Id":"{STOPPED_ID}","State":"stopped","Names":[]}},{{"Id":"{INITIALIZED_ID}","State":"initialized","Names":[]}},{{"Id":"{STOPPING_ID}","State":"stopping","Names":[]}},{{"Id":"{CONFIGURED_ID}","State":"configured","Names":[]}}]"#, + ); + let (temp, target) = podman_target_with_container_json(&container_json); + let receipt_dir = temp.path().join("receipts"); + std::fs::create_dir(&receipt_dir).expect("create receipt directory"); + let mut receipt_permissions = std::fs::metadata(&receipt_dir) + .expect("receipt directory metadata") + .permissions(); + receipt_permissions.set_mode(0o700); + std::fs::set_permissions(&receipt_dir, receipt_permissions) + .expect("secure receipt directory"); + + let plan = disksage_lib::container_orphan_reclaim::probe_container_orphans_with_receipt_dir( + &target, + &receipt_dir, + ); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + + assert!(container.evidence_complete, "{:?}", container.issue); + let evidence = container.evidence.as_ref().expect("container evidence"); + assert_eq!(evidence.total_records, 4); + assert_eq!(evidence.candidate_records, 1); + assert!(container.approval_phrase.is_some()); +} + +#[cfg(unix)] +#[test] +fn podman_unknown_state_remains_fail_closed() { + const UNKNOWN_ID: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let container_json = format!(r#"[{{"Id":"{UNKNOWN_ID}","State":"unknown","Names":[]}}]"#); + let (_temp, target) = podman_target_with_container_json(&container_json); + + let plan = probe_container_orphans(&target); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + + assert!(!container.evidence_complete); + assert_eq!(container.issue.as_deref(), Some("unknown-container-state:unknown")); +} diff --git a/src-tauri/tests/container_orphan_public_privacy.rs b/src-tauri/tests/container_orphan_public_privacy.rs new file mode 100644 index 000000000..a4e9c37b5 --- /dev/null +++ b/src-tauri/tests/container_orphan_public_privacy.rs @@ -0,0 +1,86 @@ +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[cfg(unix)] +fn fake_runtime(secret: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write( + &runtime, + format!( + "#!/bin/sh\nset -eu\nprintf '%s\\n' '{}' >&2\nexit 17\n", + secret + ), + ) + .expect("write fake Docker runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + (temp, runtime) +} + +#[cfg(unix)] +#[test] +fn shipped_orphan_plan_cli_redacts_runtime_stderr_from_json() { + let secret = "/Users/customer/private/docker.sock bearer-secret-token"; + let (_temp, runtime) = fake_runtime(secret); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .arg("--runtime") + .arg("docker-native") + .arg("--bin") + .arg(&runtime) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("machine-readable UTF-8 evidence"); + let document: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON plan"); + assert_eq!( + document["runtime"]["detail_issue"], + serde_json::json!("runtime-info-failed") + ); + assert_eq!( + document["issues"], + serde_json::json!(["runtime-info-failed"]) + ); + assert!(!stdout.contains(secret)); + assert!(!stdout.contains("private/docker.sock")); + assert!(!stdout.contains("bearer-secret-token")); +} + +#[cfg(unix)] +#[test] +fn shipped_scoped_orphan_plan_never_exposes_context_or_binary_path() { + let secret_scope = "customer-colima-secret"; + let secret = "/Users/customer/private/docker.sock bearer-secret-token"; + let (_temp, runtime) = fake_runtime(secret); + let runtime_path = runtime.to_string_lossy().into_owned(); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-container-orphan-plan")) + .arg("--runtime") + .arg("docker-colima-context") + .arg("--scope") + .arg(secret_scope) + .arg("--bin") + .arg(&runtime) + .output() + .expect("run shipped scoped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("machine-readable UTF-8 evidence"); + let document: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON plan"); + assert_eq!( + document["runtime"]["display_name"], + serde_json::json!("docker-colima-context") + ); + assert!(!stdout.contains(secret_scope)); + assert!(!stdout.contains(&runtime_path)); + assert!(!stdout.contains(secret)); +} diff --git a/src-tauri/tests/container_orphan_runtime_regression.rs b/src-tauri/tests/container_orphan_runtime_regression.rs new file mode 100644 index 000000000..78557276d --- /dev/null +++ b/src-tauri/tests/container_orphan_runtime_regression.rs @@ -0,0 +1,509 @@ +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans, + probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, ContainerRuntimeTarget, + OrphanCategory, +}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[cfg(unix)] +fn fake_runtime(script_body: &str) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write(&runtime, format!("#!/bin/sh\nset -eu\n{script_body}\n")) + .expect("write fake runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + (temp, runtime) +} + +#[cfg(unix)] +fn docker_target(runtime: &Path) -> ContainerRuntimeTarget { + ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime.to_path_buf(), + None, + ) + .expect("valid Docker target") +} + +#[cfg(unix)] +fn private_receipt_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + dir +} + +#[cfg(unix)] +#[test] +fn healthy_empty_docker_lists_are_complete_and_binary_is_not_repeated() { + let (_temp, runtime) = fake_runtime( + r#" +case "${1:-}" in + info) exit 0 ;; + container) + [ "${2:-}" = "ps" ] || exit 91 + exit 0 + ;; + images) exit 0 ;; + buildx) exit 0 ;; + volume) + [ "${2:-}" = "ls" ] || exit 92 + exit 0 + ;; + network) + [ "${2:-}" = "ls" ] || exit 93 + exit 0 + ;; + *) + echo "unexpected command" >&2 + exit 94 + ;; +esac +"#, + ); + + let plan = probe_container_orphans(&docker_target(&runtime)); + assert!( + plan.runtime.healthy, + "runtime info must receive info as argv[1]" + ); + assert!( + plan.evidence_complete, + "zero-record Docker listings are complete evidence" + ); + assert_eq!(plan.categories.len(), 5); + for category in &plan.categories { + assert!( + category.evidence_complete, + "{:?}: {:?}", + category.category, category.issue + ); + let evidence = category + .evidence + .as_ref() + .expect("complete category evidence"); + assert_eq!(evidence.total_records, 0); + assert_eq!(evidence.candidate_records, 0); + assert!(category.approval_phrase.is_none()); + } +} + +#[cfg(unix)] +#[test] +fn docker_audit_requests_full_ids_and_all_image_evidence() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let (_temp, runtime) = fake_runtime(&format!( + r#" +case "${{1:-}}" in + info) exit 0 ;; + container) + [ "${{2:-}}" = "ps" ] || exit 91 + case " $* " in *" --no-trunc "*) ;; *) echo "missing container --no-trunc" >&2; exit 92 ;; esac + case " $* " in + *" --filter ancestor={FULL_ID} "*) exit 0 ;; + *) printf '%s\n' '{{"ID":"{FULL_ID}","State":"running","Names":[]}}' ;; + esac + ;; + images) + case " $* " in *" --no-trunc "*) ;; *) echo "missing image --no-trunc" >&2; exit 93 ;; esac + case " $* " in *" --all "*) ;; *) echo "missing all-images scope" >&2; exit 94 ;; esac + printf '%s\n' '{{"Containers":"N/A","ID":"{FULL_ID}","Repository":"","Size":"72.9MB","Tag":""}}' + ;; + image) + [ "${{2:-}}" = "inspect" ] || exit 96 + case " $* " in *" --format "*) ;; *) echo "missing image inspect format" >&2; exit 97 ;; esac + case " $* " in *"json .Id"*"json .Size"*) ;; *) echo "missing image inspect fields" >&2; exit 98 ;; esac + printf '%s\n' '{{"Id":"sha256:{FULL_ID}","Size":72900000}}' + ;; + volume|network) exit 0 ;; + *) exit 98 ;; +esac +"# + )); + + let plan = probe_container_orphans(&docker_target(&runtime)); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + assert!(container.evidence_complete, "{:?}", container.issue); + assert_eq!(container.evidence.as_ref().unwrap().candidate_records, 0); + + let image = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Image) + .expect("image category"); + assert!(image.evidence_complete, "{:?}", image.issue); + let evidence = image.evidence.as_ref().expect("image evidence"); + assert_eq!(evidence.total_records, 1); + assert_eq!(evidence.candidate_records, 1); + assert_eq!(evidence.candidate_size_sum_bytes, Some(72_900_000)); + assert!( + image.approval_phrase.is_none(), + "an unbound receipt directory must not publish mutation approval" + ); +} + +#[cfg(unix)] +#[test] +fn docker_image_size_identity_mismatch_blocks_only_image_category() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER_ID: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let (_temp, runtime) = fake_runtime(&format!( + r#" +case "${{1:-}}" in + info|container|volume|network) exit 0 ;; + images) + printf '%s\n' '{{"Containers":"N/A","ID":"{FULL_ID}","Repository":"","Size":"72.9MB","Tag":""}}' + ;; + image) + [ "${{2:-}}" = "inspect" ] || exit 96 + printf '%s\n' '{{"Id":"sha256:{OTHER_ID}","Size":72900000}}' + ;; + *) exit 98 ;; +esac +"# + )); + let plan = probe_container_orphans(&docker_target(&runtime)); + let image = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Image) + .expect("image category"); + assert!(!image.evidence_complete); + assert_eq!( + image.issue.as_deref(), + Some("docker-image-size-identity-mismatch") + ); +} + +#[cfg(unix)] +#[test] +fn approved_container_execution_targets_only_the_fingerprinted_candidate() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let (_temp, runtime) = fake_runtime(&format!( + r#" +case "${{1:-}}" in + info) exit 0 ;; + container) + if [ "${{2:-}}" = "ps" ]; then + printf '%s\n' '{{"ID":"{FULL_ID}","State":"exited","Names":[]}}' + exit 0 + fi + if [ "${{2:-}}" = "inspect" ] && [ "${{3:-}}" = "{FULL_ID}" ]; then + printf '%s\n' '[{{"Id":"{FULL_ID}","Created":"2026-08-30T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]' + exit 0 + fi + if [ "${{2:-}}" = "rm" ] && [ "${{3:-}}" = "{FULL_ID}" ] && [ "${{4:-}}" = "" ]; then + printf '%s\n' '{FULL_ID}' + exit 0 + fi + if [ "${{2:-}}" = "prune" ]; then + echo "category-wide prune is not candidate-bound" >&2 + exit 97 + fi + exit 98 + ;; + images|volume|network) exit 0 ;; + *) exit 99 ;; +esac +"# + )); + let target = docker_target(&runtime); + let receipts = private_receipt_dir(); + let plan = probe_container_orphans_with_receipt_dir(&target, receipts.path()); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + let phrase = container + .approval_phrase + .as_deref() + .expect("candidate-bound approval phrase"); + + let execution = execute_container_orphan_prune( + &target, + OrphanCategory::Container, + phrase, + "Remove the exact stopped-container candidate verified by DiskSage.", + 1, + receipts.path(), + ) + .expect("exact candidate removal must succeed"); + + assert!(execution.executed); + assert_eq!(execution.status_code, 0); + assert!(execution.stdout.contains(FULL_ID)); + assert!(!execution.command.iter().any(|part| part == "prune")); + assert!(!execution.command.iter().any(|part| part == FULL_ID)); + assert_eq!( + execution.command.last().map(String::as_str), + Some("") + ); +} + +#[cfg(unix)] +#[test] +fn volume_execution_requires_explicit_ownership_and_preserves_compose_volumes() { + let (_temp, runtime) = fake_runtime( + r#" +case "${1:-}" in + info|container|images|network) exit 0 ;; + volume) + case "${2:-}" in + ls) + printf '%s\n' '[{"Name":"owned-cache"},{"Name":"compose-data"}]' + ;; + inspect) + if [ "${3:-}" = "owned-cache" ]; then + printf '%s\n' '[{"Name":"owned-cache","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}]' + else + printf '%s\n' '[{"Name":"compose-data","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"com.docker.compose.project":"customer-app"}}]' + fi + ;; + rm) + [ "${3:-}" = "owned-cache" ] && [ "${4:-}" = "" ] || exit 97 + printf '%s\n' 'owned-cache' + ;; + *) exit 98 ;; + esac + ;; + *) exit 99 ;; +esac +"#, + ); + let target = docker_target(&runtime); + let receipts = private_receipt_dir(); + let plan = probe_container_orphans_with_receipt_dir(&target, receipts.path()); + let volume = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Volume) + .expect("volume category"); + let evidence = volume.evidence.as_ref().expect("volume evidence"); + assert_eq!(evidence.total_records, 2); + assert_eq!(evidence.candidate_records, 1); + + let execution = execute_container_orphan_prune( + &target, + OrphanCategory::Volume, + volume.approval_phrase.as_deref().unwrap(), + "Remove only the explicitly owned cache volume after fresh reinspection.", + 1, + receipts.path(), + ) + .expect("owned volume removal must succeed"); + assert!(execution.executed); + assert!(execution.stdout.contains("owned-cache")); + assert!(!execution.stdout.contains("compose-data")); +} + +#[cfg(unix)] +#[test] +fn option_shaped_network_name_is_rejected_before_network_inspect() { + let (_temp, runtime) = fake_runtime( + r#" +case "${1:-}" in + info|container|images|volume) exit 0 ;; + network) + if [ "${2:-}" = "ls" ]; then + printf '%s\n' '[{"Driver":"bridge","ID":"net-1","Name":"-danger"}]' + exit 0 + fi + echo "network inspect must not receive an option-shaped runtime name" >&2 + exit 95 + ;; + *) exit 96 ;; +esac +"#, + ); + + let plan = probe_container_orphans(&docker_target(&runtime)); + let network = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Network) + .expect("network category"); + assert!(!network.evidence_complete); + assert_eq!(network.issue.as_deref(), Some("network-invalid-name")); +} + +#[cfg(unix)] +#[test] +fn non_utf8_cli_argument_prints_real_usage_not_a_literal_placeholder() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let opaque = + std::ffi::OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); + let output = Command::new(binary) + .arg(opaque) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("bounded UTF-8 stderr"); + assert!(stderr.contains("Usage: disksage-container-orphan-plan")); + assert!(!stderr.contains("{USAGE}")); + assert!(!stderr.contains("opaque")); +} + +#[test] +fn cli_help_must_be_a_terminal_solo_request() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let output = Command::new(binary) + .args(["--runtime", "docker-native", "--help"]) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("bounded UTF-8 stderr"); + assert!(stderr.contains("help must be used alone")); + assert!(stderr.contains("Usage: disksage-container-orphan-plan")); +} + +#[test] +fn unsupported_runtime_kind_is_not_reflected_in_diagnostics() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let untrusted = "customer-secret-runtime-name"; + let output = Command::new(binary) + .args(["--runtime", untrusted]) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("bounded UTF-8 stderr"); + assert!(stderr.contains("unsupported runtime kind")); + assert!(!stderr.contains(untrusted)); +} + +#[test] +fn singleton_cli_options_reject_duplicates_before_domain_work() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let cases = [ + ( + vec!["--runtime", "docker-native", "--runtime", "docker-native"], + "--runtime may be supplied once", + ), + ( + vec![ + "--runtime", + "docker-colima-context", + "--scope", + "one", + "--scope", + "two", + ], + "--scope may be supplied once", + ), + ( + vec![ + "--runtime", + "docker-native", + "--bin", + "first", + "--bin", + "second", + ], + "--bin may be supplied once", + ), + ( + vec!["--runtime", "docker-native", "--pretty", "--pretty"], + "--pretty may be supplied once", + ), + ]; + + for (args, expected) in cases { + let output = Command::new(binary) + .args(args) + .output() + .expect("run shipped container orphan plan CLI"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("bounded UTF-8 stderr"); + assert!(stderr.contains(expected), "stderr={stderr}"); + } +} + +#[test] +fn runtime_scope_relationship_is_validated_before_domain_work() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let cases = [ + ( + vec!["--runtime", "docker-native", "--scope", "ignored"], + "--scope is not valid for docker-native", + ), + ( + vec!["--runtime", "docker-colima-context"], + "--scope is required for docker-colima-context", + ), + ( + vec!["--runtime", "podman-machine"], + "--scope is required for podman-machine", + ), + ]; + + for (args, expected) in cases { + let output = Command::new(binary) + .args(args) + .output() + .expect("run shipped container orphan plan CLI"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("bounded UTF-8 stderr"); + assert!(stderr.contains(expected), "stderr={stderr}"); + } +} + +#[cfg(unix)] +#[test] +fn podman_machine_cli_defaults_to_the_podman_binary() { + let binary = env!("CARGO_BIN_EXE_disksage-container-orphan-plan"); + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let podman = temp.path().join("podman"); + std::fs::write( + &podman, + r#"#!/bin/sh +set -eu +[ "${1:-}" = "--connection" ] || exit 91 +[ "${2:-}" = "machine-a" ] || exit 92 +shift 2 +case "${1:-}" in + info|container|images|volume|network) exit 0 ;; + *) exit 93 ;; +esac +"#, + ) + .expect("write fake podman runtime"); + let mut permissions = std::fs::metadata(&podman) + .expect("fake podman metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&podman, permissions).expect("make fake podman executable"); + + let output = Command::new(binary) + .env("PATH", temp.path()) + .args(["--runtime", "podman-machine", "--scope", "machine-a"]) + .output() + .expect("run shipped container orphan plan CLI"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let document: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("machine-readable container orphan evidence"); + assert_eq!(document["runtime"]["healthy"], true); + assert_eq!(document["evidence_complete"], true); +} diff --git a/src-tauri/tests/container_orphan_truncated_mutation_receipt.rs b/src-tauri/tests/container_orphan_truncated_mutation_receipt.rs new file mode 100644 index 000000000..ad3ae8329 --- /dev/null +++ b/src-tauri/tests/container_orphan_truncated_mutation_receipt.rs @@ -0,0 +1,98 @@ +#![cfg(unix)] + +use disksage_lib::container_orphan_reclaim::{ + execute_container_orphan_prune, probe_container_orphans_with_receipt_dir, + ContainerRuntimeKind, ContainerRuntimeTarget, OrphanCategory, +}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +fn fake_runtime(script_body: &str) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write(&runtime, format!("#!/bin/sh\nset -eu\n{script_body}\n")) + .expect("write fake runtime"); + let mut permissions = std::fs::metadata(&runtime) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&runtime, permissions).expect("make fake runtime executable"); + (temp, runtime) +} + +fn docker_target(runtime: &Path) -> ContainerRuntimeTarget { + ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime.to_path_buf(), + None, + ) + .expect("valid Docker target") +} + +fn private_receipt_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("temporary receipt directory"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)) + .expect("private receipt permissions"); + dir +} + +#[test] +fn oversized_delete_output_is_reported_as_truncated_indeterminate_evidence() { + const FULL_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let (_temp, runtime) = fake_runtime(&format!( + r#" +case "${{1:-}}" in + info) exit 0 ;; + container) + if [ "${{2:-}}" = "ps" ]; then + printf '%s\n' '{{"ID":"{FULL_ID}","State":"exited","Names":[]}}' + exit 0 + fi + if [ "${{2:-}}" = "inspect" ] && [ "${{3:-}}" = "{FULL_ID}" ]; then + printf '%s\n' '[{{"Id":"{FULL_ID}","Created":"2026-08-30T00:00:00Z","State":{{"Status":"exited"}},"Config":{{"Labels":{{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}}}}}]' + exit 0 + fi + if [ "${{2:-}}" = "rm" ] && [ "${{3:-}}" = "{FULL_ID}" ] && [ "${{4:-}}" = "" ]; then + dd if=/dev/zero bs=1048576 count=2 2>/dev/null + exit 0 + fi + exit 98 + ;; + images|volume|network) exit 0 ;; + *) exit 99 ;; +esac +"# + )); + let target = docker_target(&runtime); + let receipts = private_receipt_dir(); + let plan = probe_container_orphans_with_receipt_dir(&target, receipts.path()); + let container = plan + .categories + .iter() + .find(|category| category.category == OrphanCategory::Container) + .expect("container category"); + let phrase = container + .approval_phrase + .as_deref() + .expect("candidate-bound approval phrase"); + + let execution = execute_container_orphan_prune( + &target, + OrphanCategory::Container, + phrase, + "Verify that oversized mutation output remains explicit in the execution receipt.", + 1, + receipts.path(), + ) + .expect("mutation outcome must be returned as conservative receipt evidence"); + + assert_eq!(execution.status_code, -1, "oversized output makes the mutation outcome indeterminate"); + assert!( + execution.output_truncated, + "discarded mutation output must be represented explicitly in the execution receipt" + ); + assert!( + !execution.stderr.is_empty(), + "the conservative receipt must retain an indeterminate-outcome diagnostic" + ); +} diff --git a/src-tauri/tests/container_orphan_volume_public_authority.rs b/src-tauri/tests/container_orphan_volume_public_authority.rs new file mode 100644 index 000000000..4a11fcc62 --- /dev/null +++ b/src-tauri/tests/container_orphan_volume_public_authority.rs @@ -0,0 +1,73 @@ +use disksage_lib::container_orphan_public::sanitize_plan; +use disksage_lib::container_orphan_reclaim::{ + probe_container_orphans_with_receipt_dir, ContainerRuntimeKind, ContainerRuntimeTarget, + OrphanCategory, +}; + +#[cfg(unix)] +#[test] +fn replaceable_volume_names_never_publish_destructive_authority() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let runtime = temp.path().join("docker"); + std::fs::write( + &runtime, + r#"#!/bin/sh +set -eu +case "${1:-}" in + info|container|images|network) exit 0 ;; + volume) + case "${2:-}" in + ls) + printf '%s\n' '[{"Name":"owned-cache"}]' + ;; + inspect) + printf '%s\n' '[{"Name":"owned-cache","Driver":"local","CreatedAt":"2026-08-30T00:00:00Z","Labels":{"io.contextualwisdomlab.disksage.owner":"disksage","io.contextualwisdomlab.disksage.reclaimable":"true"}}]' + ;; + *) exit 97 ;; + esac + ;; + *) exit 98 ;; +esac +"#, + ) + .expect("write fake runtime"); + std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)) + .expect("make fake runtime executable"); + + let receipt_dir = temp.path().join("receipts"); + std::fs::create_dir(&receipt_dir).expect("create receipt directory"); + std::fs::set_permissions(&receipt_dir, std::fs::Permissions::from_mode(0o700)) + .expect("protect receipt directory"); + + let target = ContainerRuntimeTarget::new( + ContainerRuntimeKind::DockerNative, + runtime, + None, + ) + .expect("valid Docker target"); + let raw_plan = probe_container_orphans_with_receipt_dir(&target, &receipt_dir); + let raw_volume = raw_plan + .categories + .iter() + .find(|entry| entry.category == OrphanCategory::Volume) + .expect("volume category"); + assert!(raw_volume.approval_phrase.is_some(), "RED requires the backend to expose today's unsafe name-bound volume authority"); + + let public_plan = sanitize_plan(raw_plan); + let volume = public_plan + .categories + .iter() + .find(|entry| entry.category == OrphanCategory::Volume) + .expect("volume category"); + + assert!( + volume.approval_phrase.is_none(), + "a reusable volume name cannot authorize deletion of the object that happens to own that name later" + ); + assert!( + volume.prune_command.is_none(), + "read-only volume evidence must not expose a destructive command until deletion is bound to immutable object identity" + ); +} diff --git a/src-tauri/tests/dev_artifact_authority.rs b/src-tauri/tests/dev_artifact_authority.rs new file mode 100644 index 000000000..0d76a1def --- /dev/null +++ b/src-tauri/tests/dev_artifact_authority.rs @@ -0,0 +1,20 @@ +use disksage_lib::dev_artifacts::find_artifacts; + +#[test] +fn unrelated_target_layout_is_not_cleanup_authority() { + let tmp = tempfile::tempdir().expect("create fixture root"); + let target = tmp.path().join("target"); + for child in ["deps", "build", "incremental"] { + std::fs::create_dir_all(target.join("debug").join(child)) + .expect("create cargo-like directory name"); + } + std::fs::write(target.join("customer-owned.sqlite"), b"business data") + .expect("write customer-owned fixture"); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert!( + artifacts.is_empty(), + "directory names alone must not authorize permanent cleanup of an unrelated target tree: {artifacts:?}" + ); +} diff --git a/src-tauri/tests/dev_artifact_editor_extension_regression.rs b/src-tauri/tests/dev_artifact_editor_extension_regression.rs new file mode 100644 index 000000000..99f21c80b --- /dev/null +++ b/src-tauri/tests/dev_artifact_editor_extension_regression.rs @@ -0,0 +1,62 @@ +use disksage_lib::dev_artifacts::find_artifacts; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[test] +fn retained_editor_extension_is_not_reclassified_as_generic_dev_artifact() { + let temp = tempfile::tempdir().expect("temporary test root"); + let extensions = temp.path().join(".vscode/extensions"); + let obsolete = extensions.join("publisher.old-1.0.0"); + let retained = extensions.join("publisher.keep-1.0.0"); + + fs::create_dir_all(&obsolete).expect("obsolete extension directory"); + fs::create_dir_all(retained.join("node_modules/dependency")) + .expect("retained extension dependency tree"); + fs::write(retained.join("package.json"), b"{}") + .expect("retained extension manifest"); + fs::write(retained.join("node_modules/dependency/payload.js"), b"generated") + .expect("retained extension dependency payload"); + fs::write( + extensions.join(".obsolete"), + br#"{"publisher.old-1.0.0":true,"publisher.keep-1.0.0":false}"#, + ) + .expect("native editor lifecycle metadata"); + + let found = find_artifacts(temp.path(), 0, u64::MAX); + + assert!(found.iter().any(|artifact| { + artifact.kind == "vscode-obsolete-extension" + && artifact.path == obsolete.to_string_lossy() + })); + assert!( + !found + .iter() + .any(|artifact| Path::new(&artifact.path).starts_with(&retained)), + "a retained editor extension must remain outside generic development-artifact cleanup authority" + ); +} + +#[test] +fn newly_obsolete_editor_extension_respects_minimum_age() { + let temp = tempfile::tempdir().expect("temporary test root"); + let extensions = temp.path().join(".vscode/extensions"); + let obsolete = extensions.join("publisher.newly-obsolete-1.0.0"); + fs::create_dir_all(&obsolete).expect("obsolete extension directory"); + fs::write( + extensions.join(".obsolete"), + br#"{"publisher.newly-obsolete-1.0.0":true}"#, + ) + .expect("native editor lifecycle metadata"); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_millis() as u64; + + let found = find_artifacts(temp.path(), 30, now_ms); + + assert!( + !found.iter().any(|artifact| artifact.path == obsolete.to_string_lossy()), + "editor lifecycle metadata must not bypass the caller's minimum-age safety boundary" + ); +} diff --git a/src-tauri/tests/dev_artifact_generic_build_guard.rs b/src-tauri/tests/dev_artifact_generic_build_guard.rs new file mode 100644 index 000000000..9c2eb1459 --- /dev/null +++ b/src-tauri/tests/dev_artifact_generic_build_guard.rs @@ -0,0 +1,24 @@ +use disksage_lib::dev_artifacts::find_artifacts; +use std::fs; +use std::path::Path; + +#[test] +fn arbitrary_package_project_does_not_authorize_generic_build_directory() { + let temp = tempfile::tempdir().expect("temporary test root"); + let build = temp.path().join(".build"); + + fs::create_dir_all(&build).expect("generic build directory"); + fs::write(temp.path().join("package.json"), b"{}") + .expect("ordinary package manifest"); + fs::write(build.join("customer-owned.sqlite"), b"not a disposable tool cache") + .expect("customer-owned payload"); + + let found = find_artifacts(temp.path(), 0, u64::MAX); + + assert!( + !found.iter().any(|artifact| { + artifact.kind == ".build" || Path::new(&artifact.path) == build.as_path() + }), + "an arbitrary .build directory beside package.json must remain outside destructive development-artifact cleanup authority" + ); +} diff --git a/src-tauri/tests/dev_artifact_reversible_active_use_timeout.rs b/src-tauri/tests/dev_artifact_reversible_active_use_timeout.rs new file mode 100644 index 000000000..48e6108d5 --- /dev/null +++ b/src-tauri/tests/dev_artifact_reversible_active_use_timeout.rs @@ -0,0 +1,72 @@ +#![cfg(unix)] + +//! Process-level regression for the interactive development-artifact cleanup timeout. +//! +//! Reversible cleanup must fail closed quickly when recursive active-use evidence stalls. The +//! irreversible path may use a longer probe budget, but the GUI-backed Trash path must not inherit +//! that latency. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::time::{Duration, Instant}; + +#[test] +fn reversible_cleanup_caps_stalled_active_use_probe() { + let fixture = tempfile::tempdir().expect("temporary fixture"); + let root = fixture.path().join("workspace"); + let project = root.join("app"); + let artifact = project.join("node_modules"); + let fake_bin = fixture.path().join("bin"); + fs::create_dir_all(&artifact).expect("artifact directory"); + fs::create_dir(&fake_bin).expect("fake bin directory"); + fs::write(project.join("package.json"), b"{}\n").expect("project marker"); + fs::write(artifact.join("payload.bin"), b"generated").expect("artifact payload"); + + let fake_lsof = fake_bin.join("lsof"); + fs::write( + &fake_lsof, + b"#!/bin/sh\nsleep 6\nprintf 'probe failed\\n' >&2\nexit 2\n", + ) + .expect("fake lsof"); + fs::set_permissions(&fake_lsof, fs::Permissions::from_mode(0o755)).expect("fake lsof mode"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let mut path_parts = vec![fake_bin.clone()]; + path_parts.extend(std::env::split_paths(&original_path)); + let child_path = std::env::join_paths(path_parts).expect("PATH composition"); + let journal = fixture.path().join("journal.jsonl"); + + let started = Instant::now(); + let output = Command::new(env!("CARGO_BIN_EXE_disksage-dev-artifacts")) + .env("PATH", child_path) + .arg("--root") + .arg(&root) + .arg("--min-age-days") + .arg("0") + .arg("--journal-path") + .arg(&journal) + .arg("--execute") + .output() + .expect("development-artifact CLI should start"); + let elapsed = started.elapsed(); + + assert!(output.status.success(), "CLI should return a bounded result report"); + assert!( + elapsed < Duration::from_millis(4_500), + "reversible cleanup inherited a destructive-path active-use timeout: {elapsed:?}" + ); + + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON report"); + let result = report["results"] + .as_array() + .and_then(|results| results.first()) + .expect("one cleanup result"); + assert_eq!(result["ok"], false); + assert_eq!( + result["error"], + "development artifact active-use evidence incomplete; rescan before cleanup" + ); + assert!(artifact.exists(), "timeout must fail closed before Trash mutation"); + assert!(!journal.exists(), "timeout must not create mutation evidence"); +} diff --git a/src-tauri/tests/dev_artifacts_uv_venv314.rs b/src-tauri/tests/dev_artifacts_uv_venv314.rs new file mode 100644 index 000000000..8815c1b53 --- /dev/null +++ b/src-tauri/tests/dev_artifacts_uv_venv314.rs @@ -0,0 +1,20 @@ +use disksage_lib::dev_artifacts::find_artifacts; + +#[test] +fn discovers_uv_python_314_environment_in_bare_repository() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(".git"), "gitdir: /private/fixture").expect("git marker"); + let environment = tmp.path().join(".venv314"); + std::fs::create_dir(&environment).expect("environment directory"); + std::fs::write( + environment.join("pyvenv.cfg"), + "home = /opt/python\nversion_info = 3.14.1\n", + ) + .expect("uv pyvenv metadata"); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].kind, ".venv314"); + assert_eq!(artifacts[0].path, environment.to_string_lossy()); +} diff --git a/src-tauri/tests/duplicate_audit_coverage_runtime_contract.rs b/src-tauri/tests/duplicate_audit_coverage_runtime_contract.rs index 0abe5e7e1..db5bf0cab 100644 --- a/src-tauri/tests/duplicate_audit_coverage_runtime_contract.rs +++ b/src-tauri/tests/duplicate_audit_coverage_runtime_contract.rs @@ -9,7 +9,7 @@ use std::process::Command; -const EXPECTED_USAGE: &str = "usage: disksage-duplicate-audit --root ABSOLUTE_PATH [--min-bytes POSITIVE_INTEGER] [--max-entries 1..=1000000] [--private-output ABSOLUTE_NEW_FILE.json]"; +const EXPECTED_USAGE: &str = "usage: disksage-duplicate-audit --root ABSOLUTE_PATH [--min-bytes POSITIVE_INTEGER] [--max-entries 1..=1000000] [--private-output ABSOLUTE_NEW_FILE.json] [--execute --approved-private-report ABSOLUTE_FILE.json --approved-audit-fingerprint HEX64 --confirm EXACT_PHRASE --rationale TEXT]"; const DUPLICATE_AUDIT_SOURCE: &str = include_str!("../src/bin/disksage-duplicate-audit.rs"); #[test] diff --git a/src-tauri/tests/duplicate_audit_help_exit.rs b/src-tauri/tests/duplicate_audit_help_exit.rs index 2a42782b1..9b2f32fcd 100644 --- a/src-tauri/tests/duplicate_audit_help_exit.rs +++ b/src-tauri/tests/duplicate_audit_help_exit.rs @@ -2,7 +2,7 @@ use std::process::{Command, Output}; -const EXPECTED_USAGE: &str = "usage: disksage-duplicate-audit --root ABSOLUTE_PATH [--min-bytes POSITIVE_INTEGER] [--max-entries 1..=1000000] [--private-output ABSOLUTE_NEW_FILE.json]"; +const EXPECTED_USAGE: &str = "usage: disksage-duplicate-audit --root ABSOLUTE_PATH [--min-bytes POSITIVE_INTEGER] [--max-entries 1..=1000000] [--private-output ABSOLUTE_NEW_FILE.json] [--execute --approved-private-report ABSOLUTE_FILE.json --approved-audit-fingerprint HEX64 --confirm EXACT_PHRASE --rationale TEXT]"; /// Require one invalid process result to stay visible without reflecting opaque input. fn assert_invalid_argument_is_bounded(output: Output) { diff --git a/src-tauri/tests/duplicate_audit_stale_photo_report_guard.rs b/src-tauri/tests/duplicate_audit_stale_photo_report_guard.rs new file mode 100644 index 000000000..867294940 --- /dev/null +++ b/src-tauri/tests/duplicate_audit_stale_photo_report_guard.rs @@ -0,0 +1,100 @@ +use disksage_lib::content_digest::ContentDigests; +use disksage_lib::duplicate_audit::{ + exact_duplicate_reclaim_approval_phrase, execute_exact_duplicate_reclaim_from_report, + ExactDuplicateAuditCluster, ExactDuplicateAuditMember, ExactDuplicateAuditReport, + ExactDuplicateProductionMetadata, EXACT_DUPLICATE_AUDIT_VERSION, +}; +use std::collections::BTreeMap; +use std::path::Path; + +fn stale_managed_photo_report() -> ExactDuplicateAuditReport { + let metadata = ExactDuplicateProductionMetadata { + production_time_ms: 1, + production_time_source: "filesystem:modified-fallback".into(), + production_time_confidence: "low".into(), + embedded_production_time_ms: None, + filename_date_ms: None, + title: None, + authors: Vec::new(), + context: Vec::new(), + duration_ms: None, + embedded_evidence: Vec::new(), + metadata_probe_complete: true, + }; + let member = ExactDuplicateAuditMember { + member_fingerprint: "member".into(), + metadata_fingerprint: "metadata".into(), + relative_path: "Library.photoslibrary/original.jpg".into(), + logical_bytes: 4, + filesystem_created_ms: 1, + filesystem_modified_ms: 1, + production_metadata: metadata, + storage_identity_fingerprint: None, + source_stable: true, + path_identity_verified: false, + write_performed: false, + }; + let cluster = ExactDuplicateAuditCluster { + cluster_fingerprint: "cluster".into(), + content_digests: ContentDigests { + blake3: "blake3".into(), + sha256: "sha256".into(), + quick_xor_base64: "quickxor".into(), + }, + logical_bytes_per_file: 4, + file_count: 2, + logical_duplicate_bytes: 8, + logical_redundant_bytes: 4, + distinct_storage_identity_count: None, + physical_reclaimable_bytes: None, + requires_human_canonical_selection: true, + automatic_delete_allowed: false, + members: vec![member], + }; + ExactDuplicateAuditReport { + schema_version: EXACT_DUPLICATE_AUDIT_VERSION, + observed_at_ms: 1, + source_root: "/tmp/disksage-stale-photo-report".into(), + source_scope_fingerprint: "scope".into(), + min_bytes: 1, + max_entries: 10, + evidence_complete: true, + entries_seen: 2, + file_count: 2, + size_collision_candidate_count: 2, + content_hashed_file_count: 2, + cluster_count: 1, + duplicate_file_count: 2, + logical_duplicate_bytes: 8, + logical_redundant_bytes: 4, + physical_reclaimable_bytes: None, + metadata_evidence_complete: true, + production_time_source_counts: BTreeMap::new(), + issue_counts: BTreeMap::new(), + audit_fingerprint: "audit".into(), + production_metadata_evaluated: true, + production_date_policy: "embedded>filename-explicit>filesystem-created>filesystem-modified".into(), + exact_content_match_is_delete_approval: false, + automatic_delete_allowed: false, + mutation_performed: false, + clusters: vec![cluster], + } +} + +#[test] +fn stale_reports_with_managed_photo_members_never_grant_reclaim_authority() { + let report = stale_managed_photo_report(); + assert_eq!(exact_duplicate_reclaim_approval_phrase(&report), None); + assert_eq!( + execute_exact_duplicate_reclaim_from_report( + Path::new(&report.source_root), + &report, + &report.audit_fingerprint, + "stale approval", + "reject stale managed-library authority", + 2, + ) + .expect_err("managed photo-library evidence must fail closed before reclaim validation"), + "duplicate-reclaim-system-managed-photo-library" + ); +} diff --git a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs index ba4c3ed12..ba98af9c3 100644 --- a/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs +++ b/src-tauri/tests/eviction_cli_duplicate_singleton_process.rs @@ -9,7 +9,7 @@ use std::process::Command; use std::sync::OnceLock; const BINARIES: [&str; 2] = [ - "disksage-icloud-local-eviction-batch", + "disksage-cloud-local-eviction-batch", "disksage-incomplete-download-destination-plan", ]; diff --git a/src-tauri/tests/git_clone_reclaim_cli_contract.rs b/src-tauri/tests/git_clone_reclaim_cli_contract.rs new file mode 100644 index 000000000..2926d2501 --- /dev/null +++ b/src-tauri/tests/git_clone_reclaim_cli_contract.rs @@ -0,0 +1,66 @@ +use std::{fs, path::Path, process::Command}; + +fn git(repository: &Path, arguments: &[&str]) { + let output = Command::new("git") + .current_dir(repository) + .args(arguments) + .output() + .expect("Git should be available for the stale-clone CLI fixture"); + assert!( + output.status.success(), + "git {arguments:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn stale_clone_reclaim_cli_uses_the_shared_pull_request_flag_contract() { + let output = Command::new(env!("CARGO_BIN_EXE_disksage-git-clone-reclaim")) + .arg("--help") + .output() + .expect("run shipped git clone reclaim CLI"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 CLI help"); + assert!(stdout.contains("--include-closed-pull-requests")); + assert!(stdout.contains("--stale-open-pull-request-cutoff-ms")); + assert!(!stdout.contains("--stale-open-cutoff-ms")); +} + +#[test] +fn stale_clone_plan_stays_within_the_public_local_command_timeout() { + let fixture = tempfile::tempdir().expect("temporary fixture directory"); + let repository = fixture.path().join("repository"); + fs::create_dir(&repository).expect("repository directory"); + git(&repository, &["init", "-q", "-b", "main"]); + git( + &repository, + &["config", "user.email", "disksage@example.invalid"], + ); + git(&repository, &["config", "user.name", "DiskSage Test"]); + fs::write(repository.join("tracked.txt"), b"tracked\n").expect("tracked fixture"); + git(&repository, &["add", "tracked.txt"]); + git(&repository, &["commit", "-q", "-m", "fixture"]); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-git-clone-reclaim")) + .arg("--repository-root") + .arg(&repository) + .args(["--reference-ref", "HEAD"]) + .output() + .expect("run shipped git clone reclaim plan"); + let stderr = String::from_utf8(output.stderr).expect("UTF-8 stderr"); + + assert!( + output.status.success(), + "stale-clone plan must reach its read-only product boundary: {stderr}" + ); + assert!(!stderr.contains("git-worktree-command-timeout-out-of-bounds")); + let payload: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("plan output should be JSON"); + assert_eq!( + payload["schema_kind"], + serde_json::Value::String("disksage.git-clone-reclaim-plan".into()) + ); + assert_eq!(payload["filesystem_mutation_executed"], false); +} diff --git a/src-tauri/tests/git_worktree_audit_help_exit.rs b/src-tauri/tests/git_worktree_audit_help_exit.rs index 36bba49e3..0f661c1dd 100644 --- a/src-tauri/tests/git_worktree_audit_help_exit.rs +++ b/src-tauri/tests/git_worktree_audit_help_exit.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -const EXPECTED_USAGE: &str = "usage: disksage-git-worktree-audit --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] [--private-output NEW_ABSOLUTE_JSON_PATH] [--command-timeout-ms N] [--size-scan-timeout-ms N] [--max-worktrees N] [--max-entries-per-worktree N] [--max-active-pids N]"; +const EXPECTED_USAGE: &str = "usage: disksage-git-worktree-audit --repository-root ABSOLUTE_PATH --reference-ref REF [--reference-ref REF ...] [--include-closed-pull-requests] [--stale-open-pull-request-cutoff-ms N] [--private-output NEW_ABSOLUTE_JSON_PATH] [--command-timeout-ms N] [--size-scan-timeout-ms N] [--max-worktrees N] [--max-entries-per-worktree N] [--max-active-pids N]"; const OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; fn binary_path() -> &'static Path { @@ -123,8 +123,8 @@ fn primary_worktree_audit_keeps_machine_json_path_redacted_and_read_only() { assert!(output.stderr.is_empty()); let stdout = String::from_utf8(output.stdout).expect("audit stdout should remain UTF-8 JSON"); let report: serde_json::Value = serde_json::from_str(&stdout).expect("audit stdout should be JSON"); - assert_eq!(report["schema_kind"], "disksage.git-worktree-audit/v2"); - assert_eq!(report["version"], 2); + assert_eq!(report["schema_kind"], "disksage.git-worktree-audit/v4"); + assert_eq!(report["version"], 4); assert_eq!(report["worktree_count"], 1); assert_eq!(report["removal_candidate_count"], 0); assert_eq!(report["preserved_count"], 1); diff --git a/src-tauri/tests/git_worktree_audit_shared_github_timeout.rs b/src-tauri/tests/git_worktree_audit_shared_github_timeout.rs new file mode 100644 index 000000000..9c49b65cb --- /dev/null +++ b/src-tauri/tests/git_worktree_audit_shared_github_timeout.rs @@ -0,0 +1,90 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn git(repository: &Path, arguments: &[&str]) { + let output = Command::new("git") + .current_dir(repository) + .args(arguments) + .output() + .expect("Git should be available"); + assert!( + output.status.success(), + "git {arguments:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn initialized_repository() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("temporary directory should be created"); + let repository = temp.path().join("repository"); + fs::create_dir(&repository).expect("repository directory should be created"); + git(&repository, &["init", "-q", "-b", "main"]); + git( + &repository, + &["config", "user.email", "coverage@example.invalid"], + ); + git(&repository, &["config", "user.name", "DiskSage Test"]); + fs::write(repository.join("tracked.txt"), b"tracked\n") + .expect("tracked fixture should be written"); + git(&repository, &["add", "tracked.txt"]); + git(&repository, &["commit", "-q", "-m", "fixture"]); + (temp, repository) +} + +#[test] +fn one_timeout_bounds_the_complete_github_evidence_phase() { + let (temp, repository) = initialized_repository(); + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).expect("fake bin directory should be created"); + let gh_path = bin_dir.join("gh"); + fs::write( + &gh_path, + r#"#!/bin/sh +set -eu +case "$*" in + "api --paginate repos/{owner}/{repo}/pulls?state=all&per_page=100 --jq "*) + if [ -e "$PWD/.gh-pull-list-called" ]; then sleep 1; else touch "$PWD/.gh-pull-list-called"; sleep 0.1; fi + printf '' ;; + "api repos/{owner}/{repo} --jq .full_name"*) sleep 1; printf 'ContextualWisdomLab/disksage\n' ;; + "api -X GET search/issues "*) printf '{"total_count":0,"items":[]}\n' ;; + *) printf 'unexpected fake gh invocation\n' >&2; exit 9 ;; +esac +"#, + ) + .expect("fake gh should be written"); + let mut permissions = fs::metadata(&gh_path) + .expect("fake gh metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&gh_path, permissions).expect("fake gh should be executable"); + + let mut paths = vec![bin_dir]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + let path = std::env::join_paths(paths).expect("PATH should be joinable"); + let output = Command::new(env!("CARGO_BIN_EXE_disksage-git-worktree-audit")) + .env("PATH", path) + .arg("--repository-root") + .arg(&repository) + .args([ + "--reference-ref", + "HEAD", + "--include-closed-pull-requests", + "--command-timeout-ms", + "800", + ]) + .output() + .expect("Git worktree audit binary should start"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8(output.stderr).expect("stderr should remain UTF-8"), + "DiskSage Git worktree audit: github-exact-pr-membership-timeout\n" + ); +} diff --git a/src-tauri/tests/git_worktree_closed_pr_search_cap_contract.rs b/src-tauri/tests/git_worktree_closed_pr_search_cap_contract.rs new file mode 100644 index 000000000..257927b9c --- /dev/null +++ b/src-tauri/tests/git_worktree_closed_pr_search_cap_contract.rs @@ -0,0 +1,99 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::github_closed_pull_request_heads; +use serde_json::json; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn paginated_rest_result_above_supported_bound_fails_closed() { + let temp = tempfile::tempdir().expect("temporary repository root"); + Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(temp.path()) + .status() + .expect("initialize fixture repository"); + fs::write(temp.path().join("tracked.txt"), b"fixture\n").expect("write tracked fixture"); + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(temp.path()) + .status() + .expect("stage fixture"); + Command::new("git") + .args([ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ]) + .current_dir(temp.path()) + .status() + .expect("commit fixture"); + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).expect("create fake bin directory"); + + let output_path = temp.path().join("closed-prs.json"); + let records: Vec<_> = (0..10_001u64) + .map(|index| { + json!({ + "number": index + 1, + "headRefName": format!("closed-{index}"), + "headRefOid": format!("{index:040x}"), + "isCrossRepository": false, + "createdAt": "2026-01-01T00:00:00Z", + "state": "CLOSED" + }) + }) + .collect(); + fs::write( + &output_path, + records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n"), + ) + .expect("write fake GitHub response"); + + let gh_path = bin_dir.join("gh"); + fs::write( + &gh_path, + "#!/bin/sh\nset -eu\ncase \" $* \" in\n *' api --paginate repos/{owner}/{repo}/pulls?state=all&per_page=100 --jq '*) cat \"$DISKSAGE_FAKE_GH_OUTPUT\" ;;\n *) exit 64 ;;\nesac\n", + ) + .expect("write fake gh executable"); + let mut permissions = fs::metadata(&gh_path) + .expect("fake gh metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&gh_path, permissions).expect("make fake gh executable"); + + let original_path = std::env::var_os("PATH"); + let joined_path = match original_path.as_ref() { + Some(existing) => { + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(existing)); + std::env::join_paths(paths).expect("join PATH") + } + None => bin_dir.into_os_string(), + }; + std::env::set_var("PATH", &joined_path); + std::env::set_var("DISKSAGE_FAKE_GH_OUTPUT", &output_path); + + let result = github_closed_pull_request_heads(temp.path(), 5_000); + + match original_path { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + std::env::remove_var("DISKSAGE_FAKE_GH_OUTPUT"); + + assert_eq!( + result.expect_err("oversized REST evidence must fail closed"), + "github-closed-pr-count-exceeds-limit" + ); +} diff --git a/src-tauri/tests/git_worktree_command_timeout_boundary.rs b/src-tauri/tests/git_worktree_command_timeout_boundary.rs new file mode 100644 index 000000000..4359dd41d --- /dev/null +++ b/src-tauri/tests/git_worktree_command_timeout_boundary.rs @@ -0,0 +1,76 @@ +//! Production-boundary regression for Git worktree subprocess deadlines. +//! +//! A caller may have a long overall audit budget, but that budget must never become the deadline +//! for one local `git` subprocess. The fixture places a deliberately blocking `git` on PATH and +//! proves an oversized per-command deadline is rejected before that child can hold the audit open. + +#![cfg(target_os = "linux")] + +use disksage_lib::git_worktree::{audit_git_worktrees, GitWorktreeAuditOptions}; +use std::{ + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + sync::Mutex, + time::{Duration, Instant}, +}; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvRestore { + key: &'static str, + value: Option, +} + +impl EnvRestore { + fn capture(key: &'static str) -> Self { + Self { + key, + value: std::env::var_os(key), + } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + match self.value.take() { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } +} + +#[test] +fn hour_scale_budget_is_rejected_before_blocking_git_subprocess_starts() { + let _guard = ENV_LOCK.lock().expect("environment test lock"); + let path_restore = EnvRestore::capture("PATH"); + + let fixture = tempfile::tempdir().expect("fixture directory"); + let bin_dir = fixture.path().join("bin"); + let repository = fixture.path().join("repository"); + fs::create_dir(&bin_dir).expect("bin directory"); + fs::create_dir(&repository).expect("repository directory"); + + let fake_git = bin_dir.join("git"); + fs::write(&fake_git, "#!/bin/sh\nsleep 1\nexit 0\n").expect("blocking git fixture"); + fs::set_permissions(&fake_git, fs::Permissions::from_mode(0o700)).expect("git executable mode"); + unsafe { std::env::set_var("PATH", &bin_dir) }; + + let options = GitWorktreeAuditOptions { + command_timeout_ms: 3_600_000, + ..GitWorktreeAuditOptions::default() + }; + let started = Instant::now(); + let result = audit_git_worktrees(&repository, &["HEAD".into()], options, 7_001); + let elapsed = started.elapsed(); + drop(path_restore); + + assert_eq!( + result.unwrap_err(), + "git-worktree-command-timeout-out-of-bounds" + ); + assert!( + elapsed < Duration::from_millis(500), + "oversized local command budget reached the blocking git child: {elapsed:?}" + ); +} diff --git a/src-tauri/tests/git_worktree_merged_pr_branch_scope.rs b/src-tauri/tests/git_worktree_merged_pr_branch_scope.rs new file mode 100644 index 000000000..1cf473d25 --- /dev/null +++ b/src-tauri/tests/git_worktree_merged_pr_branch_scope.rs @@ -0,0 +1,110 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::github_closed_pull_request_heads; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::sync::Mutex; + +static PATH_ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn init_repository(path: &std::path::Path) -> String { + Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(path) + .status() + .expect("initialize fixture repository"); + fs::write(path.join("tracked.txt"), b"fixture\n").expect("write fixture"); + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(path) + .status() + .expect("stage fixture"); + Command::new("git") + .args([ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ]) + .current_dir(path) + .status() + .expect("commit fixture"); + String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(path) + .output() + .expect("resolve fixture head") + .stdout, + ) + .unwrap() + .trim() + .to_string() +} + +#[test] +fn closed_and_merged_heads_use_one_paginated_rest_request_with_open_veto() { + let _env_guard = PATH_ENV_LOCK.lock().expect("serialize PATH mutation"); + let temp = tempfile::tempdir().expect("temporary repository root"); + let head = init_repository(temp.path()); + let output_path = temp.path().join("pull-requests.json"); + fs::write( + &output_path, + format!( + "{{\"number\":1,\"headRefName\":\"closed-work\",\"headRefOid\":\"{head}\",\"isCrossRepository\":false,\"createdAt\":\"2026-01-01T00:00:00Z\",\"state\":\"CLOSED\"}}\n{{\"number\":2,\"headRefName\":\"merged-work\",\"headRefOid\":\"{head}\",\"isCrossRepository\":false,\"createdAt\":\"2026-01-01T00:00:00Z\",\"state\":\"MERGED\"}}\n{{\"number\":3,\"headRefName\":\"closed-work\",\"headRefOid\":\"{head}\",\"isCrossRepository\":false,\"createdAt\":\"2026-01-01T00:00:00Z\",\"state\":\"OPEN\"}}\n" + ), + ) + .expect("write fake REST response"); + + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).expect("create fake bin directory"); + let gh_path = bin_dir.join("gh"); + fs::write( + &gh_path, + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DISKSAGE_FAKE_GH_LOG\"\ncase \" $* \" in\n *' api --paginate repos/{owner}/{repo}/pulls?state=all&per_page=100 --jq '*) cat \"$DISKSAGE_FAKE_GH_OUTPUT\" ;;\n *) exit 64 ;;\nesac\n", + ) + .expect("write fake gh executable"); + let mut permissions = fs::metadata(&gh_path) + .expect("fake gh metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&gh_path, permissions).expect("make fake gh executable"); + + let log_path = temp.path().join("gh.log"); + let original_path = std::env::var_os("PATH"); + let mut paths = vec![bin_dir]; + if let Some(existing) = original_path.as_ref() { + paths.extend(std::env::split_paths(existing)); + } + std::env::set_var("PATH", std::env::join_paths(paths).expect("join PATH")); + std::env::set_var("DISKSAGE_FAKE_GH_OUTPUT", &output_path); + std::env::set_var("DISKSAGE_FAKE_GH_LOG", &log_path); + + let result = github_closed_pull_request_heads(temp.path(), 5_000); + + match original_path { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + std::env::remove_var("DISKSAGE_FAKE_GH_OUTPUT"); + std::env::remove_var("DISKSAGE_FAKE_GH_LOG"); + + assert_eq!( + result.expect("paginated REST evidence"), + [("refs/heads/merged-work".to_string(), head)] + .into_iter() + .collect() + ); + assert_eq!( + fs::read_to_string(log_path) + .expect("read fake gh log") + .lines() + .count(), + 1 + ); +} diff --git a/src-tauri/tests/git_worktree_open_pr_veto.rs b/src-tauri/tests/git_worktree_open_pr_veto.rs new file mode 100644 index 000000000..0694dafeb --- /dev/null +++ b/src-tauri/tests/git_worktree_open_pr_veto.rs @@ -0,0 +1,91 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::github_closed_pull_request_heads; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::sync::Mutex; + +static PATH_ENV_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn exact_open_pull_request_vetoes_historical_merged_worktree_authority() { + let _env_guard = PATH_ENV_LOCK.lock().expect("serialize PATH mutation"); + let temp = tempfile::tempdir().expect("temporary fixture root"); + let repository = temp.path().join("repository"); + fs::create_dir_all(&repository).expect("create repository root"); + Command::new("git") + .args(["init", "-q", "-b", "shared-head"]) + .current_dir(&repository) + .status() + .expect("initialize fixture repository"); + fs::write(repository.join("tracked.txt"), b"fixture\n").expect("write fixture"); + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(&repository) + .status() + .expect("stage fixture"); + Command::new("git") + .args([ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ]) + .current_dir(&repository) + .status() + .expect("commit fixture"); + let head = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&repository) + .output() + .expect("resolve fixture head") + .stdout, + ) + .unwrap(); + let head = head.trim(); + + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).expect("create fake bin directory"); + let gh_path = bin_dir.join("gh"); + fs::write( + &gh_path, + format!( + r#"#!/bin/sh +set -eu +case " $* " in + *' api --paginate repos/{{owner}}/{{repo}}/pulls?state=all&per_page=100 --jq '*) printf '%s\n' \ + '{{"headRefName":"shared-head","headRefOid":"{head}","isCrossRepository":false,"createdAt":"2026-01-01T00:00:00Z","state":"MERGED"}}' \ + '{{"headRefName":"shared-head","headRefOid":"{head}","isCrossRepository":false,"createdAt":"2026-01-02T00:00:00Z","state":"OPEN"}}' ;; + *) exit 64 ;; +esac +"# + ), + ) + .expect("write fake gh executable"); + let mut permissions = fs::metadata(&gh_path).expect("fake gh metadata").permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&gh_path, permissions).expect("make fake gh executable"); + + let original_path = std::env::var_os("PATH"); + let mut paths = vec![bin_dir]; + if let Some(existing) = original_path.as_ref() { + paths.extend(std::env::split_paths(existing)); + } + std::env::set_var("PATH", std::env::join_paths(paths).expect("join PATH")); + let result = github_closed_pull_request_heads(&repository, 5_000); + match original_path { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + assert!( + result.expect("bounded pull-request evidence").is_empty(), + "an exact current open PR must veto historical merged authority for the same branch/head" + ); +} diff --git a/src-tauri/tests/git_worktree_pr_commit_cap.rs b/src-tauri/tests/git_worktree_pr_commit_cap.rs new file mode 100644 index 000000000..65a7da0f5 --- /dev/null +++ b/src-tauri/tests/git_worktree_pr_commit_cap.rs @@ -0,0 +1,105 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::{github_pull_request_commit_membership, GitWorktreeAuditOptions}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::sync::Mutex; + +static PATH_ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn init_repository(path: &std::path::Path) -> String { + fs::create_dir_all(path).unwrap(); + Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(path) + .status() + .unwrap(); + fs::write(path.join("tracked.txt"), b"fixture\n").unwrap(); + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(path) + .status() + .unwrap(); + Command::new("git") + .args([ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ]) + .current_dir(path) + .status() + .unwrap(); + String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(path) + .output() + .unwrap() + .stdout, + ) + .unwrap() + .trim() + .to_string() +} + +#[test] +fn commit_list_above_ten_thousand_is_incomplete_evidence() { + let _guard = PATH_ENV_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + let head = init_repository(&repository); + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let gh = bin_dir.join("gh"); + fs::write( + &gh, + format!( + r#"#!/bin/sh +set -eu +case " $* " in + *' api repos/{{owner}}/{{repo}} --jq .full_name '*) printf '%s\n' 'ContextualWisdomLab/disksage' ;; + *' api -X GET search/issues -f q='*) printf '%s' '{{"total_count":1,"items":[{{"number":1,"state":"open","repository_url":"https://api.github.com/repos/ContextualWisdomLab/disksage"}}]}}' ;; + *' api --paginate repos/ContextualWisdomLab/disksage/pulls/1/commits?per_page=100 '*) + printf '%s\n' '{head}' + i=1 + while [ "$i" -lt 10001 ]; do + printf '%040x\n' "$i" + i=$((i + 1)) + done + ;; + *) exit 64 ;; +esac +"# + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&gh).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&gh, permissions).unwrap(); + + let original_path = std::env::var_os("PATH"); + let mut paths = vec![bin_dir]; + if let Some(existing) = original_path.as_ref() { + paths.extend(std::env::split_paths(existing)); + } + std::env::set_var("PATH", std::env::join_paths(paths).unwrap()); + let result = github_pull_request_commit_membership( + &repository, + GitWorktreeAuditOptions { + command_timeout_ms: 5_000, + ..GitWorktreeAuditOptions::default() + }, + ); + match original_path { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + assert_eq!(result.unwrap_err(), "github-pr-commit-count-exceeds-limit"); +} diff --git a/src-tauri/tests/git_worktree_remove_audit_limits.rs b/src-tauri/tests/git_worktree_remove_audit_limits.rs new file mode 100644 index 000000000..d2cb28ff8 --- /dev/null +++ b/src-tauri/tests/git_worktree_remove_audit_limits.rs @@ -0,0 +1,40 @@ +#![cfg(feature = "cloud-cli")] + +use std::process::Command; + +#[test] +fn removal_cli_accepts_the_exact_audit_resource_limits() { + let repository_root = tempfile::tempdir().expect("temporary repository root must be created"); + let record_root = tempfile::tempdir().expect("temporary record root must be created"); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-git-worktree-remove")) + .arg("--repository-root") + .arg(repository_root.path()) + .args(["--reference-ref", "origin/develop"]) + .args(["--command-timeout-ms", "1234"]) + .args(["--size-scan-timeout-ms", "5678"]) + .args(["--max-worktrees", "17"]) + .args(["--max-entries-per-worktree", "2345"]) + .args(["--max-active-pids", "9"]) + .args([ + "--approved-removal-plan-fingerprint", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ]) + .args([ + "--confirmation-exact-approval-phrase", + "DiskSage stale worktree approval", + ]) + .args(["--reviewed-by", "human:test"]) + .args(["--rationale", "operator reviewed the exact bounded audit"]) + .arg("--record-root") + .arg(record_root.path()) + .output() + .expect("worktree removal CLI must launch"); + + assert_ne!( + output.status.code(), + Some(64), + "resource limits accepted by the audit CLI must also cross the removal CLI parser; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/src-tauri/tests/git_worktree_schema_version.rs b/src-tauri/tests/git_worktree_schema_version.rs new file mode 100644 index 000000000..d2974a237 --- /dev/null +++ b/src-tauri/tests/git_worktree_schema_version.rs @@ -0,0 +1,83 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::{ + audit_git_worktrees_with_pull_request_membership, GitWorktreeAuditOptions, + PullRequestCommitMembership, +}; +use std::collections::BTreeSet; +use std::fs; +use std::process::Command; + +fn init_repository(path: &std::path::Path) { + fs::create_dir_all(path).unwrap(); + assert!(Command::new("git") + .args(["init", "-q", "-b", "main"]) + .current_dir(path) + .status() + .unwrap() + .success()); + fs::write(path.join("tracked.txt"), b"fixture\n").unwrap(); + assert!(Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(path) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args([ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ]) + .current_dir(path) + .status() + .unwrap() + .success()); +} + +#[test] +fn pull_request_membership_report_matches_shared_v4_contract() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../contracts/git-worktree-audit-v4.json" + )) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + init_repository(&repository); + + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &BTreeSet::new(), + &std::collections::BTreeMap::new(), + &PullRequestCommitMembership::default(), + None, + GitWorktreeAuditOptions::default(), + 42, + ) + .unwrap(); + + assert_eq!( + report.schema_kind, + contract["schema_kind"].as_str().unwrap() + ); + assert_eq!(report.version, contract["version"].as_u64().unwrap() as u32); + + let serialized_entry = serde_json::to_value(report.entries.first().expect("audit entry")) + .expect("serialize audit entry"); + for field in contract["entry_membership_fields"] + .as_array() + .expect("membership field list") + { + let field = field.as_str().expect("membership field name"); + assert!( + serialized_entry.get(field).is_some(), + "runtime audit entry is missing shared contract field {field}" + ); + } +} diff --git a/src-tauri/tests/git_worktree_stale_open_membership.rs b/src-tauri/tests/git_worktree_stale_open_membership.rs new file mode 100644 index 000000000..7fdf78df7 --- /dev/null +++ b/src-tauri/tests/git_worktree_stale_open_membership.rs @@ -0,0 +1,192 @@ +#![cfg(unix)] + +use disksage_lib::git_worktree::{ + audit_git_worktrees_with_pull_request_membership, ClosedPullRequestHeads, + GitWorktreeAuditOptions, GitWorktreeDisposition, PullRequestCommitMembership, + StaleOpenPullRequestHeads, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::process::Command; + +fn git(repository: &std::path::Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(repository) + .status() + .expect("run git fixture command"); + assert!(status.success(), "git fixture command failed: {args:?}"); +} + +#[test] +fn stale_open_head_exempts_only_cutoff_authorized_pull_request_membership() { + let temp = tempfile::tempdir().expect("temporary fixture root"); + let repository = temp.path().join("repository"); + let stale_worktree = temp.path().join("stale-open-worktree"); + fs::create_dir_all(&repository).expect("create repository root"); + + git(&repository, &["init", "-q", "-b", "main"]); + fs::write(repository.join("tracked.txt"), b"base\n").unwrap(); + git(&repository, &["add", "tracked.txt"]); + git( + &repository, + &[ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "base", + ], + ); + let base = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&repository) + .output() + .unwrap() + .stdout, + ) + .unwrap() + .trim() + .to_owned(); + + git( + &repository, + &[ + "worktree", + "add", + "-q", + "-b", + "stale-open", + stale_worktree.to_str().unwrap(), + &base, + ], + ); + fs::write(repository.join("tracked.txt"), b"base\nretained tip\n").unwrap(); + git(&repository, &["add", "tracked.txt"]); + git( + &repository, + &[ + "-c", + "user.name=DiskSage Test", + "-c", + "user.email=disksage@example.invalid", + "commit", + "-q", + "-m", + "retained tip", + ], + ); + + let stale_pull_request_number = 101u64; + let second_stale_pull_request_number = 102u64; + let other_open_pull_request_number = 202u64; + let head_binding = ("refs/heads/stale-open".to_string(), base.clone()); + let stale_heads = StaleOpenPullRequestHeads::from([( + head_binding.clone(), + BTreeSet::from([stale_pull_request_number]), + )]); + let mut own_membership = PullRequestCommitMembership::default(); + own_membership.open.insert( + base.clone(), + BTreeSet::from([stale_pull_request_number]), + ); + + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &ClosedPullRequestHeads::new(), + &stale_heads, + &own_membership, + Some(1), + GitWorktreeAuditOptions::default(), + 2, + ) + .expect("audit stale-open worktree with only cutoff-authorized open membership"); + + let entry = report + .entries + .iter() + .find(|entry| entry.branch.as_deref() == Some("refs/heads/stale-open")) + .expect("stale-open worktree entry"); + assert!(entry.stale_open_pull_request_head); + assert!( + !entry.open_pull_request_commit, + "cutoff-authorized stale PR membership must not veto its own cleanup authority" + ); + assert_eq!(entry.disposition, GitWorktreeDisposition::RemovalCandidate); + + let mut independent_open_membership = PullRequestCommitMembership::default(); + independent_open_membership.open.insert( + base.clone(), + BTreeSet::from([ + stale_pull_request_number, + other_open_pull_request_number, + ]), + ); + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &ClosedPullRequestHeads::new(), + &stale_heads, + &independent_open_membership, + Some(1), + GitWorktreeAuditOptions::default(), + 3, + ) + .expect("audit stale-open worktree with independent open membership"); + + let entry = report + .entries + .iter() + .find(|entry| entry.branch.as_deref() == Some("refs/heads/stale-open")) + .expect("stale-open worktree entry"); + assert!(entry.open_pull_request_commit); + assert!(entry + .blockers + .iter() + .any(|blocker| blocker == "open-pull-request-commit")); + assert_eq!(entry.disposition, GitWorktreeDisposition::Preserve); + + let all_stale_heads = StaleOpenPullRequestHeads::from([( + head_binding, + BTreeSet::from([ + stale_pull_request_number, + second_stale_pull_request_number, + ]), + )]); + let mut all_stale_membership = PullRequestCommitMembership::default(); + all_stale_membership.open = BTreeMap::from([( + base, + BTreeSet::from([ + stale_pull_request_number, + second_stale_pull_request_number, + ]), + )]); + let report = audit_git_worktrees_with_pull_request_membership( + &repository, + &["refs/heads/main".into()], + &ClosedPullRequestHeads::new(), + &all_stale_heads, + &all_stale_membership, + Some(1), + GitWorktreeAuditOptions::default(), + 4, + ) + .expect("audit worktree shared only by cutoff-authorized stale pull requests"); + + let entry = report + .entries + .iter() + .find(|entry| entry.branch.as_deref() == Some("refs/heads/stale-open")) + .expect("shared stale-open worktree entry"); + assert!(entry.stale_open_pull_request_head); + assert!( + !entry.open_pull_request_commit, + "multiple cutoff-authorized stale PRs sharing one exact head must not veto one another" + ); + assert_eq!(entry.disposition, GitWorktreeDisposition::RemovalCandidate); +} diff --git a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs index 84a698ee2..f137b329c 100644 --- a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs +++ b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs @@ -1,4 +1,4 @@ -//! Contract tests for the buyer-visible iCloud batch safety documentation. +//! Contract tests for the operator-visible cloud batch safety documentation. //! //! These checks keep the operational claims, evidence boundary, standards mapping, and APA 7th //! references reviewable alongside the Rust behavior they describe. diff --git a/src-tauri/tests/naruon_active_fileprovider_transfer.rs b/src-tauri/tests/naruon_active_fileprovider_transfer.rs index f7c1d4244..20092df95 100644 --- a/src-tauri/tests/naruon_active_fileprovider_transfer.rs +++ b/src-tauri/tests/naruon_active_fileprovider_transfer.rs @@ -79,6 +79,8 @@ fn active_transfer_health() -> IcloudSyncHealthReport { no_progress_create_count: 0, materialization_failure_count: 0, staged_item_missing_count: 0, + stale_error_count: 0, + oldest_stale_error_age_ms: None, sync_excluded_filename_count: 0, sync_excluded_root_count: 0, active_upload_count: 1, diff --git a/src-tauri/tests/naruon_locked_fileprovider_item.rs b/src-tauri/tests/naruon_locked_fileprovider_item.rs index ff3a0d4c3..da85278c1 100644 --- a/src-tauri/tests/naruon_locked_fileprovider_item.rs +++ b/src-tauri/tests/naruon_locked_fileprovider_item.rs @@ -82,6 +82,8 @@ fn locked_item_health() -> IcloudSyncHealthReport { no_progress_create_count: 0, materialization_failure_count: 0, staged_item_missing_count: 0, + stale_error_count: 0, + oldest_stale_error_age_ms: None, sync_excluded_filename_count: 0, sync_excluded_root_count: 0, active_upload_count: 0, diff --git a/src-tauri/tests/onedrive_eviction_admission_boundary.rs b/src-tauri/tests/onedrive_eviction_admission_boundary.rs new file mode 100644 index 000000000..aba8a2096 --- /dev/null +++ b/src-tauri/tests/onedrive_eviction_admission_boundary.rs @@ -0,0 +1,56 @@ +//! Regression contract for OneDrive local-space recovery under provider-wide backlog. + +#[test] +fn onedrive_unpin_does_not_reuse_new_copy_admission() { + let source = include_str!("../src/cloud_local_eviction.rs"); + let function = source + .split_once("fn request_native_icloud_eviction") + .expect("native eviction function") + .1 + .split_once("fn observe_post_eviction") + .expect("native eviction function boundary") + .0; + + assert!(function.contains("unpin_onedrive_local_copy")); + assert!(!function.contains("inspect_new_copy_admission")); + assert!(!function.contains("require_new_copy_admission")); +} + +#[test] +fn onedrive_unpin_has_a_bounded_graceful_term_fallback() { + let source = include_str!("../src/provider_recovery.rs"); + let function = source + .split_once("pub(crate) fn unpin_onedrive_local_copy") + .expect("OneDrive unpin function") + .1 + .split_once("pub fn recover_provider_client") + .expect("OneDrive unpin function boundary") + .0; + + assert!(function.contains("request_quit(\"OneDrive\")")); + assert!(function.contains("request_graceful_term(\"OneDrive\")")); + assert!(function.contains("provider-recovery-quit-timeout")); + assert!(function.contains("require_primary_runtime_observation")); + assert!(!function.contains("\"/getpin\"")); + assert!(function.contains("\"/unpin\"")); + + let graceful_term = source + .split_once("fn request_graceful_term") + .expect("graceful termination helper") + .1 + .split_once("pub fn recover_provider_client") + .expect("graceful termination helper boundary") + .0; + assert!(graceful_term.contains("\"-TERM\"")); + assert!(!graceful_term.contains("\"-KILL\"")); + + let quit = source + .split_once("fn request_quit") + .expect("quit helper") + .1 + .split_once("fn request_graceful_term") + .expect("quit helper boundary") + .0; + assert!(quit.contains("require_primary_runtime_observation")); + assert!(!quit.contains("require_runtime_observation")); +} diff --git a/src-tauri/tests/onedrive_primary_runtime_boundary.rs b/src-tauri/tests/onedrive_primary_runtime_boundary.rs new file mode 100644 index 000000000..ba6adf7e6 --- /dev/null +++ b/src-tauri/tests/onedrive_primary_runtime_boundary.rs @@ -0,0 +1,75 @@ +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_client_runtime::assess_provider_client_runtime; + +#[test] +fn onedrive_helper_does_not_force_a_quit_when_the_primary_app_is_stopped() { + // The broad copy-prerequisite observation intentionally accepts the sync helper. + let helper_only = b"Finder\nOneDrive Sync Service\n"; + let broad = assess_provider_client_runtime(CloudProvider::Onedrive, Some(helper_only), 42); + assert_eq!(broad.runtime_observed, Some(true)); + + // The destructive local-eviction boundary must make its quit decision from the narrower + // primary-process observation, so a lingering helper cannot turn an already-stopped app into + // a failing AppleScript quit request. + let recovery_source = include_str!("../src/provider_recovery.rs"); + let unpin = recovery_source + .split_once("pub(crate) fn unpin_onedrive_local_copy") + .expect("OneDrive unpin function") + .1 + .split_once("fn runtime_observation") + .expect("OneDrive unpin function boundary") + .0; + assert!(unpin.contains("let primary_runtime_observed =")); + assert!(unpin.contains("collect_provider_primary_runtime")); + assert!(unpin.contains("if primary_runtime_observed {")); + assert!(unpin.contains("request_quit(\"OneDrive\").is_err()")); + assert!(unpin.contains("request_graceful_term(\"OneDrive\")?")); + + // A fully closed primary app is already in the state required by OneDrive `/unpin`. + // Requiring the broad helper-aware observation here would incorrectly reject that safe state. + assert!(!unpin.contains("require_runtime_observation(CloudProvider::Onedrive, 0)")); + + let runtime_source = include_str!("../src/provider_client_runtime.rs"); + let primary_observer = runtime_source + .split_once("pub(crate) fn collect_provider_primary_runtime") + .expect("primary provider runtime observer") + .1 + .split_once("pub fn require_provider_client_runtime") + .expect("primary provider runtime observer boundary") + .0; + assert!(primary_observer.contains("CloudProvider::Onedrive => \"OneDrive\"")); + assert!(!primary_observer.contains("OneDrive Sync Service")); +} + +#[test] +fn failed_shutdown_requests_are_judged_by_primary_process_evidence() { + let recovery_source = include_str!("../src/provider_recovery.rs"); + let request_quit = recovery_source + .split_once("fn request_quit") + .expect("quit request helper") + .1 + .split_once("fn request_graceful_term") + .expect("quit request helper boundary") + .0; + assert!(request_quit.contains("require_primary_runtime_observation")); + assert!(!request_quit.contains("require_runtime_observation(provider, 0)")); + + let graceful_term = recovery_source + .split_once("fn request_graceful_term") + .expect("graceful termination helper") + .1 + .split_once("pub fn recover_provider_client") + .expect("graceful termination helper boundary") + .0; + assert!(graceful_term.contains("require_primary_runtime_observation")); + assert!(!graceful_term.contains("require_runtime_observation(provider, 0)")); + + let primary_requirement = recovery_source + .split_once("fn require_primary_runtime_observation") + .expect("primary runtime requirement") + .1 + .split_once("fn request_quit") + .expect("primary runtime requirement boundary") + .0; + assert!(primary_requirement.contains("collect_provider_primary_runtime")); +} diff --git a/src-tauri/tests/onedrive_unpin_outcome_contract.rs b/src-tauri/tests/onedrive_unpin_outcome_contract.rs new file mode 100644 index 000000000..2a3423eb8 --- /dev/null +++ b/src-tauri/tests/onedrive_unpin_outcome_contract.rs @@ -0,0 +1,21 @@ +#[test] +fn successful_onedrive_unpin_preserves_restart_failure_as_verification_evidence() { + let recovery = include_str!("../src/provider_recovery.rs"); + let eviction = include_str!("../src/cloud_local_eviction.rs"); + + assert!( + recovery.contains("struct OneDriveUnpinOutcome") + && recovery.contains("restart_blockers: Vec"), + "OneDrive unpin must distinguish a completed local eviction request from provider restart evidence" + ); + assert!( + !recovery.contains("restart.and(operation)"), + "a successful unpin must not disappear behind a later provider restart failure" + ); + assert!( + eviction.contains("request_blockers") + && eviction.contains("build_result(") + && eviction.contains("verification_blockers"), + "post-eviction verification must retain provider restart blockers in the immutable result" + ); +} diff --git a/src-tauri/tests/podman_desktop_branch_coverage.rs b/src-tauri/tests/podman_desktop_branch_coverage.rs new file mode 100644 index 000000000..937ee1332 --- /dev/null +++ b/src-tauri/tests/podman_desktop_branch_coverage.rs @@ -0,0 +1,182 @@ +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, PodmanReclaimPlan, + PodmanRecommendedAction, PodmanRecommendedActionKind, PodmanStoreEvidence, + PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, + RawImageEvidence, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build one deterministic `podman system df` category for projection tests. +fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } +} + +/// Build a complete plan whose private identifiers must never cross the desktop boundary. +fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1_000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "abcdef0123456789".repeat(4), + }), + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Exercise every character-class and length boundary of privacy-safe issue-code admission. +#[test] +fn issue_code_projection_covers_length_prefix_and_character_boundaries() { + let mut plan = complete_plan(); + plan.issues = vec![ + "stable-code9:private-detail".to_string(), + "stable--0".to_string(), + "a".repeat(97), + "1starts-with-digit".to_string(), + "-starts-with-hyphen".to_string(), + "with space".to_string(), + "éclair".to_string(), + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(evidence.issue_codes.contains(&"stable-code9".to_string())); + assert!(evidence.issue_codes.contains(&"stable--0".to_string())); + assert!(evidence + .issue_codes + .contains(&"podman-evidence-error".to_string())); + assert_eq!( + evidence + .issue_codes + .iter() + .filter(|code| code.as_str() == "podman-evidence-error") + .count(), + 1 + ); +} + +/// Reject lowercase non-hexadecimal fingerprints that otherwise satisfy the exact length bound. +#[test] +fn fingerprint_validation_rejects_lowercase_non_hex_at_exact_length() { + let mut plan = complete_plan(); + plan.unused_images + .as_mut() + .expect("fixture has unused image evidence") + .candidate_set_sha256 = "g".repeat(64); + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); +} + +/// Preserve fail-closed candidate review while distinguishing action-approval branches. +#[test] +fn observed_candidates_force_review_even_without_matching_approval() { + let mut plan = complete_plan(); + plan.assessment.recommended_actions = vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: false, + rationale: "image observation only".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: true, + rationale: "unrelated approval".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: false, + rationale: "volume observation only".to_string(), + }, + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + // The stopped-container path is satisfied by a matching approved action. Image and volume + // deliberately are not, but their non-zero observed candidates still force review. An + // unrelated approved action cannot substitute for the object-domain boundary. + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); +} + +/// Preserve unknown inner optional measurements even when their enclosing observations exist. +#[test] +fn nested_optional_capacity_values_remain_unknown() { + let mut plan = complete_plan(); + plan.machine + .as_mut() + .expect("fixture has machine evidence") + .configured_disk_bytes = None; + plan.raw_image + .as_mut() + .expect("fixture has raw-image evidence") + .allocated_bytes = None; + + let evidence = redact_podman_reclaim_plan(plan); + + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); +} diff --git a/src-tauri/tests/podman_desktop_bridge_command.rs b/src-tauri/tests/podman_desktop_bridge_command.rs new file mode 100644 index 000000000..dfb15db18 --- /dev/null +++ b/src-tauri/tests/podman_desktop_bridge_command.rs @@ -0,0 +1,20 @@ +use disksage_lib::podman_desktop::PODMAN_DESKTOP_SCHEMA_KIND; +use disksage_lib::podman_desktop_bridge::inspect_podman_desktop_evidence; + +/// Exercise the separately registered privacy-safe Podman command through its public Rust boundary. +/// +/// The probe may report partial evidence when Podman is absent or unhealthy, but the bridge must +/// always preserve the schema and must never claim verified host physical reclaimability. +#[test] +fn privacy_safe_podman_bridge_executes_public_boundary() { + let evidence = inspect_podman_desktop_evidence(); + + assert_eq!(evidence.schema_kind, PODMAN_DESKTOP_SCHEMA_KIND); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert_eq!(evidence.assessment_status, "unverified"); + assert_eq!(evidence.notices.len(), 2); + assert!(evidence.notices.iter().any(|notice| { + notice.contains("no prune, remove, machine lifecycle, TRIM, or raw-image mutation") + })); +} diff --git a/src-tauri/tests/podman_desktop_candidate_review_consistency.rs b/src-tauri/tests/podman_desktop_candidate_review_consistency.rs new file mode 100644 index 000000000..f13e83763 --- /dev/null +++ b/src-tauri/tests/podman_desktop_candidate_review_consistency.rs @@ -0,0 +1,79 @@ +//! Fail-closed review-boundary regressions for observed Podman candidates. +//! +//! Review booleans are decision-support evidence, not mutation authority. They still must not be +//! false when the same projected payload contains a non-zero candidate in that object domain, +//! even if an upstream assessment accidentally omits its recommended-action record. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PodmanStoreEvidence, + PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, + PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build one deterministic `podman system df` category observation. +fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } +} + +/// Build a plan with candidates but deliberately omit every recommended action. +fn candidate_plan_without_actions() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: Some(PodmanStoreEvidence { + graph_root: "/private/graph-root".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "a".repeat(64), + }), + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Candidate observations themselves conservatively require review in their own domain. +#[test] +fn observed_candidates_force_independent_review_boundaries() { + let evidence = redact_podman_reclaim_plan(candidate_plan_without_actions()); + + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); +} diff --git a/src-tauri/tests/podman_desktop_command_coverage.rs b/src-tauri/tests/podman_desktop_command_coverage.rs new file mode 100644 index 000000000..97c30cc68 --- /dev/null +++ b/src-tauri/tests/podman_desktop_command_coverage.rs @@ -0,0 +1,20 @@ +use disksage_lib::podman_desktop::{inspect_podman_reclaim, PODMAN_DESKTOP_SCHEMA_KIND}; + +/// Exercise the production desktop command boundary with the host's read-only Podman probe. +/// +/// The assertions intentionally cover only invariants that hold whether Podman is absent, +/// installed without a machine, or connected to a running machine. This keeps the regression +/// deterministic while proving that the actual command wrapper executes instead of relying only +/// on source-text contracts or the lower-level projection helper. +#[test] +fn desktop_command_executes_the_read_only_probe_boundary() { + let evidence = inspect_podman_reclaim(); + + assert_eq!(evidence.schema_kind, PODMAN_DESKTOP_SCHEMA_KIND); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert_eq!(evidence.assessment_status, "unverified"); + assert!(evidence.notices.iter().any(|notice| { + notice.contains("no prune, remove, machine lifecycle, TRIM, or raw-image mutation") + })); +} diff --git a/src-tauri/tests/podman_desktop_documentation_contract.rs b/src-tauri/tests/podman_desktop_documentation_contract.rs new file mode 100644 index 000000000..407f8580b --- /dev/null +++ b/src-tauri/tests/podman_desktop_documentation_contract.rs @@ -0,0 +1,70 @@ +//! Source-level documentation contract for the Podman desktop evidence module. +//! +//! This test keeps private helpers and regression tests understandable in addition to the public +//! API rustdoc enforced by the module's `missing_docs` lint. + +use std::fs; +use std::path::PathBuf; + +/// Require every named function in the Podman desktop evidence module to have adjacent, +/// beginner-readable rustdoc rather than an empty marker or placeholder text. +#[test] +fn every_podman_desktop_function_has_beginner_readable_rustdoc() { + let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/podman_desktop.rs"); + let source = fs::read_to_string(&source_path).expect("podman_desktop.rs must be readable"); + let lines = source.lines().collect::>(); + let mut violations = Vec::new(); + + for (line_index, line) in lines.iter().enumerate() { + let declaration = line.trim_start(); + let is_named_function = declaration.starts_with("fn ") + || declaration.starts_with("pub fn ") + || declaration.starts_with("pub(crate) fn ") + || declaration.starts_with("async fn ") + || declaration.starts_with("pub async fn ") + || declaration.starts_with("pub(crate) async fn ") + || declaration.starts_with("unsafe fn ") + || declaration.starts_with("pub unsafe fn ") + || declaration.starts_with("pub(crate) unsafe fn ") + || declaration.starts_with("const fn ") + || declaration.starts_with("pub const fn ") + || declaration.starts_with("pub(crate) const fn "); + if !is_named_function { + continue; + } + + let mut cursor = line_index; + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + if previous.is_empty() || previous.starts_with("#[") { + cursor -= 1; + continue; + } + break; + } + + let mut rustdoc_lines = Vec::new(); + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + let Some(rustdoc) = previous.strip_prefix("///") else { + break; + }; + rustdoc_lines.push(rustdoc.trim()); + cursor -= 1; + } + rustdoc_lines.reverse(); + let rustdoc = rustdoc_lines.join(" "); + let readable = rustdoc.chars().count() >= 24 + && !rustdoc.to_ascii_lowercase().contains("todo") + && !rustdoc.to_ascii_lowercase().contains("placeholder"); + if !readable { + violations.push(format!("line {}: {declaration}", line_index + 1)); + } + } + + assert!( + violations.is_empty(), + "every Podman desktop function needs adjacent beginner-readable rustdoc; violations: {}", + violations.join(", ") + ); +} diff --git a/src-tauri/tests/podman_desktop_issue_privacy.rs b/src-tauri/tests/podman_desktop_issue_privacy.rs new file mode 100644 index 000000000..c4036e704 --- /dev/null +++ b/src-tauri/tests/podman_desktop_issue_privacy.rs @@ -0,0 +1,62 @@ +//! Integration regression for privacy-safe Podman issue codes. +//! +//! Headless probe failures are untrusted local diagnostic strings. A missing delimiter must never +//! allow a path, socket, machine name, or command detail to cross the desktop IPC boundary. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Builds the smallest public plan needed to exercise issue-code projection. +fn plan_with_issue(issue: &str) -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: false, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec![], + recommended_actions: vec![], + }, + issues: vec![issue.to_string()], + } +} + +/// Rejects delimiter-free local paths instead of serializing them as desktop issue codes. +#[test] +fn delimiter_free_private_issue_detail_falls_back_to_stable_code() { + let evidence = redact_podman_reclaim_plan(plan_with_issue( + "/Users/alice/.local/share/containers/private-machine.sock", + )); + + assert_eq!(evidence.issue_codes, vec!["podman-evidence-error"]); + let json = serde_json::to_string(&evidence).expect("desktop evidence must serialize"); + assert!(!json.contains("alice")); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/")); +} + +/// Any projected issue forces completeness false even if an upstream caller contradicts it. +#[test] +fn projected_issue_codes_fail_completeness_closed() { + let mut plan = plan_with_issue("podman-info-failed:/run/user/501/private.sock"); + plan.evidence_complete = true; + + let evidence = redact_podman_reclaim_plan(plan); + + assert_eq!(evidence.issue_codes, vec!["podman-info-failed"]); + assert!(!evidence.evidence_complete); +} diff --git a/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs b/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs new file mode 100644 index 000000000..742a8bf7b --- /dev/null +++ b/src-tauri/tests/podman_desktop_physical_reclaim_claim.rs @@ -0,0 +1,50 @@ +//! Fail-closed regression for contradictory Podman physical-reclaim evidence. +//! +//! A headless plan with an `unverified` assessment may not publish a concrete host-physical +//! reclaim amount to the desktop. The Rust projection must clear the claim and mark the evidence +//! incomplete before the untrusted IPC boundary, rather than relying on frontend rejection. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build the smallest contradictory plan that carries an unverified physical-reclaim claim. +fn contradictory_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: Some(4096), + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Contradictory physical-reclaim claims are removed and make the projection incomplete. +#[test] +fn unverified_physical_reclaim_claim_fails_closed_in_rust_projection() { + let evidence = redact_podman_reclaim_plan(contradictory_plan()); + + assert_eq!(evidence.assessment_status, "unverified"); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert!(!evidence.evidence_complete); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-unverified-physical-reclaim-claim".to_string())); +} diff --git a/src-tauri/tests/podman_desktop_review_regressions.rs b/src-tauri/tests/podman_desktop_review_regressions.rs new file mode 100644 index 000000000..b98042e31 --- /dev/null +++ b/src-tauri/tests/podman_desktop_review_regressions.rs @@ -0,0 +1,83 @@ +//! Review regressions for the privacy-safe Podman desktop boundary. +//! +//! These tests exercise two fail-closed contracts discovered during exact-head review: assessment +//! text may not cross IPC as unbounded local detail, and the registered Tauri command may not +//! disappear from a `coverage` configuration while `lib.rs` still references it. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build the smallest public plan that can carry hostile assessment text into projection. +fn plan_with_assessment(status: &str, reason_codes: &[&str]) -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + dangling_prune_approval_phrase: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: status.to_string(), + reason_codes: reason_codes + .iter() + .map(|value| (*value).to_string()) + .collect(), + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Host paths, socket-like text, and duplicate detail never survive assessment projection. +#[test] +fn hostile_assessment_text_is_redacted_and_fails_completeness_closed() { + let evidence = redact_podman_reclaim_plan(plan_with_assessment( + "/Users/alice/private-machine.sock", + &[ + "host-physical-reclaim-unverified:/Users/alice/private-machine.sock", + "/run/user/501/podman.sock", + "host-physical-reclaim-unverified:duplicate-private-detail", + ], + )); + + assert_eq!(evidence.assessment_status, "unverified"); + assert_eq!( + evidence.reason_codes, + vec![ + "host-physical-reclaim-unverified".to_string(), + "podman-assessment-error".to_string(), + ] + ); + assert!(!evidence.evidence_complete); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-assessment-code".to_string())); + + let json = serde_json::to_string(&evidence).expect("desktop evidence must serialize"); + assert!(!json.contains("alice")); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/")); + assert!(!json.contains("/run/user/")); +} + +/// The public command definition and Tauri registration must remain cfg-compatible. +#[test] +fn registered_command_is_not_removed_only_from_coverage_builds() { + let command_source = include_str!("../src/podman_desktop.rs").replace("\r\n", "\n"); + let library_source = include_str!("../src/lib.rs").replace("\r\n", "\n"); + + assert!(library_source.contains("podman_desktop_bridge::inspect_podman_desktop_evidence",)); + assert!(!command_source + .contains("#[cfg(not(coverage))]\n#[tauri::command]\npub fn inspect_podman_reclaim",)); +} diff --git a/src-tauri/tests/provider_recovery_post_launch_contract.rs b/src-tauri/tests/provider_recovery_post_launch_contract.rs new file mode 100644 index 000000000..6eff7b154 --- /dev/null +++ b/src-tauri/tests/provider_recovery_post_launch_contract.rs @@ -0,0 +1,35 @@ +#[test] +fn general_recovery_keeps_slow_post_launch_observation_structured() { + let source = include_str!("../src/provider_recovery.rs"); + + assert!( + source.contains("launch_provider(&path)?;"), + "general provider recovery must treat a successful launch request separately from runtime re-observation" + ); + assert!( + !source.contains("fn launch_provider(provider: CloudProvider"), + "launch_provider must not convert a slow post-launch runtime observation into a launch failure" + ); + assert!( + source.contains("let post_runtime_observed = runtime_observation(provider, observed_at_ms);") + && source.contains("post_runtime_blockers(post_runtime_observed)"), + "slow or unavailable post-launch observation must remain structured recovery evidence" + ); +} + +#[test] +fn bounded_output_wait_errors_reap_the_spawned_child() { + let source = include_str!("../src/provider_recovery.rs"); + let bounded_output = source + .split("fn run_bounded_output") + .nth(1) + .and_then(|tail| tail.split("fn launch_provider").next()) + .expect("run_bounded_output source boundary"); + + assert!( + bounded_output.contains( + "Err(_) => {\n let _ = child.kill();\n let _ = child.wait();\n return Err(\"provider-recovery-command-wait-failed\".into());\n }" + ), + "a wait failure must not leave the OneDrive helper process unreaped" + ); +} diff --git a/src-tauri/tests/provider_runtime_state_contract.rs b/src-tauri/tests/provider_runtime_state_contract.rs new file mode 100644 index 000000000..5181cf1c8 --- /dev/null +++ b/src-tauri/tests/provider_runtime_state_contract.rs @@ -0,0 +1,28 @@ +use disksage_lib::provider_runtime_state::restore_after_temporary_stop; +use std::cell::Cell; + +#[test] +fn onedrive_unpin_preserves_an_initially_stopped_client() { + let restart_called = Cell::new(false); + + restore_after_temporary_stop(false, || { + restart_called.set(true); + Ok::<(), String>(()) + }) + .expect("an initially stopped provider needs no restart"); + + assert!(!restart_called.get()); +} + +#[test] +fn onedrive_unpin_restores_an_initially_running_client() { + let restart_called = Cell::new(false); + + restore_after_temporary_stop(true, || { + restart_called.set(true); + Ok::<(), String>(()) + }) + .expect("a previously running provider is restored"); + + assert!(restart_called.get()); +} diff --git a/src-tauri/tests/python_tool_cache_discovery_contract.rs b/src-tauri/tests/python_tool_cache_discovery_contract.rs new file mode 100644 index 000000000..61b71f11d --- /dev/null +++ b/src-tauri/tests/python_tool_cache_discovery_contract.rs @@ -0,0 +1,53 @@ +use disksage_lib::dev_artifacts::find_artifacts; +use std::fs; + +#[test] +fn bare_tox_section_does_not_authorize_setup_cfg_cache_discovery() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("setup.cfg"), "[tox]\nlegacy = true\n").unwrap(); + let tox = tmp.path().join(".tox"); + fs::create_dir(&tox).unwrap(); + fs::write(tox.join("cache.bin"), b"cache").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert!( + artifacts.iter().all(|artifact| artifact.kind != ".tox"), + "only the standard setup.cfg [tox:tox] section may authorize .tox discovery" + ); +} + +#[test] +fn rejected_python_314_environment_is_not_descended_for_nested_cache_candidates() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join(".git"), "gitdir: /private/fixture\n").unwrap(); + let environment = tmp.path().join(".venv314"); + let nested_cache = environment.join(".mypy_cache"); + fs::create_dir_all(&nested_cache).unwrap(); + fs::write(environment.join("pyvenv.cfg"), "version = 3.13.9\n").unwrap(); + fs::write(nested_cache.join("cache.bin"), b"cache").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert!( + artifacts.is_empty(), + "a marker-qualified .venv314 that fails its Python 3.14 proof must be pruned from discovery" + ); +} + +#[test] +fn markerless_python_314_environment_is_not_descended_for_nested_cache_candidates() { + let tmp = tempfile::tempdir().unwrap(); + let environment = tmp.path().join(".venv314"); + let nested_cache = environment.join(".mypy_cache"); + fs::create_dir_all(&nested_cache).unwrap(); + fs::write(environment.join("pyvenv.cfg"), "version = 3.14.1\n").unwrap(); + fs::write(nested_cache.join("cache.bin"), b"cache").unwrap(); + + let artifacts = find_artifacts(tmp.path(), 0, u64::MAX); + + assert!( + artifacts.is_empty(), + "an unowned .venv314 must be pruned instead of lending authority to marker-free nested caches" + ); +} diff --git a/src-tauri/tests/runtime_storage_async_boundary.rs b/src-tauri/tests/runtime_storage_async_boundary.rs new file mode 100644 index 000000000..c60a9b99a --- /dev/null +++ b/src-tauri/tests/runtime_storage_async_boundary.rs @@ -0,0 +1,31 @@ +#[test] +fn runtime_storage_commands_use_blocking_task_boundary() { + let inspection_source = include_str!("../src/runtime_storage_commands.rs"); + let inspection_start = inspection_source + .find("pub async fn inspect_runtime_storage(") + .expect("async runtime-storage inspection command"); + let inspection_body = &inspection_source[inspection_start..]; + assert!(inspection_body.contains("tauri::async_runtime::spawn_blocking")); + + let command_source = include_str!("../src/commands.rs"); + for signature in [ + "pub async fn execute_runtime_storage_trim(", + "pub async fn execute_runtime_storage_recovery(", + ] { + let start = command_source.find(signature).expect("async runtime-storage command"); + let body = &command_source[start + ..command_source[start..] + .find("\n}\n") + .map(|end| start + end + 3) + .unwrap()]; + assert!(body.contains("tauri::async_runtime::spawn_blocking")); + } + + let app_source = include_str!("../src/lib.rs"); + assert!(app_source.lines().any(|line| { + line.trim() == "runtime_storage_commands::inspect_runtime_storage," + })); + assert!(!app_source + .lines() + .any(|line| line.trim() == "commands::inspect_runtime_storage,")); +} diff --git a/src-tauri/tests/runtime_storage_public_privacy.rs b/src-tauri/tests/runtime_storage_public_privacy.rs new file mode 100644 index 000000000..564693423 --- /dev/null +++ b/src-tauri/tests/runtime_storage_public_privacy.rs @@ -0,0 +1,38 @@ +use disksage_lib::runtime_storage::{RuntimeStorageExecution, RuntimeStorageKind}; + +#[test] +fn runtime_storage_execution_serialization_omits_guest_output() { + let secret_stdout = "/Users/customer/Documents/acquisition/private-plan.txt"; + let secret_stderr = "token=customer-secret-runtime-diagnostic"; + let execution = RuntimeStorageExecution { + schema_kind: "disksage.runtime-storage-execution", + schema_version: 1, + runtime: RuntimeStorageKind::Colima, + command: vec![ + "colima".into(), + "ssh".into(), + "--".into(), + "sudo".into(), + "fstrim".into(), + "-av".into(), + ], + status_code: 17, + stdout: secret_stdout.into(), + stderr: secret_stderr.into(), + output_truncated: true, + executed: false, + executed_at_ms: 42, + rationale: "operator approved trim".into(), + volume_comparison: None, + volume_evidence_error: None, + }; + + let json = serde_json::to_string(&execution).expect("runtime storage execution serializes"); + + assert!(!json.contains("\"stdout\"")); + assert!(!json.contains("\"stderr\"")); + assert!(!json.contains(secret_stdout)); + assert!(!json.contains(secret_stderr)); + assert!(json.contains("\"status_code\":17")); + assert!(json.contains("\"output_truncated\":true")); +} diff --git a/src-tauri/tests/runtime_storage_recovery_receipt.rs b/src-tauri/tests/runtime_storage_recovery_receipt.rs new file mode 100644 index 000000000..ab8d84ae4 --- /dev/null +++ b/src-tauri/tests/runtime_storage_recovery_receipt.rs @@ -0,0 +1,153 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +const HELPER_ENV: &str = "DISKSAGE_RUNTIME_STORAGE_RECOVERY_HELPER"; +const START_FAIL_ENV: &str = "DISKSAGE_RUNTIME_STORAGE_START_FAIL"; +const FIXED_COLIMA_CANDIDATES: &[&str] = &[ + "/opt/homebrew/bin/colima", + "/usr/local/bin/colima", + "/usr/bin/colima", +]; + +#[test] +fn completed_restart_is_recorded_even_when_reachability_remains_unavailable() { + if std::env::var_os(HELPER_ENV).is_some() { + let plan = disksage_lib::runtime_storage::inspect() + .into_iter() + .find(|plan| plan.runtime == disksage_lib::runtime_storage::RuntimeStorageKind::Colima) + .expect("Colima plan"); + let phrase = plan + .recovery_approval_phrase + .as_deref() + .expect("unreachable running guest should offer recovery"); + let receipt = disksage_lib::runtime_storage::execute_recovery( + disksage_lib::runtime_storage::RuntimeStorageKind::Colima, + phrase, + "Verify that a completed stop/start is not erased by the post-restart reachability probe.", + ) + .expect("completed restart should return a receipt"); + + assert_eq!(receipt.stop_status_code, 0); + assert_eq!(receipt.start_status_code, 0); + assert!(!receipt.guest_reachable_after_recovery); + assert!( + receipt.executed, + "executed must report whether the approved stop/start mutation completed, not whether the later reachability observation succeeded" + ); + return; + } + + if installed_colima_would_override_test_path() { + return; + } + let temp = tempfile::tempdir().expect("temporary fake runtime directory"); + write_fake_colima(temp.path()); + let isolated_path = isolated_path_with(temp.path()); + + let status = Command::new(std::env::current_exe().expect("current test executable")) + .arg("--exact") + .arg("completed_restart_is_recorded_even_when_reachability_remains_unavailable") + .arg("--nocapture") + .env(HELPER_ENV, "1") + .env("PATH", isolated_path) + .status() + .expect("run isolated recovery regression"); + + assert!(status.success(), "isolated production-boundary regression failed"); +} + +#[test] +fn successful_stop_with_failed_start_returns_partial_recovery_receipt() { + if std::env::var_os(HELPER_ENV).is_some() { + let plan = disksage_lib::runtime_storage::inspect() + .into_iter() + .find(|plan| plan.runtime == disksage_lib::runtime_storage::RuntimeStorageKind::Colima) + .expect("Colima plan"); + let phrase = plan + .recovery_approval_phrase + .as_deref() + .expect("unreachable running guest should offer recovery"); + let receipt = disksage_lib::runtime_storage::execute_recovery( + disksage_lib::runtime_storage::RuntimeStorageKind::Colima, + phrase, + "Preserve evidence that shutdown completed even when the approved restart command fails.", + ) + .expect("a completed shutdown is a mutation and must return a structured receipt"); + + assert_eq!(receipt.stop_status_code, 0); + assert_eq!(receipt.start_status_code, 42); + assert!(!receipt.guest_reachable_after_recovery); + assert!( + !receipt.executed, + "executed remains the full stop/start completion flag while the receipt preserves the partial shutdown mutation" + ); + return; + } + + if installed_colima_would_override_test_path() { + return; + } + let temp = tempfile::tempdir().expect("temporary fake runtime directory"); + write_fake_colima(temp.path()); + let isolated_path = isolated_path_with(temp.path()); + + let status = Command::new(std::env::current_exe().expect("current test executable")) + .arg("--exact") + .arg("successful_stop_with_failed_start_returns_partial_recovery_receipt") + .arg("--nocapture") + .env(HELPER_ENV, "1") + .env(START_FAIL_ENV, "1") + .env("PATH", isolated_path) + .status() + .expect("run isolated partial-recovery regression"); + + assert!(status.success(), "partial-recovery receipt regression failed"); +} + +fn installed_colima_would_override_test_path() -> bool { + FIXED_COLIMA_CANDIDATES.iter().any(|candidate| { + fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()) + }) +} + +fn isolated_path_with(directory: &std::path::Path) -> std::ffi::OsString { + let current_path = std::env::var_os("PATH").unwrap_or_default(); + let mut path_entries = vec![directory.to_path_buf()]; + path_entries.extend(std::env::split_paths(¤t_path)); + std::env::join_paths(path_entries).expect("isolated PATH") +} + +fn write_fake_colima(directory: &std::path::Path) { + let colima = directory.join("colima"); + fs::write( + &colima, + r#"#!/bin/sh +set -eu +case "${1:-}" in + --version) exit 0 ;; + status) + printf '%s\n' '{"display_name":"colima","runtime":"docker","driver":"mock"}' + exit 0 + ;; + ssh) exit 1 ;; + stop) exit 0 ;; + start) + if [ "${DISKSAGE_RUNTIME_STORAGE_START_FAIL:-}" = "1" ]; then + exit 42 + fi + exit 0 + ;; + *) exit 98 ;; +esac +"#, + ) + .expect("write fake colima"); + let mut permissions = fs::metadata(&colima) + .expect("fake runtime metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&colima, permissions).expect("make fake runtime executable"); +} diff --git a/src-tauri/tests/runtime_storage_trim_timeout_regression.rs b/src-tauri/tests/runtime_storage_trim_timeout_regression.rs new file mode 100644 index 000000000..3c24bd1f3 --- /dev/null +++ b/src-tauri/tests/runtime_storage_trim_timeout_regression.rs @@ -0,0 +1,79 @@ +#[cfg(unix)] +#[test] +fn guest_trim_can_run_longer_than_the_probe_timeout() { + use disksage_lib::runtime_storage::{self, RuntimeStorageKind}; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::time::{Duration, Instant}; + + let temp = tempfile::tempdir().expect("temporary runtime directory"); + let colima = temp.path().join("colima"); + fs::write( + &colima, + r#"#!/bin/sh +case "$*" in + "--version") + exit 0 + ;; + "status --json") + printf '%s\n' '{"status":"running"}' + exit 0 + ;; + "ssh -- true") + exit 0 + ;; + "ssh -- sudo fstrim -av") + sleep 31 + printf '%s\n' '/: 1048576 bytes trimmed' + exit 0 + ;; + *) + exit 2 + ;; +esac +"#, + ) + .expect("write fake colima"); + let mut permissions = fs::metadata(&colima).expect("fake colima metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&colima, permissions).expect("make fake colima executable"); + + let previous_path = std::env::var_os("PATH"); + let mut paths = vec![temp.path().to_path_buf()]; + if let Some(existing) = previous_path.as_deref() { + paths.extend(std::env::split_paths(existing)); + } + let test_path = std::env::join_paths(paths).expect("construct PATH"); + std::env::set_var("PATH", &test_path); + + let result = (|| { + let plan = runtime_storage::inspect() + .into_iter() + .find(|plan| plan.runtime == RuntimeStorageKind::Colima) + .expect("colima plan"); + assert_eq!(plan.guest_running, Some(true)); + assert_eq!(plan.guest_reachable, Some(true)); + let approval = plan + .exact_approval_phrase + .as_deref() + .expect("running reachable guest has trim approval"); + + let started = Instant::now(); + let execution = runtime_storage::execute_trim( + RuntimeStorageKind::Colima, + approval, + "verify bounded long-running guest trim", + ) + .expect("a normal fstrim may legitimately exceed the 30-second probe timeout"); + + assert!(execution.executed); + assert_eq!(execution.status_code, 0); + assert!(started.elapsed() >= Duration::from_secs(30)); + })(); + + match previous_path { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + result +} diff --git a/src/lib/BrewCleanup.svelte b/src/lib/BrewCleanup.svelte index 905a36adb..05abac33a 100644 --- a/src/lib/BrewCleanup.svelte +++ b/src/lib/BrewCleanup.svelte @@ -25,8 +25,8 @@ reset(); try { judgment = await api.judgeBrewCleanup(); - } catch (e) { - error = String(e); + } catch { + error = "Homebrew 정리 계획을 확인하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } finally { planning = false; } @@ -35,10 +35,10 @@ function approvalGuidance(): string { if (!judgment || judgment.verdict !== "safe") return ""; if (!judgment.calibration || judgment.calibration.judgment_id !== judgment.judgment_id) { - return "이 정확한 LLM 판정에 연결된 fast-mlsirm calibration이 필요합니다."; + return "자동 안전성 검토가 완료되지 않아 실행할 수 없습니다."; } if (!judgment.calibration.passed) { - return "fast-mlsirm Judge calibration이 통과하지 않아 실행할 수 없습니다."; + return "자동 안전성 검토를 통과하지 않아 실행할 수 없습니다."; } if (confirmationPhrase.trim() !== judgment.exact_approval_phrase) { return "승인 문구가 일치하지 않습니다."; @@ -64,7 +64,7 @@ async function executeCleanup() { if (!judgment || !executionReady()) return; const okay = await confirm( - "LLM이 안전하다고 판단한 고정 명령을 실행합니다.\n\n" + "안전성 검토가 완료된 고정 명령을 실행합니다.\n\n" + "brew cleanup --prune-prefix\n\n" + "Homebrew prefix 안의 끊어진 심볼릭 링크와 빈 디렉터리만 정리하며, 실행 전 dry-run 계획을 다시 검증합니다.", { title: "DiskSage Homebrew 정리", kind: "warning" }, @@ -81,8 +81,8 @@ confirmationPhrase.trim(), rationale.trim(), ); - } catch (e) { - error = String(e); + } catch { + error = "Homebrew 정리를 실행하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } finally { judgment = null; confirmationPhrase = ""; @@ -95,10 +95,10 @@
Homebrew 정리 (macOS)

- 읽기 전용 dry-run 결과를 로컬 LLM이 판단합니다. 실행 범위는 Homebrew prefix 안의 끊어진 심볼릭 링크와 빈 디렉터리로 제한되며, Safe여도 사람의 승인 문구와 사유를 입력해야 고정 명령만 실행됩니다. + 읽기 전용 미리보기로 Homebrew prefix 안의 끊어진 심볼릭 링크와 빈 디렉터리만 확인합니다. 안전하더라도 승인 문구와 사유를 입력해야 고정 명령이 실행됩니다.

{#if error}{/if} @@ -106,23 +106,23 @@ {#if judgment || completedJudgment} {@const report = (judgment ?? completedJudgment)!}
-
LLM 판정: {report.verdict} · {report.model_name}
-

{report.reason || "모델이 설명을 반환하지 않았습니다."}

+
안전성 검토: {report.verdict === "safe" ? "실행 가능" : "실행 보류"}
+

{report.reason || "안전성 검토 설명이 제공되지 않았습니다."}

계획 지문: {report.plan_fingerprint}

실행 예정: brew cleanup --prune-prefix

{#if report.calibration}

- Judge calibration ({report.calibration.engine}): {report.calibration.passed ? "통과" : "실패"} + 안전성 검토 일치 여부: {report.calibration.passed ? "통과" : "실패"} · 표본 {report.calibration.sample_count}개 · 일치율 {Math.round(report.calibration.exact_agreement * 100)}%

{:else} -

fast-mlsirm calibration 증거가 없어 독립적인 사람 승인 문구가 계속 필요합니다.

+

안전성 검토 증거가 없어 독립적인 사람 승인 문구가 계속 필요합니다.

{/if} -
{report.plan.dry_run_output || "dry-run에서 정리 대상이 보고되지 않았습니다."}
+
{report.plan.dry_run_output || "미리보기에서 정리 대상이 보고되지 않았습니다."}
{#if judgment && judgment.verdict === "safe" && !execution}
-

아래 승인 문구 전체를 직접 입력해야 합니다. 실행 직전에 dry-run 계획과 LLM 판단을 다시 대조합니다.

+

아래 승인 문구 전체를 직접 입력해야 합니다. 실행 직전에 미리보기와 안전성 검토를 다시 대조합니다.

{judgment.exact_approval_phrase} {#if approvalGuidance()}

{approvalGuidance()}

@@ -140,19 +140,17 @@
{:else if judgment && judgment.verdict !== "safe"} -

Safe가 아니므로 실행 권한을 만들지 않았습니다.

+

안전성 검토를 통과하지 않아 실행 권한을 만들지 않았습니다.

{/if} {#if execution}

{execution.executed ? `실행 완료 (종료 코드 ${execution.status_code})` : "실행되지 않음"}

- {#if execution.stdout}
{execution.stdout}
{/if} - {#if execution.stderr}
{execution.stderr}
{/if} {#if execution.record_path} -

감사 기록: {execution.record_path}

+

감사 기록을 저장했습니다. 다음 정리 전에 최신 상태를 다시 확인하세요.

{:else} - + {/if} {/if}
diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index 2c18753cc..6825944a7 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -2,10 +2,15 @@ import * as api from "./api"; import { fmtBytes } from "./fmt"; import { verdictBadge } from "./verdictBadge"; + import { + executeRuntimeStorageMutation, + runtimeStorageRecoverySucceeded, + } from "./runtimeStorageMaintenanceFlow"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; import BrewCleanup from "./BrewCleanup.svelte"; import OrphanCleanup from "./OrphanCleanup.svelte"; + import ContainerOrphanCleanup from "./ContainerOrphanCleanup.svelte"; let { scannedRoot }: { scannedRoot: string | null } = $props(); @@ -16,17 +21,38 @@ let busy = $state(false); let loadError = $state(""); let cacheRetryMessage = $state(""); - let podmanPlan: api.PodmanReclaimPlan | null = $state(null); - let podmanBusy = $state(false); - let podmanError = $state(""); - let podmanPruneBusy = $state(false); - let podmanPruneError = $state(""); - let podmanPrunePhrase = $state(""); - let podmanPruneRationale = $state(""); - let podmanPruneExecution: api.PodmanDanglingImagePruneExecution | null = $state(null); - // ponytail: 배지는 개별 파일/디렉토리 후보(artifacts)에만 표시 — caches는 소수의 고정 규칙 카테고리라 LLM 판정 가치가 낮음. + let runtimeStoragePlans: api.RuntimeStoragePlan[] = $state([]); + let runtimeStorageBusy = $state(false); + let runtimeStorageError = $state(""); + let runtimeStoragePhrase = $state>({}); + let runtimeStorageRationale = $state>({}); + let runtimeStorageExecutions: Record = $state({}); + let runtimeStorageRecoveryExecutions: Record = $state({}); + // ponytail: 배지는 개별 파일/디렉토리 후보(artifacts)에만 표시 — caches는 소수의 고정 규칙 카테고리라 자동 자문 가치가 낮음. let verdicts: Record = $state({}); + function artifactKindLabel(kind: string): string { + const labels: Record = { + node_modules: "Node.js 의존 파일", + target: "개발 도구 빌드 산출물", + ".venv": "Python 환경 파일", + ".venv314": "Python 3.14 환경 파일", + ".mypy_cache": "Python 형식 검사 캐시", + ".pytest_cache": "Python 테스트 캐시", + ".ruff_cache": "Python 코드 검사 캐시", + ".tox": "Python 호환성 테스트 환경", + ".nox": "Python 자동화 테스트 환경", + dist: "배포용 빌드 파일", + build: "빌드 파일", + ".codegraph": "코드 분석 자료", + }; + return labels[kind] ?? "개발 파일"; + } + + function runtimeStorageLabel(runtime: api.RuntimeStorageKind): string { + return runtime === "podman-machine" ? "Podman" : "Colima"; + } + async function loadVerdicts(paths: string[]) { try { const fvs = await api.fileVerdicts(paths); @@ -42,55 +68,107 @@ caches = await api.listCacheCandidates(); artifacts = scannedRoot ? await api.listDevArtifacts(scannedRoot) : []; loadVerdicts(artifacts.map((a) => a.path)); - } catch (e) { - loadError = String(e); + } catch { + loadError = "정리 대상을 불러오지 못했습니다. 저장 공간을 확인한 뒤 다시 시도하세요."; } } - async function inspectPodman() { - if (podmanBusy) return; - podmanBusy = true; - podmanError = ""; + async function inspectRuntimeStorage() { + if (runtimeStorageBusy) return; + runtimeStorageBusy = true; + runtimeStorageError = ""; try { - podmanPlan = await api.inspectPodmanReclaim(); - } catch (e) { - podmanError = String(e); - podmanPlan = null; + runtimeStoragePlans = await api.inspectRuntimeStorage(); + runtimeStoragePhrase = {}; + runtimeStorageRationale = {}; + runtimeStorageExecutions = {}; + runtimeStorageRecoveryExecutions = {}; + } catch { + runtimeStoragePlans = []; + runtimeStorageError = "저장 공간 상태를 확인하지 못했습니다. 다시 시도하세요."; } finally { - podmanBusy = false; + runtimeStorageBusy = false; } } - function podmanPruneReady(): boolean { - const phrase = podmanPlan?.dangling_prune_approval_phrase; - return phrase !== null - && phrase !== undefined - && podmanPrunePhrase.trim() === phrase - && podmanPruneRationale.trim().length > 0 - && !podmanPruneBusy; + function runtimeStorageReady(plan: api.RuntimeStoragePlan): boolean { + return plan.exact_approval_phrase !== null + && runtimeStoragePhrase[plan.runtime]?.trim() === plan.exact_approval_phrase + && (runtimeStorageRationale[plan.runtime]?.trim().length ?? 0) > 0 + && !runtimeStorageBusy; } - async function prunePodmanDanglingImages() { - if (!podmanPlan || !podmanPruneReady()) return; + function runtimeStorageRecoveryReady(plan: api.RuntimeStoragePlan): boolean { + return plan.recovery_approval_phrase !== null + && runtimeStoragePhrase[plan.runtime]?.trim() === plan.recovery_approval_phrase + && (runtimeStorageRationale[plan.runtime]?.trim().length ?? 0) > 0 + && !runtimeStorageBusy; + } + + function invalidateRuntimeStorageApproval() { + runtimeStoragePhrase = {}; + runtimeStorageRationale = {}; + } + + async function trimRuntimeStorage(plan: api.RuntimeStoragePlan) { + if (!runtimeStorageReady(plan) || !plan.exact_approval_phrase) return; const okay = await confirm( - "참조 컨테이너가 없고 tag가 없는 Podman 이미지만 삭제합니다. volume·컨테이너·tagged image·VM은 건드리지 않습니다.\n\n실행 직전에 이미지 목록을 다시 읽어 지문을 검증합니다.", - { title: "DiskSage Podman 정리", kind: "warning" }, + `${runtimeStorageLabel(plan.runtime)}에서 회수 가능한 영역만 정리합니다. 개인 파일과 설정은 변경하지 않습니다.\n\n실행 전에 상태를 다시 확인합니다.`, + { title: "DiskSage 저장 공간 정리", kind: "warning" }, ); if (!okay) return; - podmanPruneBusy = true; - podmanPruneError = ""; + runtimeStorageBusy = true; + runtimeStorageError = ""; try { - podmanPruneExecution = await api.executePodmanDanglingImagePrune( - podmanPrunePhrase.trim(), - podmanPruneRationale.trim(), + const outcome = await executeRuntimeStorageMutation( + () => api.executeRuntimeStorageTrim( + plan.runtime, + runtimeStoragePhrase[plan.runtime].trim(), + runtimeStorageRationale[plan.runtime].trim(), + ), + invalidateRuntimeStorageApproval, + api.inspectRuntimeStorage, ); - podmanPrunePhrase = ""; - podmanPruneRationale = ""; - podmanPlan = await api.inspectPodmanReclaim(); - } catch (e) { - podmanPruneError = String(e); + runtimeStorageExecutions[plan.runtime] = outcome.execution; + if (outcome.plans) runtimeStoragePlans = outcome.plans; + if (outcome.refreshFailed) { + runtimeStorageError = "저장 공간 정리는 실행했지만 최신 상태를 다시 확인하지 못했습니다. 상태를 새로 확인하세요."; + } + } catch { + runtimeStorageError = "저장 공간 정리를 실행하지 못했습니다. 최신 상태를 확인한 뒤 다시 시도하세요."; } finally { - podmanPruneBusy = false; + runtimeStorageBusy = false; + } + } + + async function recoverRuntimeStorage(plan: api.RuntimeStoragePlan) { + if (!runtimeStorageRecoveryReady(plan) || !plan.recovery_approval_phrase) return; + const okay = await confirm( + `${runtimeStorageLabel(plan.runtime)} 연결을 정상 종료한 뒤 다시 시작합니다. 실행 중인 작업이 있다면 중단될 수 있습니다.\n\n복구 후 저장 공간 상태를 다시 확인합니다.`, + { title: "저장 공간 연결 복구", kind: "warning" }, + ); + if (!okay) return; + runtimeStorageBusy = true; + runtimeStorageError = ""; + try { + const outcome = await executeRuntimeStorageMutation( + () => api.executeRuntimeStorageRecovery( + plan.runtime, + runtimeStoragePhrase[plan.runtime].trim(), + runtimeStorageRationale[plan.runtime].trim(), + ), + invalidateRuntimeStorageApproval, + api.inspectRuntimeStorage, + ); + runtimeStorageRecoveryExecutions[plan.runtime] = outcome.execution; + if (outcome.plans) runtimeStoragePlans = outcome.plans; + if (outcome.refreshFailed) { + runtimeStorageError = "연결 재시작은 실행했지만 최신 게스트 상태를 다시 확인하지 못했습니다. 상태를 새로 확인하세요."; + } + } catch { + runtimeStorageError = "연결을 복구하지 못했습니다. 실행 중인 작업을 확인한 뒤 다시 시도하세요."; + } finally { + runtimeStorageBusy = false; } } @@ -108,19 +186,18 @@ const targetBytes = targets.reduce((sum, target) => sum + target.bytes, 0); const okay = await confirm( `${candidate.label}의 직계 캐시 ${targets.length}개(${fmtBytes(targetBytes)})를 휴지통으로 보냅니다.\n\n` + - "캐시 루트는 보존하며, 각 항목은 객체 지문·크기·수정시각·active-use를 다시 검증합니다. 사용 중이거나 증명이 불완전한 항목은 건너뜁니다. 휴지통에서 복원할 수 있습니다.", + "캐시 루트는 보존하며, 각 항목의 파일 정보·크기·수정 시각·사용 여부를 다시 확인합니다. 사용 중이거나 확인이 불완전한 항목은 건너뜁니다. 휴지통에서 복원할 수 있습니다.", { title: "DiskSage", kind: "warning" }, ); if (!okay) return; results = await api.cleanCacheContents(candidate.path, targets); await load(); } catch (e) { - const error = String(e); - if (error.includes("cache-cleanup-targets-stale")) { + if (typeof e === "string" && e.includes("cache-cleanup-targets-stale")) { await load(); cacheRetryMessage = "캐시 내용이 바뀌어 최신 목록을 불러왔습니다. 다시 휴지통으로를 눌러 검토하세요."; } else { - loadError = error; + loadError = "캐시를 정리하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } } finally { busy = false; @@ -134,8 +211,8 @@ try { results = await api.cleanRegenerableCaches(); await load(); - } catch (e) { - loadError = String(e); + } catch { + loadError = "재생성 가능한 캐시를 정리하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } finally { busy = false; } @@ -163,14 +240,12 @@ (a) => selected.has(a.path) && a.scan_complete && a.skipped === 0, ); if (selectedArtifacts.length === 0 || !scannedRoot) return; - const summary = selectedArtifacts.map( - (a) => `${a.path} (${fmtBytes(a.bytes)}, ${a.files}개) — 메타데이터 지문 ${a.fingerprint.slice(0, 12)}`, - ); + const summary = selectedArtifacts.map((a) => `${a.path} (${fmtBytes(a.bytes)}, ${a.files}개)`); const okay = await confirm( `다음 ${summary.length}개 항목을 휴지통으로 보냅니다 (논리 크기 합계 ${fmtBytes(totalSelected)}):\n\n` + summary.slice(0, 15).join("\n") + (summary.length > 15 ? `\n… 외 ${summary.length - 15}개` : "") + - "\n\n휴지통에서 언제든 복원할 수 있습니다. 휴지통을 비우기 전에는 물리 공간이 회수되지 않으며, APFS 공유 블록 때문에 실제 회수량은 논리 크기보다 작을 수 있습니다.", + "\n\n휴지통에서 언제든 복원할 수 있습니다. 휴지통을 비우기 전에는 저장 공간이 회수되지 않습니다.", { title: "DiskSage", kind: "warning" }, ); if (!okay) return; @@ -180,8 +255,8 @@ results = await api.cleanDevArtifacts(scannedRoot, 30, selectedArtifacts); selected = new Set(); await load(); - } catch (e) { - loadError = String(e); + } catch { + loadError = "개발 파일을 정리하지 못했습니다. 상태를 확인한 뒤 다시 시도하십시오."; } finally { busy = false; } @@ -192,19 +267,19 @@

정리

- {#if loadError}{/if} + {#if loadError}{/if}

캐시

- 알려진 캐시 루트의 직계 항목만 객체 지문·크기·수정시각을 재검증한 뒤 휴지통으로 보냅니다. 캐시 루트 자체는 보존됩니다. + 알려진 캐시 루트의 직계 항목만 파일 정보·크기·수정 시각을 다시 확인한 뒤 휴지통으로 보냅니다. 캐시 루트 자체는 보존됩니다.

- npm·pnpm·Adobe·Edge·uv·Trivy 캐시만 대상으로 하며, 사용 중이거나 증거가 바뀐 항목은 자동으로 건너뜁니다. + npm·pnpm·Adobe·Edge·uv·Trivy·AppMap·Superset·Playwright 캐시만 대상으로 하며, 사용 중이거나 확인이 바뀐 항목은 자동으로 건너뜁니다. 정리 범위를 확인하세요.

- {#if cacheRetryMessage}

{cacheRetryMessage}

{/if} + {#if cacheRetryMessage}

안내를 확인하세요. {cacheRetryMessage}

{/if}
    {#each caches as c (c.id)}
  • @@ -220,7 +295,7 @@ {/each}
-

오래된 개발 아티팩트 {scannedRoot ? `(${scannedRoot}, 30일+)` : "(먼저 스캔하세요)"}

+

오래된 개발 파일 {scannedRoot ? `(${scannedRoot}, 30일+)` : "(먼저 스캔하세요)"}

    {#each artifacts as a (a.path)}
  • @@ -231,10 +306,10 @@ checked={selected.has(a.path)} onchange={() => (selected = toggle(selected, a.path))} /> - {a.kind} ({a.project}, {a.age_days}일) + {artifactKindLabel(a.kind)} ({a.project}, {a.age_days}일) {!a.scan_complete - ? `${fmtBytes(a.bytes)} · 메타데이터 스캔 미완료` + ? `${fmtBytes(a.bytes)} · 파일 정보 확인 미완료` : a.skipped > 0 ? `${fmtBytes(a.bytes)} · 읽기 오류 ${a.skipped}` : fmtBytes(a.bytes)} @@ -263,7 +338,7 @@ {#if failedResults.length > 0}
      {#each failedResults as r (r.path)} -
    • ⚠ {r.path} — {r.error}
    • +
    • ⚠ {r.path} — 정리하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요.
    • {/each}
    {/if} @@ -272,61 +347,81 @@ -

    Podman VM 저장소

    + + +

    Podman·Colima 저장 공간

    - 게스트·이미지·volume 증거만 읽습니다. prune, 삭제, trim, 중지는 이 화면에서 실행하지 않습니다. - 실제 물리 회수량은 전후 호스트 관측 없이는 확정하지 않습니다. + Podman과 Colima가 사용하는 저장 공간 상태를 확인합니다. 정리는 목록과 사유를 검토하고 승인한 경우에만 실행합니다. + 전체 저장 공간을 줄이는 기능은 자동으로 실행하지 않으며, 필요하면 해당 도구의 관리 화면에서 상태를 확인하세요.

    - - {#if podmanError}{/if} - {#if podmanPlan} -
    -

    - {podmanPlan.evidence_complete ? "증거 완전" : "증거 불완전"} · - 게스트 여유 {podmanPlan.guest_filesystem ? fmtBytes(podmanPlan.guest_filesystem.available_bytes) : "확인 불가"} · - 보고 reclaimable {podmanPlan.assessment.podman_reported_reclaimable_bytes === null - ? "미확인" - : fmtBytes(podmanPlan.assessment.podman_reported_reclaimable_bytes)} -

    - {#if podmanPlan.unused_images} -

    미사용 이미지 {podmanPlan.unused_images.unused_records}개 · exact record 합계 {fmtBytes(podmanPlan.unused_images.candidate_record_size_sum)}

    - {/if} - {#if podmanPlan.dangling_prune_approval_phrase} -
    -

    dangling 이미지(무tag·참조 컨테이너 0)만 실행 대상으로 확인되었습니다.

    -
    + {:else if plan.recovery_approval_phrase} +

    저장 공간을 확인할 수 없습니다. 연결을 복구한 뒤 다시 확인하세요.

    +

    아래 확인 문구를 그대로 입력하고 복구 사유를 남겨야 실행됩니다.

    + {plan.recovery_approval_phrase} + + + + {/if} + {#if runtimeStorageExecutions[plan.runtime]} + {@const execution = runtimeStorageExecutions[plan.runtime]} +

    + {execution.executed ? "저장 공간 정리를 완료했습니다." : "저장 공간 정리가 완료되지 않았습니다."} + 상태를 다시 확인하세요. +

    + {#if execution.volume_comparison?.available_change.direction === "increased"} +

    + 확인된 사용 가능 공간 증가: {fmtBytes(execution.volume_comparison.available_change.bytes)} +

    + {/if} + {/if} + {#if runtimeStorageRecoveryExecutions[plan.runtime]} + {@const recoveryExecution = runtimeStorageRecoveryExecutions[plan.runtime]} +

    + {runtimeStorageRecoverySucceeded(recoveryExecution) + ? "연결을 복구했습니다. 저장 공간을 다시 확인하세요." + : "연결 복구가 완료되지 않았습니다. 실행 중인 작업과 연결 상태를 확인하세요."} +

    + {/if} +
    + {/each} {/if}
@@ -344,9 +439,6 @@ .error, .errors { color: #b00; } .errors { font-size: 0.85rem; } .podman-evidence { margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #b7c6d8; border-radius: 4px; background: #f8fafc; } - .podman-prune { margin-top: 0.75rem; display: grid; gap: 0.5rem; } - .podman-prune label { display: grid; gap: 0.25rem; } - .podman-prune input, .podman-prune textarea { width: 100%; box-sizing: border-box; } .badge-safe, .badge-caution, .badge-keep, .badge-unrated { display: inline-block; margin-left: 0.4rem; padding: 1px 6px; border-radius: 8px; font-size: 0.75rem; color: #fff; @@ -355,4 +447,4 @@ .badge-caution { background: #b8860b; } .badge-keep { background: #b03030; } .badge-unrated { background: #888; } - + \ No newline at end of file diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 0392ba454..18794c925 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -1153,11 +1153,16 @@

관리자 권한이나 OAuth 없이 macOS의 읽기 전용 iCloud 계정 상태를 사용합니다.

{/if} {#key selectedRoot} - + {/key}
{:else if selectedRootDetails()}
+ {#if selectedRootDetails()?.provider === "onedrive"} + {#key selectedRoot} + + {/key} + {/if} {#if connectionForSelectedRoot()} {providerApiWriteConnected() ? "OAuth 업로드 연결" : "읽기 전용 OAuth descriptor 발견"} 범위: {connectionForSelectedRoot()?.scope} @@ -1349,7 +1354,7 @@

먼저 macOS File Provider의 업로드·최신 버전 메타데이터를 확인합니다. file ID를 입력하면 네이티브 증거가 불완전할 때 OAuth API로 SHA-256과 부모 폴더 체인을 My Drive 루트까지 두 차례 검증합니다. 영수증 목적지와 정확히 일치하고 검증 중 변경되지 않은 경우에만 원본 제거 허가를 생성합니다. 공유 드라이브는 아직 실패 폐쇄합니다.

-

API 보완 시 access token은 OS 보안 저장소의 refresh token으로 Rust 내부에서 한 번만 갱신하며 UI·설정·영수증에 노출하지 않습니다.

+

API 보완에 사용하는 연결 자격 증명은 운영체제 보안 저장소에서만 갱신하며 화면·설정·영수증에 표시하지 않습니다.

{:else if copied.receipt.provider === "onedrive"}

macOS File Provider 증거가 불완전하면 OAuth 연결을 사용해 영수증의 OneDrive 상대 경로를 직접 조회하고 QuickXorHash를 검증합니다. 임의 item ID는 받지 않습니다.

{/if} diff --git a/src/lib/ContainerOrphanCleanup.svelte b/src/lib/ContainerOrphanCleanup.svelte new file mode 100644 index 000000000..c0490592b --- /dev/null +++ b/src/lib/ContainerOrphanCleanup.svelte @@ -0,0 +1,253 @@ + + +
+

Docker · Podman · Colima 미사용 자원

+

+ 각 개발 환경의 컨테이너·이미지·볼륨·네트워크 중 아무것도 연결되지 않은 항목만 찾아줍니다. + 실행 중인 서비스와 기본 네트워크는 절대 건드리지 않습니다. 삭제 전 승인 문구와 사유를 요구합니다. +

+ + {#if loadError}{/if} + + {#if lastRefreshFailedExecution} +
+

{lastRefreshFailedExecution.runtimeDisplayName}

+

+ 최근 정리 결과는 보존했습니다. 최신 개발 환경 상태를 다시 확인해야 새 정리 계획을 만들 수 있습니다. +

+

+ {CATEGORY_LABELS[lastRefreshFailedExecution.category]} 정리 결과를 확인하세요: + {containerOrphanExecutionStatus(lastRefreshFailedExecution.execution)} · + 호스트 여유 공간 변화 + {lastRefreshFailedExecution.execution.observed_available_gain_bytes === null + ? "관측 불가" + : `+${fmtBytes(lastRefreshFailedExecution.execution.observed_available_gain_bytes)}`} +

+
+ {/if} + + {#if unavailableRuntimeCount > 0} +

사용할 수 없는 개발 환경 {unavailableRuntimeCount}개가 있습니다. 연결 상태를 확인한 뒤 다시 확인하세요.

+ {/if} + {#if plans.length > 0 && healthyPlans.length === 0} +

연결 가능한 개발 환경이 없습니다. 사용할 환경을 시작한 뒤 다시 확인하세요.

+ {/if} + {#each healthyPlans as plan (plan.runtime.kind)} + {@const pkey = planKey(plan)} +
+

{plan.runtime.display_name}

+
    + {#each plan.categories as cat (cat.category)} + {@const ckey = categoryKey(pkey, cat.category)} +
  • + {CATEGORY_LABELS[cat.category]} + {#if !cat.evidence_complete} + 확인이 끝나지 않아 안전을 위해 실행할 수 없습니다. + {:else if cat.evidence && cat.evidence.candidate_records > 0} + + 대상 {cat.evidence.candidate_records}개 + {#if cat.evidence.candidate_size_sum_bytes !== null} + · 약 {fmtBytes(cat.evidence.candidate_size_sum_bytes)} + {/if} + + {#if cat.approval_phrase} +
    +

    아래 승인 문구를 직접 입력하세요.

    + {cat.approval_phrase} + + + +

    {CATEGORY_HINTS[cat.category]}

    +
    + {/if} + {:else} + 정리 대상 없음 + {/if} + {#if pruneErrors[ckey]} + + {/if} + {#if executions[ckey]} +

    + 결과를 확인하세요: {containerOrphanExecutionStatus(executions[ckey])} · + 호스트 여유 공간 변화 {executions[ckey].observed_available_gain_bytes === null ? "관측 불가" : `+${fmtBytes(executions[ckey].observed_available_gain_bytes)}`} +

    + {/if} +
  • + {/each} +
+
+ {/each} +
+ + diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index 29d0c27a3..d5506f5e0 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -38,8 +38,8 @@ } toDelete = next; loadVerdicts(groups.flatMap((g) => g.paths)); - } catch (e) { - loadError = String(e); + } catch { + loadError = "중복 파일을 확인하지 못했습니다. 스캔 범위와 저장 공간을 확인한 뒤 다시 시도하세요."; } finally { busy = false; } @@ -77,8 +77,8 @@ const r = await api.cleanPaths(paths); await scan(); results = r; - } catch (e) { - loadError = String(e); + } catch { + loadError = "중복 파일을 휴지통으로 보내지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } finally { busy = false; } @@ -90,6 +90,7 @@ 중복 파일 {scannedRoot ? "" : "(먼저 스캔하세요)"} +

내용이 완전히 같은 사본만 자동으로 묶습니다. 해상도나 압축이 다른 사진은 자동 삭제하지 않으니 먼저 비교하세요.

{#if loadError}

{loadError}

{/if} {#if groups.length === 0 && !busy} diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index 0c45e040e..1ed0572ce 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -7,6 +7,9 @@ let repositoryRoot = $state(""); let retentionText = $state(""); + let includeClosedPullRequests = $state(false); + let includeStaleOpenPullRequests = $state(false); + let staleOpenPullRequestCutoffDate = $state(""); let planning = $state(false); let executing = $state(false); let error = $state(""); @@ -44,6 +47,17 @@ error = ""; } + function staleOpenPullRequestCutoffMs(): number | null { + if (!includeStaleOpenPullRequests || !/^\d{4}-\d{2}-\d{2}$/.test(staleOpenPullRequestCutoffDate)) { + return null; + } + const parsed = Date.parse(`${staleOpenPullRequestCutoffDate}T00:00:00.000Z`); + return Number.isFinite(parsed) + && new Date(parsed).toISOString().slice(0, 10) === staleOpenPullRequestCutoffDate + ? parsed + : null; + } + async function chooseRepository() { error = ""; try { @@ -51,13 +65,13 @@ multiple: false, directory: true, defaultPath: repositoryRoot || scannedRoot || undefined, - title: "Git 저장소 또는 연결된 worktree 선택", + title: "Git 저장소 또는 보조 폴더 선택", }); if (typeof selected !== "string") return; repositoryRoot = selected; resetDecision(); - } catch (e) { - error = String(e); + } catch { + error = "폴더를 선택하지 못했습니다. 다시 시도하세요."; } } @@ -65,16 +79,21 @@ const root = repositoryRoot.trim(); const references = retentionReferences(); if (!root || references.length === 0) return; + const staleCutoffMs = staleOpenPullRequestCutoffMs(); + if (includeStaleOpenPullRequests && staleCutoffMs === null) { + error = "오래된 진행 중 작업을 확인하려면 기준일을 입력하세요."; + return; + } planning = true; resetDecision(); try { - report = await api.planStaleGitWorktrees(root, references); + report = await api.planStaleGitWorktrees(root, references, includeClosedPullRequests, staleCutoffMs); repositoryRoot = report.repository_root; retentionText = report.retention_references .map((binding) => binding.reference_ref) .join("\n"); - } catch (e) { - error = String(e); + } catch { + error = "보조 폴더를 확인하지 못했습니다. 경로와 보존할 기준을 확인한 뒤 다시 시도하세요."; } finally { planning = false; } @@ -94,9 +113,9 @@ async function removeWorktrees() { if (!report || !executionReady()) return; const approved = await confirm( - `${report.removal_candidate_count}개 worktree 디렉터리(최대 ${fmtBytes(report.removal_candidate_allocated_bytes)})를 제거합니다.\n\n` - + "각 항목은 실행 직전에 다시 검사합니다. 브랜치와 커밋은 유지하며 force·prune은 사용하지 않습니다. 제거된 디렉터리는 휴지통으로 가지 않습니다.", - { title: "DiskSage 오래된 Git worktree 제거", kind: "warning" }, + `${report.removal_candidate_count}개 보조 폴더(최대 ${fmtBytes(report.removal_candidate_allocated_bytes)})를 정리합니다.\n\n` + + "각 항목은 실행 직전에 다시 확인합니다. 보존할 작업 기록은 유지됩니다. 정리한 폴더는 휴지통으로 가지 않습니다.", + { title: "오래된 보조 폴더 정리", kind: "warning" }, ); if (!approved) return; executing = true; @@ -105,14 +124,16 @@ removal = await api.removeStaleGitWorktrees( report.repository_root, report.retention_references.map((binding) => binding.reference_ref), + includeClosedPullRequests, + report.stale_open_pull_request_cutoff_ms, report.removal_plan_fingerprint, confirmationPhrase, rationale.trim(), ); confirmationPhrase = ""; rationale = ""; - } catch (e) { - error = String(e); + } catch { + error = "보조 폴더를 정리하지 못했습니다. 상태를 확인한 뒤 다시 시도하세요."; } finally { executing = false; } @@ -120,14 +141,14 @@
- 오래된 Git worktree + 오래된 보조 폴더

- 명시한 보존 ref에 이미 포함된 깨끗하고 사용 중이 아닌 보조 worktree만 찾습니다. 감사 단계는 읽기 전용입니다. + 지정한 보존 기준에 이미 포함된 깨끗하고 사용하지 않는 보조 폴더만 찾습니다. 확인 단계에서는 파일을 변경하지 않습니다.

+ + {#if includeClosedPullRequests} +

+ 병합 없이 종료된 PR의 깨끗한 보조 폴더도 정리 후보가 될 수 있습니다. 브랜치와 커밋은 유지됩니다. 선택한 저장소에 로그인된 GitHub 연결이 필요합니다. +

+ {/if} + + {#if includeStaleOpenPullRequests} + + {/if} - {#if error}{/if} + {#if error}{/if} {#if report}
제거 후보 {report.removal_candidate_count}개 · 최대 {fmtBytes(report.removal_candidate_allocated_bytes)} 보존 {report.preserved_count}개 - 증거 공백 {report.evidence_gap_count}개 + 확인 필요 {report.evidence_gap_count}개 + {#if report.stale_open_pull_request_cutoff_ms !== null} + 진행 중 작업 기준일 {new Date(report.stale_open_pull_request_cutoff_ms).toISOString().slice(0, 10)} + {/if}
-

계획 지문: {report.removal_plan_fingerprint}

-

보존 ref 지문: {report.retention_reference_set_fingerprint}

+

승인 확인 코드: {report.removal_plan_fingerprint}

{#if candidateEntries().length > 0}
    {#each candidateEntries() as candidate (candidate.path_fingerprint)}
  • -
    {candidate.branch ?? "분리된 HEAD"} · {fmtBytes(candidate.size.allocated_bytes)}
    +
    보존 기준 연결 확인됨 · {fmtBytes(candidate.size.allocated_bytes)}
    {candidate.path}
    -
    HEAD {candidate.head}
  • {/each}
@@ -185,10 +231,10 @@ {#if evidenceGapEntries().length > 0}
- 증거가 부족해 전체 실행을 차단했습니다. + 확인이 끝나지 않아 정리를 시작할 수 없습니다.
    {#each evidenceGapEntries() as entry (entry.path_fingerprint)} -
  • {entry.path} — {entry.blockers.join(", ")}
  • +
  • {entry.path} — 확인이 필요한 항목이 있습니다. 상태를 다시 확인하세요.
  • {/each}
@@ -197,31 +243,30 @@ {#if removal} {#if removal.result.verification_complete}

- {removal.result.removed_count}개 worktree 제거와 Git 등록 해제, 브랜치 보존을 확인했습니다. + {removal.result.removed_count}개 보조 폴더를 정리했고 보존할 작업 기록은 유지했습니다. 사전 할당량 기준 최대 {fmtBytes(removal.result.removed_allocated_bytes_upper_bound)}입니다.

{:else}

- 일부 또는 사후 검증이 완료되지 않았습니다: {removal.result.stopped_reason ?? "검증 불완전"}. - 확인된 제거 {removal.result.removed_count}/{removal.result.planned_candidate_count}개입니다. + 일부 항목의 확인이 끝나지 않았습니다. 확인된 정리 {removal.result.removed_count}/{removal.result.planned_candidate_count}개입니다. 상태를 확인한 뒤 다시 시도하세요.

{/if} -

승인 기록: {removal.approval_path}

+

승인 기록을 저장했습니다.

{#if removal.result_path} -

결과 기록: {removal.result_path}

+

정리 결과를 저장했습니다.

{:else} {/if} {:else if report.evidence_complete && report.exact_approval_phrase}

- 아래 승인 문구 전체를 직접 입력해야 합니다. 실행 시 전체 계획과 각 후보를 재검증하며 한 항목이라도 달라지면 중단합니다. + 아래 승인 확인 코드를 입력하세요. 실행 직전에 전체 계획과 각 후보를 다시 확인하며 달라지면 중단합니다.

{report.exact_approval_phrase}
{:else if report.removal_candidate_count === 0} -

현재 엄격한 제거 조건을 모두 만족하는 보조 worktree가 없습니다.

+

현재 정리 조건을 모두 만족하는 보조 폴더가 없습니다.

{/if}
{/if} @@ -260,7 +305,7 @@ .confirmation { min-height: 4.5rem; } .report { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid #72889c; border-radius: 4px; background: #f7fafc; } .summary { display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: baseline; } - .fingerprint, .oid { margin: 0; overflow-wrap: anywhere; font: 0.75rem ui-monospace, monospace; color: #59636e; } + .fingerprint { margin: 0; overflow-wrap: anywhere; font: 0.75rem ui-monospace, monospace; color: #59636e; } .worktrees { list-style: none; margin: 0; padding: 0; max-height: 30vh; overflow-y: auto; } .worktrees li { padding: 0.45rem 0; border-bottom: 1px solid #d9e0e6; } .path { overflow-wrap: anywhere; color: #66717d; font-size: 0.78rem; } diff --git a/src/lib/IcloudLocalEviction.copy.test.ts b/src/lib/IcloudLocalEviction.copy.test.ts new file mode 100644 index 000000000..0d1297860 --- /dev/null +++ b/src/lib/IcloudLocalEviction.copy.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("cloud local eviction customer copy", () => { + it("does not expose implementation APIs or describe eviction as deletion", () => { + const source = readFileSync(new URL("./IcloudLocalEviction.svelte", import.meta.url), "utf8"); + for (const internalTerm of ["NSFileProviderManager", "fileproviderctl", "evictItem", "ubiquitous identity"]) { + expect(source).not.toContain(internalTerm); + } + expect(source).not.toContain("로컬 파일만 휴지통으로 이동"); + }); +}); diff --git a/src/lib/IcloudLocalEviction.svelte b/src/lib/IcloudLocalEviction.svelte index 9c2036cd0..3ac5b90fd 100644 --- a/src/lib/IcloudLocalEviction.svelte +++ b/src/lib/IcloudLocalEviction.svelte @@ -11,7 +11,8 @@ verificationBlockerActions, } from "./icloudLocalEvictionFeedback"; - let { cloudRoot }: { cloudRoot: string } = $props(); + let { cloudRoot, provider }: { cloudRoot: string; provider: "icloud" | "onedrive" } = $props(); + const providerName = $derived(provider === "icloud" ? "iCloud" : "OneDrive"); let path = $state(""); let planning = $state(false); @@ -37,7 +38,7 @@ multiple: false, directory: false, defaultPath: cloudRoot, - title: "로컬 사본 상태를 확인할 iCloud 파일 선택", + title: `로컬 사본 상태를 확인할 ${providerName} 파일 선택`, }); if (typeof selected !== "string") return; path = selected; @@ -73,9 +74,9 @@ async function evictLocalCopy() { if (!plan || !executionReady()) return; const approved = await confirm( - `${fmtBytes(plan.allocated_bytes)}의 로컬 iCloud 사본만 축출합니다.\n` + + `${fmtBytes(plan.allocated_bytes)}의 로컬 ${providerName} 사본만 축출합니다.\n` + "클라우드 항목은 유지되며 실행 직전에 상태를 다시 검증합니다.", - { title: "DiskSage iCloud 로컬 사본 축출", kind: "warning" }, + { title: `DiskSage ${providerName} 로컬 사본 축출`, kind: "warning" }, ); if (!approved) return; executing = true; @@ -99,8 +100,8 @@ function observationLabel(method: api.IcloudStateObservationMethod): string { return method === "file-provider-ctl-evaluate" - ? "macOS File Provider" - : "Foundation ubiquitous item"; + ? "공급자 상태 확인 완료" + : "업로드 상태를 다시 확인하세요"; } function uploadLabel(state: api.IcloudLocalState): string { @@ -121,13 +122,13 @@
- iCloud 로컬 사본 회수 + {providerName} 로컬 사본 회수

- 이미 iCloud에 있는 파일의 로컬 캐시만 검사합니다. 파일 내용과 클라우드 객체는 변경하지 않습니다. + 이미 {providerName}에 있는 파일의 로컬 캐시만 검사합니다. 파일 내용과 클라우드 객체는 변경하지 않습니다.