diff --git a/crates/januskey-cli/src/main.rs b/crates/januskey-cli/src/main.rs index a17d803..603392a 100644 --- a/crates/januskey-cli/src/main.rs +++ b/crates/januskey-cli/src/main.rs @@ -587,8 +587,15 @@ fn cmd_copy(dir: &PathBuf, source: &PathBuf, destination: &PathBuf, dry_run: boo Ok(()) } +/// Irreversibly erase the given paths. When `dir` holds a `.januskey/` +/// store, also shred every unshared content-store blob for each path and +/// purge its operation-log entries (recording each shred in +/// `.januskey/obliterations.json`); otherwise only the working files are +/// shredded. fn cmd_obliterate(dir: &PathBuf, paths: &[PathBuf], dry_run: bool, auto_yes: bool) -> Result<()> { - use januskey::obliteration::obliterate_file; + use januskey::obliteration::{ + obliterate_file, obliterate_path, ObliterationManager, OBLITERATION_LOG_FILE, + }; // Resolve each path against the working directory if it is relative. let targets: Vec = paths @@ -640,20 +647,55 @@ fn cmd_obliterate(dir: &PathBuf, paths: &[PathBuf], dry_run: bool, auto_yes: boo } } + // With a JanusKey store, scrub history too; without one, only the + // working file exists to be shredded. + let mut store = if JanusKey::is_initialized(dir) { + let jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; + let manager = + ObliterationManager::new(jk.root.join(".januskey").join(OBLITERATION_LOG_FILE)) + .context("Failed to open obliteration log")?; + Some((jk, manager)) + } else { + None + }; + let mut obliterated = 0; + let mut failed = 0; for t in &targets { - match obliterate_file(t) { - Ok(proof) => { - obliterated += 1; - println!( - "{} Obliterated {} ({} passes, proof {})", - "✓".green(), - t.display(), - proof.overwrite_passes, - &proof.id[..8] + let result = match store.as_mut() { + Some((jk, manager)) => obliterate_path(jk, manager, t, None, None).map(|r| { + let detail = format!( + "{} history entr(y/ies) purged, {} blob(s) shredded, {} shared blob(s) kept", + r.purged_operation_ids.len(), + r.blob_records.len(), + r.retained_shared.len() ); + (r.file_proof, detail) + }), + None => obliterate_file(t).map(|p| (Some(p), String::from("no JanusKey store"))), + }; + match result { + Ok((proof, detail)) => { + obliterated += 1; + match proof { + Some(proof) => println!( + "{} Obliterated {} ({} passes, proof {}; {})", + "✓".green(), + t.display(), + proof.overwrite_passes, + &proof.id[..8], + detail + ), + None => println!( + "{} Obliterated history of {} (working file already absent; {})", + "✓".green(), + t.display(), + detail + ), + } } Err(e) => { + failed += 1; eprintln!("{} Failed to obliterate {}: {}", "✗".red(), t.display(), e); } } @@ -664,6 +706,9 @@ fn cmd_obliterate(dir: &PathBuf, paths: &[PathBuf], dry_run: bool, auto_yes: boo "✓".green(), obliterated ); + if failed > 0 { + anyhow::bail!("{} path(s) could not be obliterated", failed); + } Ok(()) } diff --git a/crates/januskey-cli/src/obliteration.rs b/crates/januskey-cli/src/obliteration.rs index 045bf49..546f0ba 100644 --- a/crates/januskey-cli/src/obliteration.rs +++ b/crates/januskey-cli/src/obliteration.rs @@ -24,10 +24,13 @@ use crate::content_store::{ContentHash, ContentStore}; use crate::error::{JanusError, Result}; +use crate::metadata::OperationMetadata; +use crate::JanusKey; use chrono::{DateTime, Utc}; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -256,6 +259,30 @@ impl ObliterationManager { Ok(record) } + /// Append a record for an already-generated proof (e.g. the working-file + /// proof from [`obliterate_file`]) to the obliteration log and persist it. + pub fn record_proof( + &mut self, + proof: ObliterationProof, + reason: Option, + legal_basis: Option, + cleaned_operation_ids: Vec, + ) -> Result { + let record = ObliterationRecord { + id: Uuid::new_v4().to_string(), + timestamp: Utc::now(), + user: whoami::username(), + content_hash: proof.content_hash.clone(), + reason, + legal_basis, + proof, + cleaned_operation_ids, + }; + self.log.records.push(record.clone()); + self.save()?; + Ok(record) + } + /// Get all obliteration records pub fn records(&self) -> &[ObliterationRecord] { &self.log.records @@ -341,14 +368,10 @@ fn secure_overwrite(path: &Path) -> Result { /// store): hash its current content, securely overwrite it with /// [`OVERWRITE_PASSES`] passes, remove it, and return a proof of erasure. /// -/// This is the GDPR Article 17 "right to erasure" primitive applied to a -/// concrete filesystem path, used by the `jk obliterate ` command. -/// Unlike [`ObliterationManager::obliterate`] it does not consult the content -/// store, so it works on files the repository never ingested. -/// -/// TODO(product): also scrub any content-store copies and prune the -/// associated operation-log entries so no recoverable trace remains, and -/// thread the resulting proof into the obliteration audit log. +/// This touches only the working file. It is the fallback used by +/// `jk obliterate ` when no `.januskey/` store exists; when one does, +/// use [`obliterate_path`], which also scrubs the content-store copies and +/// operation-log entries for the path. pub fn obliterate_file(path: &Path) -> Result { if !path.exists() { return Err(JanusError::FileNotFound(format!( @@ -368,6 +391,172 @@ pub fn obliterate_file(path: &Path) -> Result { Ok(ObliterationProof::generate(&content_hash, passes)) } +/// File name of the obliteration log inside a `.januskey/` directory. +pub const OBLITERATION_LOG_FILE: &str = "obliterations.json"; + +/// Outcome of [`obliterate_path`]. +#[derive(Debug)] +pub struct PathObliterationReport { + /// Proof for the working file, if it existed and was shredded. + pub file_proof: Option, + /// IDs of the operation-log entries that were purged. + pub purged_operation_ids: Vec, + /// One record per content-store blob that was shredded. + pub blob_records: Vec, + /// Hashes referenced by the purged entries that were kept because a + /// surviving entry (another path) still references the same blob. + pub retained_shared: Vec, +} + +/// Normalise a path for comparison with stored operation paths: resolve it +/// against `root` if relative, then canonicalise its parent directory and +/// re-attach the file name (so it works whether or not the file itself still +/// exists). Falls back to the joined path if the parent cannot be resolved. +fn normalise_path(root: &Path, path: &Path) -> PathBuf { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + match (joined.parent(), joined.file_name()) { + (Some(parent), Some(name)) => parent + .canonicalize() + .map(|p| p.join(name)) + .unwrap_or_else(|_| joined.clone()), + _ => joined, + } +} + +/// Return `content_hash` and `new_content_hash` of one operation entry. +fn entry_hashes(op: &OperationMetadata) -> impl Iterator { + op.content_hash.iter().chain(op.new_content_hash.iter()) +} + +/// Obliterate every recoverable trace of `path` inside a JanusKey store +/// (GDPR Article 17 "right to erasure" for a tracked file): +/// +/// 1. shred and remove the working file, if it still exists; +/// 2. shred every content-store blob referenced by an operation-log entry +/// whose primary or secondary path is `path`, **unless** a surviving entry +/// for another path references the same (deduplicated) blob — shared blobs +/// are kept so the other path's history stays undoable; +/// 3. purge those operation-log entries (via +/// [`crate::MetadataStore::purge_path`]), so undo/history no longer +/// mention the path. +/// +/// Stored paths are matched after normalisation (relative to `jk.root`, +/// parent canonicalised), so the spelling used at record time does not +/// matter. Blobs are shredded *before* the log is purged: if a shred fails, +/// the entries survive and the command can be re-run to finish the job. +/// +/// Every shred is recorded in `manager`'s obliteration log (the working +/// file's proof as well as each blob's). That log is the unkeyed JSON +/// ledger; chaining these events into the keyed, tamper-evident `AuditLog` +/// needs an unlocked key and is deferred to J2-3. +/// +/// Returns `FileNotFound` if the working file is absent and the log holds no +/// entry for the path. +pub fn obliterate_path( + jk: &mut JanusKey, + manager: &mut ObliterationManager, + path: &Path, + reason: Option, + legal_basis: Option, +) -> Result { + let target = normalise_path(&jk.root, path); + let root = jk.root.clone(); + let matches = |p: &Path| normalise_path(&root, p) == target; + let entry_matches = |op: &OperationMetadata| { + matches(&op.path) || op.path_secondary.as_deref().is_some_and(matches) + }; + + // Plan: which stored spellings match, which hashes they reference, and + // which of those hashes no surviving entry still needs. + let ops = jk.metadata_store.operations(); + let mut spellings: Vec = Vec::new(); + for op in ops.iter().filter(|op| entry_matches(op)) { + for p in std::iter::once(&op.path).chain(op.path_secondary.iter()) { + if matches(p) && !spellings.contains(p) { + spellings.push(p.clone()); + } + } + } + let purged_ids: Vec = ops + .iter() + .filter(|op| entry_matches(op)) + .map(|op| op.id.clone()) + .collect(); + let mut seen = HashSet::new(); + let referenced: Vec = ops + .iter() + .filter(|op| entry_matches(op)) + .flat_map(entry_hashes) + .filter(|h| seen.insert((*h).clone())) + .cloned() + .collect(); + let surviving: HashSet<&ContentHash> = ops + .iter() + .filter(|op| !entry_matches(op)) + .flat_map(entry_hashes) + .collect(); + let (retained_shared, to_shred): (Vec, Vec) = + referenced.into_iter().partition(|h| surviving.contains(h)); + + let working = if path.is_absolute() { + path.to_path_buf() + } else { + jk.root.join(path) + }; + if !working.exists() && spellings.is_empty() { + return Err(JanusError::FileNotFound(format!( + "{} not found (no working file and no JanusKey history)", + path.display() + ))); + } + + // 1. Working file. + let file_proof = if working.exists() { + let proof = obliterate_file(&working)?; + manager.record_proof( + proof.clone(), + reason.clone(), + legal_basis.clone(), + purged_ids.clone(), + )?; + Some(proof) + } else { + None + }; + + // 2. Unshared blobs. A hash with no blob (e.g. a Modify's + // `new_content_hash`, which is never stored) is simply skipped. + let mut blob_records = Vec::new(); + for hash in to_shred.iter().filter(|h| jk.content_store.exists(h)) { + blob_records.push(manager.obliterate_with_cleanup( + &jk.content_store, + hash, + purged_ids.clone(), + reason.clone(), + legal_basis.clone(), + )?); + } + + // 3. Log entries, under every spelling they were stored with. + for spelling in &spellings { + jk.metadata_store.purge_path(spelling)?; + } + debug_assert!(to_shred + .iter() + .all(|h| !jk.metadata_store.referenced_hashes().contains(h))); + + Ok(PathObliterationReport { + file_proof, + purged_operation_ids: purged_ids, + blob_records, + retained_shared, + }) +} + /// Verify that content no longer exists at a path pub fn verify_obliteration(path: &Path, original_hash: &ContentHash) -> Result { if !path.exists() { diff --git a/crates/januskey-cli/tests/obliteration_cas_test.rs b/crates/januskey-cli/tests/obliteration_cas_test.rs new file mode 100644 index 0000000..c9436b9 --- /dev/null +++ b/crates/januskey-cli/tests/obliteration_cas_test.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Obliteration must leave no recoverable trace of a path in `.januskey/`: +// no content-store blob (unless shared with another path), no operation-log +// entry, no undo. Every "absent" assertion below is preceded by the same +// probe returning "present" on the same store (positive control), because +// blobs are gzip-compressed and a naive byte grep would pass vacuously. + +use assert_cmd::Command; +use flate2::read::GzDecoder; +use januskey::obliteration::{obliterate_path, ObliterationManager, OBLITERATION_LOG_FILE}; +use januskey::{ContentHash, FileOperation, JanusKey, OperationExecutor}; +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +const V0: &str = "OBLIT-MARKER-V0-c41f9e2a-original-plaintext"; +const V1: &str = "OBLIT-MARKER-V1-7d03b6aa-first-edit-plaintext"; +const V2: &str = "OBLIT-MARKER-V2-e85a1f30-second-edit-plaintext"; + +/// Read every file under `.januskey/`, gunzipping `.gz` files (falling back +/// to raw bytes if decompression fails), and return the markers found. +fn markers_in_store(root: &Path, markers: &[&str]) -> Vec { + let mut found = Vec::new(); + for entry in walkdir(root.join(".januskey")) { + let raw = fs::read(&entry).unwrap(); + let mut bytes = raw.clone(); + if entry.extension().is_some_and(|e| e == "gz") { + let mut out = Vec::new(); + if GzDecoder::new(raw.as_slice()).read_to_end(&mut out).is_ok() { + bytes = out; + } + } + let text = String::from_utf8_lossy(&bytes); + let raw_text = String::from_utf8_lossy(&raw); + for m in markers { + if text.contains(m) || raw_text.contains(m) { + found.push(format!("{} in {}", m, entry.display())); + } + } + } + found +} + +/// Recursively list regular files under `dir`. +fn walkdir(dir: PathBuf) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + for e in fs::read_dir(&d).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + out.push(p); + } + } + } + out +} + +/// Open the obliteration manager at its canonical location in the store. +fn manager(jk: &JanusKey) -> ObliterationManager { + ObliterationManager::new(jk.root.join(".januskey").join(OBLITERATION_LOG_FILE)).unwrap() +} + +/// Create `path` with `v0`, then modify it to each of `edits` in turn, via +/// the library operations. Returns the id of the last operation. +fn create_and_edit(jk: &mut JanusKey, path: &Path, v0: &str, edits: &[&str]) -> String { + let mut ex = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); + let mut last = ex + .execute(FileOperation::Create { + path: path.to_path_buf(), + content: v0.as_bytes().to_vec(), + }) + .unwrap(); + for e in edits { + last = ex + .execute(FileOperation::Modify { + path: path.to_path_buf(), + new_content: e.as_bytes().to_vec(), + }) + .unwrap(); + } + last.id +} + +/// Every stored (blob-backed) hash recorded for entries mentioning `path`. +fn stored_hashes_for(jk: &JanusKey, path: &Path) -> Vec { + let mut v: Vec = jk + .metadata_store + .operations() + .iter() + .filter(|op| op.path == path || op.path_secondary.as_deref() == Some(path)) + .flat_map(|op| op.content_hash.iter().chain(op.new_content_hash.iter())) + .filter(|h| jk.content_store.exists(h)) + .cloned() + .collect(); + v.dedup(); + v +} + +/// Create + two edits, then obliterate: blobs, log entries, plaintext and undo are all gone. +#[test] +fn obliterate_scrubs_blobs_log_and_undo() { + let tmp = TempDir::new().unwrap(); + let mut jk = JanusKey::init(tmp.path()).unwrap(); + assert!(jk.config.compression, "test assumes gzip blobs (default)"); + let file = jk.root.join("secret.txt"); + let last_id = create_and_edit(&mut jk, &file, V0, &[V1, V2]); + + // ---- Positive control: the probes see the prior plaintext. ---- + let plaintext: HashMap = [V0, V1, V2] + .iter() + .map(|v| (ContentHash::from_string(v), *v)) + .collect(); + let hashes = stored_hashes_for(&jk, &file); + assert!( + hashes.contains(&ContentHash::from_string(V0)) + && hashes.contains(&ContentHash::from_string(V1)), + "expected blobs for V0 and V1, got {hashes:?}" + ); + for h in &hashes { + let p = jk.content_store.content_path(h); + assert!(p.exists(), "blob {} missing before obliterate", p.display()); + assert!(p.extension().is_some_and(|e| e == "gz")); + let got = jk.content_store.retrieve(h).unwrap(); + assert_eq!(got, plaintext[h].as_bytes()); + } + let before = markers_in_store(&jk.root, &[V0, V1]); + assert_eq!(before.len(), 2, "walk must find V0 and V1: {before:?}"); + assert!(jk + .metadata_store + .operations() + .iter() + .any(|op| op.path == file)); + let blob_paths: Vec = hashes + .iter() + .map(|h| jk.content_store.content_path(h)) + .collect(); + + // ---- Obliterate (relative spelling, to exercise path normalisation). ---- + let mut mgr = manager(&jk); + let report = obliterate_path( + &mut jk, + &mut mgr, + Path::new("secret.txt"), + Some("test".into()), + Some("GDPR Article 17".into()), + ) + .unwrap(); + assert!(report.file_proof.is_some()); + assert_eq!(report.blob_records.len(), hashes.len()); + assert!(report.retained_shared.is_empty()); + assert_eq!(report.purged_operation_ids.len(), 3); + + // ---- Nothing recoverable. ---- + assert!(!file.exists()); + for (h, p) in hashes.iter().zip(&blob_paths) { + assert!(!p.exists(), "blob {} survived", p.display()); + assert!(jk.content_store.retrieve(h).is_err()); + } + assert!(jk + .metadata_store + .operations() + .iter() + .all(|op| op.path != file && op.path_secondary.as_deref() != Some(&*file))); + let after = markers_in_store(&jk.root, &[V0, V1, V2, "secret.txt"]); + assert!(after.is_empty(), "plaintext/path survived: {after:?}"); + // Reopen from disk: the purge was persisted. + let mut jk2 = JanusKey::open(tmp.path()).unwrap(); + assert_eq!(jk2.metadata_store.count(), 0); + let mut ex = OperationExecutor::new(&jk2.content_store, &mut jk2.metadata_store); + assert!( + ex.undo(&last_id).is_err(), + "undo must fail after obliterate" + ); + // The shreds are recorded (hashes only, never content). + assert_eq!(manager(&jk2).count(), 1 + hashes.len()); +} + +/// A blob deduplicated with another path survives, and that path can still undo. +#[test] +fn obliterate_keeps_blob_shared_with_another_path() { + let tmp = TempDir::new().unwrap(); + let mut jk = JanusKey::init(tmp.path()).unwrap(); + let shared = "OBLIT-SHARED-9b2e44c1-identical-bytes-in-two-files"; + let a1 = "OBLIT-A1-03f7c2d8-only-in-a"; + let a2 = "OBLIT-A2-5e1a9b7f-only-in-a"; + let b1 = "OBLIT-B1-c6d2e0a4-only-in-b"; + let a = jk.root.join("a.txt"); + let b = jk.root.join("b.txt"); + create_and_edit(&mut jk, &a, shared, &[a1, a2]); + let b_last = create_and_edit(&mut jk, &b, shared, &[b1]); + + let shared_h = ContentHash::from_string(shared); + let a1_h = ContentHash::from_string(a1); + // Positive control: both blobs present and readable. + assert_eq!( + jk.content_store.retrieve(&shared_h).unwrap(), + shared.as_bytes() + ); + assert_eq!(jk.content_store.retrieve(&a1_h).unwrap(), a1.as_bytes()); + + let mut mgr = manager(&jk); + let report = obliterate_path(&mut jk, &mut mgr, &a, None, None).unwrap(); + assert!(report.retained_shared.contains(&shared_h)); + + // a's unshared blob is gone; the shared one is intact. + assert!(!jk.content_store.content_path(&a1_h).exists()); + assert!(jk.content_store.retrieve(&a1_h).is_err()); + assert_eq!( + jk.content_store.retrieve(&shared_h).unwrap(), + shared.as_bytes() + ); + assert!(markers_in_store(&jk.root, &[a1, a2, "a.txt"]).is_empty()); + + // b's history is untouched and its undo still restores the shared bytes. + assert!(b.exists()); + let mut ex = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); + ex.undo(&b_last).unwrap(); + assert_eq!(fs::read_to_string(&b).unwrap(), shared); +} + +/// `delete` then `obliterate`: no working file, but the stored history is still scrubbed. +#[test] +fn obliterate_after_delete_scrubs_history() { + let tmp = TempDir::new().unwrap(); + let mut jk = JanusKey::init(tmp.path()).unwrap(); + let file = jk.root.join("gone.txt"); + create_and_edit(&mut jk, &file, V0, &[V1]); + { + let mut ex = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); + ex.execute(FileOperation::Delete { path: file.clone() }) + .unwrap(); + } + assert!(!file.exists()); + // Positive control: deleted content is still recoverable from the store. + assert_eq!(markers_in_store(&jk.root, &[V0, V1]).len(), 2); + + let mut mgr = manager(&jk); + let report = obliterate_path(&mut jk, &mut mgr, &file, None, None).unwrap(); + assert!(report.file_proof.is_none()); + assert!(!report.blob_records.is_empty()); + assert!(markers_in_store(&jk.root, &[V0, V1, "gone.txt"]).is_empty()); + assert_eq!(jk.metadata_store.count(), 0); +} + +/// A path with neither a working file nor history is reported as not found. +#[test] +fn obliterate_untracked_absent_path_errs() { + let tmp = TempDir::new().unwrap(); + let mut jk = JanusKey::init(tmp.path()).unwrap(); + let mut mgr = manager(&jk); + assert!(obliterate_path(&mut jk, &mut mgr, Path::new("nope.txt"), None, None).is_err()); +} + +/// End to end through the `jk` binary: modify, obliterate, then undo finds nothing. +#[test] +fn cli_obliterate_scrubs_store_and_undo_has_nothing() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + Command::cargo_bin("jk") + .unwrap() + .arg("init") + .arg(dir) + .assert() + .success(); + fs::write(dir.join("f.txt"), V0).unwrap(); + Command::cargo_bin("jk") + .unwrap() + .args(["-C"]) + .arg(dir) + .args(["-y", "modify", "s/V0/VX/", "f.txt"]) + .assert() + .success(); + // Positive control: the modify stored V0 as a gzip blob. + assert_eq!(markers_in_store(dir, &[V0]).len(), 1); + + Command::cargo_bin("jk") + .unwrap() + .args(["-C"]) + .arg(dir) + .args(["-y", "obliterate", "f.txt"]) + .assert() + .success(); + assert!(!dir.join("f.txt").exists()); + assert!(markers_in_store(dir, &[V0, "OBLIT-MARKER-VX", "f.txt"]).is_empty()); + + let out = Command::cargo_bin("jk") + .unwrap() + .args(["-C"]) + .arg(dir) + .arg("undo") + .assert() + .success() + .get_output() + .stdout + .clone(); + assert!( + String::from_utf8_lossy(&out).contains("Nothing to undo"), + "undo output: {}", + String::from_utf8_lossy(&out) + ); +} diff --git a/crates/reversible-core/src/metadata.rs b/crates/reversible-core/src/metadata.rs index dd7849e..7beb3a2 100644 --- a/crates/reversible-core/src/metadata.rs +++ b/crates/reversible-core/src/metadata.rs @@ -9,6 +9,7 @@ use crate::content_store::ContentHash; use crate::error::{Result, ReversibleError}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -224,7 +225,11 @@ impl OperationMetadata { } } -/// Serializable operation log (the append-only ledger) +/// Serializable operation log (the append-only ledger). +/// +/// Entries are only ever appended, with two sanctioned exceptions that remove +/// entries: [`MetadataStore::prune`] (history retention) and +/// [`MetadataStore::purge_path`] (obliteration / GDPR Art. 17 erasure). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OperationLog { /// Version for format compatibility @@ -364,6 +369,61 @@ impl MetadataStore { self.log.operations.len() } + /// Remove every operation whose primary path or secondary path equals + /// `path`, persist the log, and return every content hash + /// (`content_hash` and `new_content_hash`) the removed entries referenced. + /// + /// Matching is exact `PathBuf` equality against the path as it was stored + /// (operations record whatever path the caller passed, which is not + /// necessarily canonical); callers that need to match several spellings + /// of one file call this once per spelling. The returned list may contain + /// duplicates and hashes still referenced by surviving entries — check + /// [`MetadataStore::referenced_hashes`] before discarding any blob. + /// + /// This is the obliteration exception to the append-only ledger: purged + /// entries can no longer be undone. + pub fn purge_path(&mut self, path: &Path) -> Result> { + Ok(self + .purge_path_entries(path)? + .into_iter() + .flat_map(|op| op.content_hash.into_iter().chain(op.new_content_hash)) + .collect()) + } + + /// Like [`MetadataStore::purge_path`], but return the removed operation + /// entries themselves (so callers can record their IDs as well as their + /// content hashes). Persists the log only if something was removed. + pub fn purge_path_entries(&mut self, path: &Path) -> Result> { + let matches = + |op: &OperationMetadata| op.path == path || op.path_secondary.as_deref() == Some(path); + let (removed, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut self.log.operations) + .into_iter() + .partition(|op| matches(op)); + self.log.operations = kept; + if !removed.is_empty() { + self.save()?; + } + Ok(removed) + } + + /// Return the set of content hashes (`content_hash` and + /// `new_content_hash`) referenced by any operation currently in the log. + /// + /// Used to decide whether a content-store blob is shared with another + /// surviving history entry and must therefore be kept. + pub fn referenced_hashes(&self) -> HashSet { + self.log + .operations + .iter() + .flat_map(|op| { + op.content_hash + .iter() + .chain(op.new_content_hash.iter()) + .cloned() + }) + .collect() + } + /// Prune old operations (keep last N) pub fn prune(&mut self, keep: usize) -> Result { let original_count = self.log.operations.len(); @@ -399,6 +459,83 @@ mod tests { assert_eq!(OperationType::Modify.inverse(), OperationType::Modify); } + /// Build a Modify entry for `path` with the given before/after hashes. + fn modify_entry(path: &str, before: &[u8], after: &[u8]) -> OperationMetadata { + OperationMetadata::new(OperationType::Modify, PathBuf::from(path)) + .with_content_hash(ContentHash::from_bytes(before)) + .with_new_content_hash(ContentHash::from_bytes(after)) + } + + /// `purge_path` removes primary and secondary matches, returns their hashes, persists. + #[test] + fn test_purge_path_removes_primary_and_secondary_matches() { + let tmp = TempDir::new().unwrap(); + let log_path = tmp.path().join("metadata.json"); + let mut store = MetadataStore::new(log_path.clone()).unwrap(); + + store.append(modify_entry("/a.txt", b"a0", b"a1")).unwrap(); + store.append(modify_entry("/b.txt", b"b0", b"b1")).unwrap(); + // A move *into* /a.txt: matched via path_secondary. + let mv = OperationMetadata::new(OperationType::Move, PathBuf::from("/c.txt")) + .with_secondary_path(PathBuf::from("/a.txt")) + .with_content_hash(ContentHash::from_bytes(b"c0")); + store.append(mv).unwrap(); + + let hashes = store.purge_path(Path::new("/a.txt")).unwrap(); + let got: HashSet<_> = hashes.into_iter().collect(); + let want: HashSet<_> = [b"a0".as_ref(), b"a1", b"c0"] + .iter() + .map(|b| ContentHash::from_bytes(b)) + .collect(); + assert_eq!(got, want); + assert_eq!(store.count(), 1); + assert_eq!(store.operations()[0].path, PathBuf::from("/b.txt")); + + // Persisted: a reopened store sees the purge. + let reopened = MetadataStore::new(log_path).unwrap(); + assert_eq!(reopened.count(), 1); + assert!(reopened + .operations() + .iter() + .all(|op| op.path != Path::new("/a.txt") + && op.path_secondary.as_deref() != Some(Path::new("/a.txt")))); + } + + /// `purge_path` on an unknown path removes nothing. + #[test] + fn test_purge_path_no_match_is_noop() { + let tmp = TempDir::new().unwrap(); + let mut store = MetadataStore::new(tmp.path().join("metadata.json")).unwrap(); + store.append(modify_entry("/a.txt", b"a0", b"a1")).unwrap(); + assert!(store.purge_path(Path::new("/zzz")).unwrap().is_empty()); + assert_eq!(store.count(), 1); + } + + /// `referenced_hashes` reflects only surviving entries after a purge. + #[test] + fn test_referenced_hashes_tracks_survivors() { + let tmp = TempDir::new().unwrap(); + let mut store = MetadataStore::new(tmp.path().join("metadata.json")).unwrap(); + store + .append(modify_entry("/a.txt", b"shared", b"a1")) + .unwrap(); + store + .append(modify_entry("/b.txt", b"shared", b"b1")) + .unwrap(); + + let before = store.referenced_hashes(); + assert_eq!(before.len(), 3); + assert!(before.contains(&ContentHash::from_bytes(b"a1"))); + + store.purge_path(Path::new("/a.txt")).unwrap(); + let after = store.referenced_hashes(); + // The shared hash survives via /b.txt; a1 does not. + assert!(after.contains(&ContentHash::from_bytes(b"shared"))); + assert!(after.contains(&ContentHash::from_bytes(b"b1"))); + assert!(!after.contains(&ContentHash::from_bytes(b"a1"))); + assert_eq!(after.len(), 2); + } + #[test] fn test_metadata_store() { let tmp = TempDir::new().unwrap();