From efb409c3a248adbac4e112e5c6f0c055779c5584 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:10:09 +0100 Subject: [PATCH] fix(obliterate): scrub content-store blobs and log entries for the path `jk obliterate` only shredded the working file; every prior version stayed recoverable from .januskey/content (gzip blobs) and the operation log, and `jk undo` could resurrect it. - reversible-core MetadataStore: add purge_path / purge_path_entries (remove entries whose path or path_secondary matches, persist, return the referenced hashes) and referenced_hashes over surviving entries. - obliteration: add obliterate_path(jk, manager, path, ..) which shreds the working file if present, shreds every blob referenced only by the path's entries (blobs deduplicated with another path are kept), then purges the entries. Blobs are shredded before the log is purged so a failed shred leaves the command re-runnable. Stored paths are matched after normalisation (relative to root, parent canonicalised). Each shred is recorded in .januskey/obliterations.json via the new ObliterationManager::record_proof; chaining into the keyed AuditLog is deferred to J2-3. Removes the TODO(product). - jk obliterate: use obliterate_path when the working dir has a .januskey/ store (also handles delete-then-obliterate, where the working file is already gone); exit non-zero if any path failed. - tests/obliteration_cas_test.rs: positive-controlled checks that blobs, log entries, decompressed plaintext and undo are gone; shared blob kept and other path still undoable; delete-then-obliterate; CLI end to end. Co-Authored-By: Claude Opus 5.5 --- crates/januskey-cli/src/main.rs | 65 +++- crates/januskey-cli/src/obliteration.rs | 205 +++++++++++- .../tests/obliteration_cas_test.rs | 309 ++++++++++++++++++ crates/reversible-core/src/metadata.rs | 139 +++++++- 4 files changed, 699 insertions(+), 19 deletions(-) create mode 100644 crates/januskey-cli/tests/obliteration_cas_test.rs 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();