diff --git a/CHANGELOG.md b/CHANGELOG.md index 622ceff93..8a2762d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and options without echoing attacker-controlled option payloads. - Add an exact-allowlist generated-cache auditor that defaults to dry-run, blocks live processes, + fingerprints cache-internal symbolic links without following them, and continues to reject an + allowlisted root that is itself a symbolic link. tool locks, registered or dirty temporary Git workspaces, and provider/Photos/VM boundaries, and requires a fingerprint-bound approval plus a crash-recoverable private JSON Lines receipt before removal. Add a plan-first CLI; temporary Git workspaces remain audit-only and route to the diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 118537578..71fd65f1e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1183,6 +1183,17 @@ runner's private workspace temp root instead of weakening the shared production the approved cloud items remain intact and locally materialized until that native path passes post-allocation verification. +## 2026-08-30 generated-cache symbolic-link boundary + +- Live execution-readiness audits found that the allowlisted uv and Playwright caches contain + symbolic-link entries. The prior all-or-nothing rejection prevented DiskSage from producing an + approval fingerprint, allocated-byte estimate, or activity blockers for these ordinary caches. +- The manifest now fingerprints a link's own target text without resolving, opening, or traversing + its destination. An allowlisted root that is itself a symbolic link remains denied, special files + remain denied, and fresh evidence plus exact human approval are still mandatory before removal. +- Focused regression tests prove that changing content outside the cache through a child link does + not alter the cache manifest and that a root link cannot enter the reclaim contract. + ## 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`. diff --git a/src-tauri/src/generated_cache_reclaim.rs b/src-tauri/src/generated_cache_reclaim.rs index 8ba287768..fc35c26a7 100644 --- a/src-tauri/src/generated_cache_reclaim.rs +++ b/src-tauri/src/generated_cache_reclaim.rs @@ -307,10 +307,14 @@ fn observe_tree_inner(path: &Path) -> Result { } let metadata = std::fs::symlink_metadata(¤t) .map_err(|_| "generated-cache-metadata-unavailable".to_string())?; - if metadata.file_type().is_symlink() { - return Err("generated-cache-symlink-rejected".into()); + let is_symlink = metadata.file_type().is_symlink(); + // The allowlisted cache root itself must remain a real directory. Symlinks below that + // root are ordinary cache entries: fingerprint the link text without resolving or + // traversing it, so a link can never extend the deletion boundary. + if current == path && is_symlink { + return Err("generated-cache-root-symlink-rejected".into()); } - if !metadata.is_dir() && !metadata.is_file() { + if !metadata.is_dir() && !metadata.is_file() && !is_symlink { return Err("generated-cache-special-file-rejected".into()); } entries += 1; @@ -321,7 +325,9 @@ fn observe_tree_inner(path: &Path) -> Result { let relative_bytes = path_bytes(relative.as_os_str()); hasher.update(&(relative_bytes.len() as u64).to_le_bytes()); hasher.update(&relative_bytes); - hasher.update(if metadata.is_dir() { + hasher.update(if is_symlink { + b"symlink" + } else if metadata.is_dir() { b"directory" } else { b"file" @@ -331,7 +337,13 @@ fn observe_tree_inner(path: &Path) -> Result { if current.file_name().is_some_and(|name| name == ".lock") { locks.push(current.to_string_lossy().into_owned()); } - if metadata.is_file() { + if is_symlink { + let target = std::fs::read_link(¤t) + .map_err(|_| "generated-cache-symlink-unreadable".to_string())?; + let target_bytes = path_bytes(target.as_os_str()); + hasher.update(&(target_bytes.len() as u64).to_le_bytes()); + hasher.update(&target_bytes); + } else if metadata.is_file() { estimated_content_bytes = estimated_content_bytes .checked_add(metadata.len()) .ok_or("generated-cache-content-bound-exceeded")?; @@ -802,6 +814,8 @@ where #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; fn inactive() -> GeneratedCacheActivityEvidence { GeneratedCacheActivityEvidence { @@ -884,6 +898,43 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn cache_child_symlink_is_fingerprinted_without_following_target() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let root = home.join(".cache/uv"); + let outside = temp.path().join("customer-data"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&outside, b"must not be read or traversed").unwrap(); + symlink(&outside, root.join("cached-link")).unwrap(); + + let before = plan_with_evidence(&root, home, inactive(), 1).unwrap(); + std::fs::write(&outside, b"changed outside content").unwrap(); + let after = plan_with_evidence(&root, home, inactive(), 2).unwrap(); + + assert_eq!(before.content_fingerprint, after.content_fingerprint); + assert_eq!(before.allocated_bytes, after.allocated_bytes); + assert_eq!(before.entry_count, 2); + } + + #[cfg(unix)] + #[test] + fn allowlisted_root_symlink_remains_denied() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let outside = temp.path().join("outside-cache"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::create_dir_all(home.join(".cache")).unwrap(); + let root = home.join(".cache/uv"); + symlink(&outside, &root).unwrap(); + + assert_eq!( + plan_with_evidence(&root, home, inactive(), 1).unwrap_err(), + "generated-cache-root-symlink-rejected" + ); + } + #[test] fn provider_and_virtual_machine_boundaries_are_never_admitted() { let home = Path::new("/Users/test");