use std::path::{Path, PathBuf};
use std::time::Duration;
use fathomdb_engine::{Engine, EngineError, PreparedWrite};
use fathomdb_schema::SQLITE_SUFFIX;
use rusqlite::Connection;
use tempfile::TempDir;
fn db_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(format!("{name}{SQLITE_SUFFIX}"))
}
fn wal_path(path: &Path) -> PathBuf {
let mut raw = path.as_os_str().to_os_string();
raw.push("-wal");
PathBuf::from(raw)
}
fn file_contains_bytes(path: &Path, needle: &str) -> bool {
let Ok(bytes) = std::fs::read(path) else { return false };
let needle = needle.as_bytes();
if needle.is_empty() || bytes.len() < needle.len() {
return false;
}
bytes.windows(needle.len()).any(|window| window == needle)
}
fn write_node(engine: &Engine, body: &str, source_id: &str, logical_id: Option<&str>) -> u64 {
engine
.write(&[PreparedWrite::Node {
kind: "doc".to_string(),
body: body.to_string(),
source_id: fathomdb_engine::SourceId::new(source_id).expect("test source id"),
logical_id: logical_id.map(str::to_string),
state: fathomdb_engine::InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
}])
.expect("write")
.cursor
}
fn register_collection(engine: &Engine, name: &str, kind: &str) {
engine
.write(&[PreparedWrite::AdminSchema {
name: name.to_string(),
kind: kind.to_string(),
schema_json: "{}".to_string(),
retention_json: "{}".to_string(),
}])
.expect("register collection");
}
fn append_op_record(engine: &Engine, collection: &str, record_key: &str, body: &str) {
engine
.write(&[PreparedWrite::OpStore {
collection: collection.to_string(),
record_key: record_key.to_string(),
schema_id: None,
body: body.to_string(),
}])
.expect("op-store append");
}
#[test]
fn erasure_wal_bytes_absent() {
const SECRET: &str = "QZXERASUREWALSECRETTOKENQZX";
const CONTROL: &str = "QZXRETAINEDCONTROLTOKENQZX";
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "wal_bytes");
let opened = Engine::open(&path).expect("open");
write_node(&opened.engine, &format!("classified {SECRET} payload"), "S1", Some("victim-1"));
write_node(&opened.engine, &format!("ordinary {CONTROL} payload"), "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
let wal = wal_path(&path);
assert!(
file_contains_bytes(&path, SECRET) || file_contains_bytes(&wal, SECRET),
"seed: erasable body must be on disk before erasure"
);
opened.engine.excise_source("S1").expect("excise_source");
assert!(
!file_contains_bytes(&path, SECRET),
"erased body still present as bytes in the database file at rest"
);
assert!(
!file_contains_bytes(&wal, SECRET),
"erased body still present as bytes in the -wal file at rest: the erasure verb never \
checkpointed the write-ahead log, so `grep` recovers the erased content"
);
assert!(
file_contains_bytes(&path, CONTROL) || file_contains_bytes(&wal, CONTROL),
"non-excised body must survive the erasure verb's WAL checkpoint"
);
opened.engine.close().unwrap();
}
#[test]
fn erasure_busy_yields_incomplete_not_success() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "wal_busy");
let opened = Engine::open(&path).expect("open");
write_node(&opened.engine, "busy-path erasable body", "S1", Some("victim-1"));
write_node(&opened.engine, "busy-path retained body", "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
let blocker = Connection::open(&path).expect("blocker connection");
blocker.execute_batch("BEGIN").expect("begin blocker read txn");
let _pinned: u64 = blocker
.query_row("SELECT COUNT(*) FROM canonical_nodes", [], |row| row.get(0))
.expect("pin a WAL read snapshot");
let err = opened
.engine
.excise_source("S1")
.expect_err("excise must not report success while the WAL cannot be truncated");
assert!(
matches!(err, EngineError::ErasureIncomplete { .. }),
"expected a typed ErasureIncomplete refusal, got {err:?}"
);
blocker.execute_batch("COMMIT").expect("release blocker");
drop(blocker);
opened.engine.close().unwrap();
}
fn telemetry_fixture(name: &str) -> (TempDir, String) {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, name);
let sink = dir.path().join("telemetry.jsonl");
let opened = Engine::open(&path).expect("open");
opened.engine.enable_telemetry(sink.to_str().unwrap()).expect("enable telemetry");
write_node(&opened.engine, "erasable zeta payload", "S1", Some("victim-1"));
write_node(&opened.engine, "retained omega payload", "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
let victim_hits = opened.engine.search("zeta").expect("search zeta");
assert!(
victim_hits.results.iter().any(|h| h.id.to_prefixed() == "l:victim-1"),
"fixture: the victim id must be captured into the sink"
);
let control_hits = opened.engine.search("omega").expect("search omega");
assert!(
control_hits.results.iter().any(|h| h.id.to_prefixed() == "l:control-1"),
"fixture: the control id must be captured into the sink"
);
let existing = std::fs::read_to_string(&sink).expect("read sink");
std::fs::write(
&sink,
format!("{existing}{}\n", r#"{"type":"operator_note","note":"UNRELATEDEVALHISTORY"}"#),
)
.expect("append operator note");
opened.engine.excise_source("S1").expect("excise_source");
let after = std::fs::read_to_string(&sink).expect("read sink after erasure");
opened.engine.close().unwrap();
(dir, after)
}
#[test]
fn purged_id_absent_from_sink() {
let (_dir, after) = telemetry_fixture("telemetry_redact");
assert!(
!after.contains("l:victim-1"),
"erased stable id still persisted in the telemetry sink:\n{after}"
);
}
#[test]
fn unrelated_sink_records_survive() {
let (_dir, after) = telemetry_fixture("telemetry_survive");
assert!(
after.contains("UNRELATEDEVALHISTORY"),
"redaction destroyed an unrelated operator record; the sink must not be truncated:\n{after}"
);
assert!(
after.contains("l:control-1"),
"redaction destroyed a retained id's telemetry record:\n{after}"
);
assert!(
after.lines().filter(|l| !l.trim().is_empty()).count() >= 3,
"redaction dropped whole records; expected >= 3 surviving lines:\n{after}"
);
}
#[cfg(unix)]
#[test]
fn telemetry_redaction_retry_completes_after_failure() {
use std::os::unix::fs::PermissionsExt;
fn chmod(dir: &Path, mode: u32) {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "telemetry_retry");
let sink_dir = dir.path().join("sink");
std::fs::create_dir(&sink_dir).expect("create sink dir");
let sink = sink_dir.join("telemetry.jsonl");
let opened = Engine::open(&path).expect("open");
opened.engine.enable_telemetry(sink.to_str().unwrap()).expect("enable telemetry");
write_node(&opened.engine, "erasable zeta payload", "S1", Some("victim-1"));
write_node(&opened.engine, "retained omega payload", "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
let hits = opened.engine.search("zeta").expect("search zeta");
assert!(
hits.results.iter().any(|h| h.id.to_prefixed() == "l:victim-1"),
"fixture: the victim id must be captured into the sink"
);
let control_hits = opened.engine.search("omega").expect("search omega");
assert!(
control_hits.results.iter().any(|h| h.id.to_prefixed() == "l:control-1"),
"fixture: the control id must be captured into the sink, so the selectivity \
assertion below is not vacuous"
);
assert!(
std::fs::read_to_string(&sink).expect("read sink").contains("l:victim-1"),
"fixture: the victim id must be on disk in the sink before the erasure"
);
chmod(&sink_dir, 0o555);
let probe = sink_dir.join("write-probe");
if std::fs::write(&probe, b"x").is_ok() {
let _ = std::fs::remove_file(&probe);
chmod(&sink_dir, 0o755);
panic!(
"cannot inject a redaction failure: the sink directory is still writable at mode \
0555 (running as root?) — this test would be vacuous"
);
}
let err = opened
.engine
.excise_source("S1")
.expect_err("excise must not report success while the sink cannot be redacted");
match &err {
EngineError::ErasureIncomplete { stage, .. } => assert_eq!(
stage, "telemetry_redaction",
"the erasure must fail at the redaction stage, not somewhere else"
),
other => panic!("expected ErasureIncomplete{{telemetry_redaction}}, got {other:?}"),
}
chmod(&sink_dir, 0o755);
let probe_conn = Connection::open(&path).expect("probe connection");
let remaining: u64 = probe_conn
.query_row("SELECT COUNT(*) FROM canonical_nodes WHERE source_id = 'S1'", [], |row| {
row.get(0)
})
.expect("count S1 rows");
drop(probe_conn);
assert_eq!(remaining, 0, "precondition: the delete transaction committed");
assert!(
std::fs::read_to_string(&sink).expect("read sink").contains("l:victim-1"),
"precondition: the leaked id is still in the sink after the failed redaction"
);
opened.engine.excise_source("S1").expect("the retry must complete the pending redaction");
let after = std::fs::read_to_string(&sink).expect("read sink after retry");
assert!(
!after.contains("l:victim-1"),
"the retry reported SUCCESS while the erased stable id is STILL in the telemetry \
sink — an erasure verb must never report success on an incomplete erasure \
(R-20-E5):\n{after}"
);
assert!(
after.contains("l:control-1"),
"the retry destroyed a retained id's telemetry record:\n{after}"
);
opened.engine.close().unwrap();
}
#[test]
fn op_store_record_erasable_by_key() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "op_store_erase");
let opened = Engine::open(&path).expect("open");
register_collection(&opened.engine, "events", "append_only_log");
append_op_record(&opened.engine, "events", "subject-a", r#"{"pii":"ERASABLERECORDBODY"}"#);
append_op_record(&opened.engine, "events", "subject-a", r#"{"pii":"ERASABLERECORDBODY2"}"#);
append_op_record(&opened.engine, "events", "subject-b", r#"{"pii":"RETAINEDRECORDBODY"}"#);
let report = opened
.engine
.excise_collection_record("events", "subject-a")
.expect("excise_collection_record");
assert_eq!(report.records_excised, 2, "both versions of the keyed record must be erased");
opened.engine.close().unwrap();
let conn = Connection::open(&path).expect("open sqlite");
let remaining: Vec<(String, String)> = conn
.prepare(
"SELECT record_key, payload_json FROM operational_mutations
WHERE collection_name = 'events' ORDER BY id",
)
.unwrap()
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert!(
!remaining.iter().any(|(k, _)| k == "subject-a"),
"erased record key survives in operational_mutations: {remaining:?}"
);
assert!(
remaining.iter().any(|(k, _)| k == "subject-b"),
"non-erased record must survive: {remaining:?}"
);
let audit_leak: u64 = conn
.query_row(
"SELECT COUNT(*) FROM operational_mutations WHERE record_key = 'subject-a'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(audit_leak, 0, "erasure audit must not persist the erased record key verbatim");
}
#[test]
fn erasure_audit_survives_retention_sweep() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "audit_durability");
let opened = Engine::open(&path).expect("open");
write_node(&opened.engine, "auditable erasable body", "S1", Some("victim-1"));
opened.engine.drain(10_000).expect("drain");
opened.engine.excise_source("S1").expect("excise_source");
let audit_rows_before = count_audit_rows(&opened.engine);
assert_eq!(audit_rows_before, 1, "fixture: one excise_source_audit row");
register_collection(&opened.engine, "events", "append_only_log");
opened.engine.set_provenance_row_cap_for_test(Some(4));
for i in 0..60 {
append_op_record(&opened.engine, "events", &format!("k{i}"), &format!(r#"{{"n":{i}}}"#));
}
let total = opened.engine.provenance_row_count_for_test().expect("row count");
assert!(total <= 12, "fixture: the sweep must actually have evicted rows (total = {total})");
assert_eq!(
count_audit_rows(&opened.engine),
1,
"the erasure audit row was swept away by enforce_provenance_retention: the proof of \
erasure is destructible and shares a retention pool with the payloads it must prove erased"
);
opened.engine.close().unwrap();
}
fn count_audit_rows(engine: &Engine) -> u64 {
let rows = engine
.read_collection("excise_source_audit", None, 1000)
.expect("read excise_source_audit");
rows.len() as u64
}
fn raw_collection_row_count(path: &Path, collection: &str) -> u64 {
let conn = Connection::open(path).expect("probe connection");
let count: u64 = conn
.query_row(
"SELECT COUNT(*) FROM operational_mutations WHERE collection_name = ?1",
[collection],
|row| row.get(0),
)
.expect("count collection rows");
count
}
#[cfg(unix)]
fn pending_redaction_fixture(
name: &str,
) -> (TempDir, PathBuf, PathBuf, fathomdb_engine::OpenedEngine) {
use std::os::unix::fs::PermissionsExt;
fn chmod(dir: &Path, mode: u32) {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
let dir = TempDir::new().unwrap();
let path = db_path(&dir, name);
let sink_dir = dir.path().join("sink");
std::fs::create_dir(&sink_dir).expect("create sink dir");
let sink = sink_dir.join("telemetry.jsonl");
let opened = Engine::open(&path).expect("open");
opened.engine.enable_telemetry(sink.to_str().unwrap()).expect("enable telemetry");
write_node(&opened.engine, "erasable zeta payload", "S1", Some("victim-1"));
write_node(&opened.engine, "retained omega payload", "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
opened.engine.search("zeta").expect("search zeta");
opened.engine.search("omega").expect("search omega");
assert!(
std::fs::read_to_string(&sink).expect("read sink").contains("l:victim-1"),
"fixture: the victim id must be on disk in the sink before the erasure"
);
chmod(&sink_dir, 0o555);
let probe = sink_dir.join("write-probe");
if std::fs::write(&probe, b"x").is_ok() {
let _ = std::fs::remove_file(&probe);
chmod(&sink_dir, 0o755);
panic!(
"cannot inject a redaction failure: the sink directory is still writable at mode \
0555 (running as root?) — this fixture would be vacuous"
);
}
let err = opened
.engine
.excise_source("S1")
.expect_err("excise must not report success while the sink cannot be redacted");
assert!(
matches!(&err, EngineError::ErasureIncomplete { stage, .. } if stage == "telemetry_redaction"),
"fixture: expected ErasureIncomplete{{telemetry_redaction}}, got {err:?}"
);
chmod(&sink_dir, 0o755);
let pending = raw_collection_row_count(&path, "erasure_pending_redaction");
assert_eq!(pending, 1, "fixture: exactly one durable pending-redaction obligation");
(dir, path, sink, opened)
}
#[cfg(unix)]
#[test]
fn erasure_bookkeeping_collections_are_not_excisable() {
let (_dir, path, sink, opened) = pending_redaction_fixture("bookkeeping_protected");
let err = opened
.engine
.excise_collection_record("erasure_pending_redaction", "excise_source")
.expect_err(
"excise_collection_record must REFUSE the engine-internal pending-redaction queue: \
deleting an outstanding erasure obligation lets the next verb report success on an \
incomplete erasure (R-20-E5)",
);
assert!(
format!("{err:?}").to_lowercase().contains("erasure"),
"the refusal must name the erasure-bookkeeping reason, got {err:?}"
);
assert_eq!(
raw_collection_row_count(&path, "erasure_pending_redaction"),
1,
"the durable pending-redaction obligation was deleted by excise_collection_record"
);
let audit_before = raw_collection_row_count(&path, "excise_source_audit");
assert_eq!(audit_before, 1, "fixture: one excise_source_audit row");
opened
.engine
.excise_collection_record("excise_source_audit", "S1")
.expect_err("excise_collection_record must REFUSE the erasure-audit collection");
assert_eq!(
raw_collection_row_count(&path, "excise_source_audit"),
1,
"the erasure audit row was deleted one-by-one by excise_collection_record; the HITL \
ruling requires an auditable record of the deletion event"
);
opened
.engine
.excise_collection_record("excise_record_audit", "anything")
.expect_err("excise_collection_record must REFUSE the record-erasure audit collection");
opened.engine.excise_source("S1").expect("the retry must complete the pending redaction");
let after = std::fs::read_to_string(&sink).expect("read sink after retry");
assert!(
!after.contains("l:victim-1"),
"an erasure verb reported SUCCESS while the erased stable id is STILL in the telemetry \
sink — the pending obligation was lost (R-20-E5):\n{after}"
);
assert!(after.contains("l:control-1"), "the retry destroyed a retained id's record:\n{after}");
assert_eq!(
raw_collection_row_count(&path, "erasure_pending_redaction"),
0,
"a discharged obligation must be retired from the queue"
);
register_collection(&opened.engine, "events", "append_only_log");
append_op_record(&opened.engine, "events", "subject-a", r#"{"pii":"BODY"}"#);
opened
.engine
.excise_collection_record("events", "subject-a")
.expect("ordinary op-store records must remain excisable");
opened.engine.close().unwrap();
}
#[test]
fn rotated_telemetry_sink_is_not_treated_as_redacted() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "sink_rotated");
let sink_dir = dir.path().join("sink");
std::fs::create_dir(&sink_dir).expect("create sink dir");
let sink = sink_dir.join("telemetry.jsonl");
let opened = Engine::open(&path).expect("open");
opened.engine.enable_telemetry(sink.to_str().unwrap()).expect("enable telemetry");
write_node(&opened.engine, "erasable zeta payload", "S1", Some("victim-1"));
write_node(&opened.engine, "retained omega payload", "S2", Some("control-1"));
opened.engine.drain(10_000).expect("drain");
opened.engine.search("zeta").expect("search zeta");
opened.engine.search("omega").expect("search omega");
assert!(
std::fs::read_to_string(&sink).expect("read sink").contains("l:victim-1"),
"fixture: the victim id must be in the sink before rotation"
);
let rotated = sink_dir.join("telemetry.jsonl.1");
std::fs::rename(&sink, &rotated).expect("rotate sink");
let err = opened.engine.excise_source("S1").expect_err(
"a moved/rotated sink must NOT be treated as successfully redacted: the erased stable \
ids are still readable under the rotated name, so the erasure is incomplete",
);
match &err {
EngineError::ErasureIncomplete { stage, .. } => {
assert_eq!(stage, "telemetry_redaction", "wrong stage for a rotated sink")
}
other => panic!("expected ErasureIncomplete{{telemetry_redaction}}, got {other:?}"),
}
let rotated_body = std::fs::read_to_string(&rotated).expect("read rotated sink");
assert!(
rotated_body.contains("l:victim-1"),
"fixture: the rotated file must still hold the erased id, else the test proves nothing"
);
assert_eq!(
raw_collection_row_count(&path, "erasure_pending_redaction"),
1,
"the pending obligation must survive a NotFound sink, not be cleared by it"
);
std::fs::rename(&rotated, &sink).expect("restore sink");
opened.engine.excise_source("S1").expect("retry after restoring the sink");
let after = std::fs::read_to_string(&sink).expect("read sink after retry");
assert!(!after.contains("l:victim-1"), "the retry left the erased id in the sink:\n{after}");
assert!(after.contains("l:control-1"), "the retry destroyed a retained id's record:\n{after}");
opened.engine.close().unwrap();
}
#[test]
fn erasure_wal_retry_is_bounded() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "wal_bounded");
let opened = Engine::open(&path).expect("open");
write_node(&opened.engine, "bounded retry body", "S1", Some("victim-1"));
opened.engine.drain(10_000).expect("drain");
let blocker = Connection::open(&path).expect("blocker connection");
blocker.execute_batch("BEGIN").expect("begin");
let _pinned: u64 = blocker
.query_row("SELECT COUNT(*) FROM canonical_nodes", [], |row| row.get(0))
.expect("pin snapshot");
let started = std::time::Instant::now();
let _ = opened.engine.excise_source("S1");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"the bounded WAL retry must give up quickly; took {elapsed:?}"
);
blocker.execute_batch("COMMIT").expect("release");
drop(blocker);
opened.engine.close().unwrap();
}