sessiond: the forensic trail leaves tmpfs, gains bounds and a hash chain
This commit is contained in:
parent
01fda8ab87
commit
4d0c7a1c0b
1 changed files with 523 additions and 31 deletions
|
|
@ -327,11 +327,40 @@ impl SourceHealthTable {
|
|||
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
|
||||
// last sensor input was proximity-far at T-30s."
|
||||
|
||||
/// How large the live trail may grow before it rotates.
|
||||
///
|
||||
/// The trail was unbounded until 2026-07-26 and measured at ~3.6 MB/day, so
|
||||
/// "unbounded" meant "fills the disk on a device that has no room for it".
|
||||
/// Bounding it is the other half of making it durable: a file that survives
|
||||
/// reboot is only an improvement if it also stops growing.
|
||||
pub const FORENSIC_MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// How many rotated generations are kept behind the live file.
|
||||
///
|
||||
/// Three files in total, so the ceiling is 12 MiB — about three days at the
|
||||
/// volume measured before proximity debounce (§9.5) exists, and considerably
|
||||
/// more once it does. The number is a floor on how far back an incident can be
|
||||
/// reconstructed, which is the only thing it is for.
|
||||
pub const FORENSIC_KEEP: usize = 2;
|
||||
|
||||
/// A forensic entry — one decision point in the device state machine.
|
||||
///
|
||||
/// `hash` is deliberately not a field here. It is computed over this struct's
|
||||
/// serialization and injected into the written line as the last key, exactly
|
||||
/// as `SessionAudit.qml` does, so one verifier reads both trails: strip the
|
||||
/// trailing `,"hash":"<hex>"`, close the object, SHA-256, compare.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForensicEntry {
|
||||
/// Monotonic sequence (from the audit trail).
|
||||
pub seq: u64,
|
||||
/// The hash of the previous entry, empty at the head of a chain.
|
||||
///
|
||||
/// §5 called this trail tamper-evident while it carried nothing but a
|
||||
/// sequence number, which detects a gap and nothing else — any past entry
|
||||
/// could be edited in place and the file still read as consistent. The
|
||||
/// chain is what the word was always claiming.
|
||||
#[serde(default)]
|
||||
pub prev: String,
|
||||
/// Wall-clock timestamp (seconds since epoch).
|
||||
pub ts: u64,
|
||||
/// What happened.
|
||||
|
|
@ -419,23 +448,117 @@ pub struct StateSnapshot {
|
|||
/// Thread-safe; entries can be added from any thread.
|
||||
pub struct ForensicLog {
|
||||
entries: Arc<Mutex<Vec<ForensicEntry>>>,
|
||||
next_seq: Arc<Mutex<u64>>,
|
||||
/// Sequence, chain head, and file health under **one** lock.
|
||||
///
|
||||
/// They were two locks and a write that happened after both were dropped,
|
||||
/// which was survivable while the only content was a sequence number and
|
||||
/// is not once entries chain: two threads could take the same `prev` and
|
||||
/// write in either order, and the resulting file would read as tampered.
|
||||
writer: Arc<Mutex<TrailWriter>>,
|
||||
}
|
||||
|
||||
/// The durable half of the trail: which file, where the chain is, and whether
|
||||
/// writing is currently working.
|
||||
struct TrailWriter {
|
||||
/// `None` means in-memory only — no writable home (LOUD at startup), or a
|
||||
/// test that has no business touching the real trail.
|
||||
path: Option<PathBuf>,
|
||||
next_seq: u64,
|
||||
/// Hash of the last entry written, which the next entry's `prev` carries.
|
||||
/// Deliberately **not** reset by rotation: the chain runs across the file
|
||||
/// boundary, so a rotated set verifies end to end rather than as three
|
||||
/// unrelated logs.
|
||||
last_hash: String,
|
||||
/// Bytes in the live file, tracked rather than stat'd per append.
|
||||
bytes: u64,
|
||||
/// The rotation threshold. A field rather than the constant read inline so
|
||||
/// the rotation rules can be tested at a few hundred bytes instead of by
|
||||
/// writing 12 MiB of real entries.
|
||||
max_bytes: u64,
|
||||
/// True while writes are failing, so the warning is one per outage edge
|
||||
/// instead of one per tick. A trail that cannot write is exactly the kind
|
||||
/// of failure that must not drown out what it was recording.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl ForensicLog {
|
||||
/// Open the durable trail at its standard path.
|
||||
pub fn new() -> Self {
|
||||
// Tests get an in-memory trail unless they ask for a file. Otherwise
|
||||
// every `DeviceStateMachine::new()` in the suite would append to the
|
||||
// developer's real trail and rotate it — the file-backed behaviour is
|
||||
// covered by tests that name their own path.
|
||||
#[cfg(test)]
|
||||
let path = None;
|
||||
#[cfg(not(test))]
|
||||
let path = match forensic_log_path() {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
warn!(
|
||||
"[device-state] no HOME or XDG_STATE_HOME — the forensic trail is MEMORY-ONLY this run and dies with the daemon"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
Self::open(path)
|
||||
}
|
||||
|
||||
/// Open the trail at an explicit path.
|
||||
#[cfg(test)]
|
||||
pub fn with_path(path: PathBuf) -> Self {
|
||||
Self::open_bounded(Some(path), FORENSIC_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// Open the trail with an explicit rotation threshold. Tests only — the
|
||||
/// live bound is `FORENSIC_MAX_BYTES` and is not a per-caller choice.
|
||||
#[cfg(test)]
|
||||
pub fn with_path_bounded(path: PathBuf, max_bytes: u64) -> Self {
|
||||
Self::open_bounded(Some(path), max_bytes)
|
||||
}
|
||||
|
||||
fn open(path: Option<PathBuf>) -> Self {
|
||||
Self::open_bounded(path, FORENSIC_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn open_bounded(path: Option<PathBuf>, max_bytes: u64) -> Self {
|
||||
let writer = match path {
|
||||
Some(path) => TrailWriter::open(path, max_bytes),
|
||||
None => TrailWriter::memory_only(),
|
||||
};
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(Vec::new())),
|
||||
next_seq: Arc::new(Mutex::new(0)),
|
||||
writer: Arc::new(Mutex::new(writer)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a forensic entry. The seq is auto-incremented.
|
||||
/// The entry is also written to the forensic log file if configured.
|
||||
/// Where the trail is being written, or `None` when it is memory-only.
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.writer
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.path
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// How the chain the daemon just opened relates to the one before it.
|
||||
pub fn chain_state(&self) -> &'static str {
|
||||
let w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if w.path.is_none() {
|
||||
"memory-only"
|
||||
} else if w.next_seq == 0 {
|
||||
"new"
|
||||
} else {
|
||||
"resumed"
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a forensic entry. The seq is auto-incremented and the entry is
|
||||
/// chained to the one before it, then written to the durable trail.
|
||||
pub fn append(&self, event: ForensicEvent, snapshot: StateSnapshot, reason: &str) {
|
||||
let mut seq = self.next_seq.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = ForensicEntry {
|
||||
seq: *seq,
|
||||
seq: w.next_seq,
|
||||
prev: w.last_hash.clone(),
|
||||
ts: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
|
|
@ -444,24 +567,9 @@ impl ForensicLog {
|
|||
snapshot,
|
||||
reason: reason.to_string(),
|
||||
};
|
||||
*seq += 1;
|
||||
drop(seq);
|
||||
|
||||
// Write to forensic log file (append-only JSONL).
|
||||
if let Ok(line) = serde_json::to_string(&entry) {
|
||||
let path = forensic_log_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
w.next_seq += 1;
|
||||
w.write(&entry);
|
||||
drop(w);
|
||||
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
entries.push(entry);
|
||||
|
|
@ -481,12 +589,213 @@ impl ForensicLog {
|
|||
}
|
||||
}
|
||||
|
||||
impl TrailWriter {
|
||||
fn memory_only() -> Self {
|
||||
Self {
|
||||
path: None,
|
||||
next_seq: 0,
|
||||
last_hash: String::new(),
|
||||
bytes: 0,
|
||||
max_bytes: FORENSIC_MAX_BYTES,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the trail, continuing the existing chain where there is one.
|
||||
fn open(path: PathBuf, max_bytes: u64) -> Self {
|
||||
let mut w = Self {
|
||||
path: Some(path.clone()),
|
||||
next_seq: 0,
|
||||
last_hash: String::new(),
|
||||
bytes: 0,
|
||||
max_bytes,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
warn!(
|
||||
"[device-state] cannot create {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
||||
dir.display()
|
||||
);
|
||||
w.path = None;
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
let existing = match std::fs::read_to_string(&path) {
|
||||
Ok(body) => body,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return w,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[device-state] cannot read the existing trail at {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
||||
path.display()
|
||||
);
|
||||
w.path = None;
|
||||
return w;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(last) = existing.lines().rev().find(|l| !l.trim().is_empty()) else {
|
||||
return w; // present but empty: a fresh chain, not a damaged one
|
||||
};
|
||||
|
||||
match serde_json::from_str::<serde_json::Value>(last) {
|
||||
Ok(v) => {
|
||||
w.next_seq = v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0) + 1;
|
||||
w.last_hash = v
|
||||
.get("hash")
|
||||
.and_then(|h| h.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
w.bytes = existing.len() as u64;
|
||||
info!(
|
||||
"[device-state] forensic trail resumed at {} (seq {}, {} KiB)",
|
||||
path.display(),
|
||||
w.next_seq,
|
||||
w.bytes / 1024
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Do not append to a trail we cannot chain onto, and do not
|
||||
// delete it either — a truncated tail is evidence of something.
|
||||
// It is rotated aside and a clean chain starts, LOUDLY.
|
||||
warn!(
|
||||
"[device-state] the tail of {} is not parseable ({e}) — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
|
||||
path.display()
|
||||
);
|
||||
w.rotate();
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Serialize, hash, and write one entry. Every failure is loud once.
|
||||
fn write(&mut self, entry: &ForensicEntry) {
|
||||
if self.path.is_none() {
|
||||
return;
|
||||
}
|
||||
let body = match serde_json::to_string(entry) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
// Not a file problem: the entry itself will not serialize.
|
||||
// Never silent — a decision that cannot be recorded is one the
|
||||
// trail would otherwise imply never happened.
|
||||
warn!("[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED", entry.seq);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let hash = sha256_hex(&body);
|
||||
// The hash goes in as the last key, so the hashed bytes are the line
|
||||
// with `,"hash":"<hex>"` removed and the object closed again.
|
||||
let line = match body.strip_suffix('}') {
|
||||
Some(open) => format!("{open},\"hash\":\"{hash}\"}}\n"),
|
||||
None => {
|
||||
warn!("[device-state] forensic entry seq {} serialized to something that is not an object — NOT RECORDED", entry.seq);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if self.bytes + line.len() as u64 > self.max_bytes {
|
||||
self.rotate();
|
||||
}
|
||||
|
||||
let Some(path) = self.path.clone() else { return };
|
||||
let written = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(line.as_bytes())
|
||||
});
|
||||
|
||||
match written {
|
||||
Ok(()) => {
|
||||
self.bytes += line.len() as u64;
|
||||
if self.failed {
|
||||
self.failed = false;
|
||||
info!(
|
||||
"[device-state] forensic trail at {} is writable again",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
// The chain only advances on a line that actually landed.
|
||||
// Advancing it on a failed write would leave the next entry
|
||||
// pointing at a `prev` no file contains, which reads as
|
||||
// tampering rather than as the outage it is.
|
||||
self.last_hash = hash;
|
||||
}
|
||||
Err(e) => {
|
||||
if !self.failed {
|
||||
self.failed = true;
|
||||
warn!(
|
||||
"[device-state] CANNOT WRITE the forensic trail at {} ({e}) — decisions are being taken and not recorded",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift the generations along and start a new live file.
|
||||
///
|
||||
/// `last_hash` survives this on purpose: the first entry of the new file
|
||||
/// carries the hash of the last entry of the rotated one, so a rotated set
|
||||
/// verifies as one chain. A verifier reading a single file in isolation
|
||||
/// finds a non-empty `prev` on line 1, which is the correct answer — its
|
||||
/// predecessor is the next file along, not nothing.
|
||||
fn rotate(&mut self) {
|
||||
let Some(path) = self.path.clone() else { return };
|
||||
let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
|
||||
|
||||
let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
|
||||
for n in (1..FORENSIC_KEEP).rev() {
|
||||
let _ = std::fs::rename(gen(n), gen(n + 1));
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&path, gen(1)) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
warn!(
|
||||
"[device-state] cannot rotate the forensic trail at {} ({e}) — it will keep growing",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.bytes = 0;
|
||||
info!(
|
||||
"[device-state] forensic trail rotated at {} bytes, keeping {} generations",
|
||||
self.max_bytes, FORENSIC_KEEP
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(body: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(body.as_bytes());
|
||||
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Path to the forensic log file.
|
||||
fn forensic_log_path() -> std::path::PathBuf {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR")
|
||||
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/share", h)))
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
std::path::PathBuf::from(runtime).join("souveraine/forensic.jsonl")
|
||||
///
|
||||
/// `$XDG_STATE_HOME/souveraine/forensic.jsonl`, which is `~/.local/state` in
|
||||
/// practice and is where the XDG spec puts logs — the same directory as
|
||||
/// `crashes.log`. It was `$XDG_RUNTIME_DIR` until 2026-07-26, which is tmpfs:
|
||||
/// RAM on a 3.5 GB phone, erased on every reboot. §5 calls this trail
|
||||
/// tamper-evident and pairs it with the audit hash chain; a file that
|
||||
/// evaporates when the device restarts cannot be either. Nothing is migrated
|
||||
/// from the old location because there is never anything there to migrate.
|
||||
///
|
||||
/// `None` when there is no home to write to. There is no `/tmp` fallback: a
|
||||
/// trail nobody can find later is not a trail, and pretending otherwise is how
|
||||
/// this one spent a month looking durable.
|
||||
#[cfg(not(test))]
|
||||
fn forensic_log_path() -> Option<PathBuf> {
|
||||
let base = std::env::var_os("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?;
|
||||
Some(base.join("souveraine").join("forensic.jsonl"))
|
||||
}
|
||||
|
||||
// Re-export for use in protocol.rs
|
||||
|
|
@ -665,7 +974,7 @@ impl SensorEvidence {
|
|||
|
||||
impl DeviceStateMachine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
let machine = Self {
|
||||
state: DeviceState::Active,
|
||||
sensor_evidence: SensorEvidence::default(),
|
||||
forensic: ForensicLog::new(),
|
||||
|
|
@ -679,7 +988,23 @@ impl DeviceStateMachine {
|
|||
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
|
||||
// and a setting that reverts on reboot is not a setting.
|
||||
policy: DeviceStatePolicy::load(),
|
||||
}
|
||||
};
|
||||
|
||||
// First entry of the run, and the only one that proves the trail is
|
||||
// writable before something worth recording needs it. It also marks
|
||||
// the boot boundary, which the trail could not show while it lived on
|
||||
// tmpfs — every reboot simply produced an empty file.
|
||||
machine.record_decision(
|
||||
"trail-opened",
|
||||
serde_json::json!({
|
||||
"path": machine.forensic.path().map(|p| p.display().to_string()),
|
||||
"chain": machine.forensic.chain_state(),
|
||||
"max_bytes": FORENSIC_MAX_BYTES,
|
||||
"generations": FORENSIC_KEEP,
|
||||
}),
|
||||
"sessiond started and opened the forensic trail",
|
||||
);
|
||||
machine
|
||||
}
|
||||
|
||||
/// The clock. Called on a fixed interval by the daemon.
|
||||
|
|
@ -2426,4 +2751,171 @@ mod tests {
|
|||
assert_eq!(p.source_down_after, SOURCE_DOWN_AFTER);
|
||||
assert_eq!(p.evidence_ttl, EVIDENCE_TTL);
|
||||
}
|
||||
|
||||
// ── The trail itself: durable, bounded, chained ───────────────────
|
||||
|
||||
fn a_snapshot() -> StateSnapshot {
|
||||
DeviceStateMachine::new().snapshot("test", false, false, false, "", false)
|
||||
}
|
||||
|
||||
fn write_n(log: &ForensicLog, n: usize) {
|
||||
let snap = a_snapshot();
|
||||
for i in 0..n {
|
||||
log.append(
|
||||
ForensicEvent::Heartbeat,
|
||||
snap.clone(),
|
||||
&format!("entry {i}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute a line's hash the way an external verifier must: strip the
|
||||
/// trailing `hash` key, close the object, SHA-256 what is left.
|
||||
fn rehash(line: &str) -> String {
|
||||
let cut = line.rfind(",\"hash\":").expect("the hash is the last key");
|
||||
sha256_hex(&format!("{}}}", &line[..cut]))
|
||||
}
|
||||
|
||||
fn chain_of(path: &std::path::Path) -> Vec<(u64, String, String)> {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|line| {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("a parseable entry");
|
||||
let hash = v["hash"].as_str().expect("every entry carries its hash");
|
||||
assert_eq!(rehash(line), hash, "the hash must cover the entry body");
|
||||
(
|
||||
v["seq"].as_u64().expect("seq"),
|
||||
v["prev"].as_str().unwrap_or_default().to_string(),
|
||||
hash.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_chains_to_the_one_before_it() {
|
||||
// §5 called the trail tamper-evident while it carried only a seq,
|
||||
// which detects a deleted line and nothing else. This is the claim.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
write_n(&ForensicLog::with_path(path.clone()), 4);
|
||||
|
||||
let chain = chain_of(&path);
|
||||
assert_eq!(chain.len(), 4);
|
||||
assert_eq!(chain[0].1, "", "the head of a new chain has no predecessor");
|
||||
for pair in chain.windows(2) {
|
||||
assert_eq!(pair[1].0, pair[0].0 + 1, "seq is contiguous");
|
||||
assert_eq!(pair[1].1, pair[0].2, "prev is the previous entry's hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trail_survives_a_reopen_and_keeps_one_chain() {
|
||||
// The whole point of leaving tmpfs: a reboot must not erase what the
|
||||
// machine decided, and the chain must not restart at zero either.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
write_n(&ForensicLog::with_path(path.clone()), 3);
|
||||
write_n(&ForensicLog::with_path(path.clone()), 3);
|
||||
|
||||
let chain = chain_of(&path);
|
||||
assert_eq!(chain.len(), 6, "the earlier run is still there");
|
||||
assert_eq!(chain[3].0, 3, "seq continues across the restart");
|
||||
assert_eq!(
|
||||
chain[3].1, chain[2].2,
|
||||
"the new run chains onto the old one rather than starting over"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trail_rotates_and_the_set_stays_bounded() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
// Small enough that a handful of entries fills a generation.
|
||||
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 400);
|
||||
|
||||
let live = std::fs::metadata(&path).expect("a live file").len();
|
||||
assert!(live <= 2048, "the live file is bounded: {live}");
|
||||
|
||||
let mut total = live;
|
||||
for n in 1..=FORENSIC_KEEP {
|
||||
let g = path.with_extension(format!("jsonl.{n}"));
|
||||
total += std::fs::metadata(&g).map(|m| m.len()).unwrap_or(0);
|
||||
}
|
||||
assert!(
|
||||
total <= 2048 * (FORENSIC_KEEP as u64 + 1),
|
||||
"the whole set is bounded: {total}"
|
||||
);
|
||||
// And nothing beyond the kept generations survives.
|
||||
assert!(
|
||||
!path
|
||||
.with_extension(format!("jsonl.{}", FORENSIC_KEEP + 1))
|
||||
.exists(),
|
||||
"generations past the keep count are deleted, not accumulated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_runs_across_a_rotation() {
|
||||
// A rotated set must verify as one chain, or bounding the trail would
|
||||
// have quietly cost the property that durability was for.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 12);
|
||||
|
||||
let rotated = chain_of(&path.with_extension("jsonl.1"));
|
||||
let live = chain_of(&path);
|
||||
assert!(!rotated.is_empty() && !live.is_empty(), "a rotation happened");
|
||||
let last_rotated = rotated.last().expect("rotated entries");
|
||||
assert_eq!(
|
||||
live[0].1, last_rotated.2,
|
||||
"the first live entry chains onto the last rotated one"
|
||||
);
|
||||
assert_eq!(live[0].0, last_rotated.0 + 1, "seq does not restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_tail_is_rotated_aside_not_appended_to() {
|
||||
// Appending onto a line we cannot parse would produce a chain that
|
||||
// fails verification forever after, which reads as tampering. The
|
||||
// damaged file is evidence, so it is kept, not deleted.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
write_n(&ForensicLog::with_path(path.clone()), 2);
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.expect("open");
|
||||
writeln!(f, "{{\"seq\":2,\"trunca").expect("write a torn line");
|
||||
}
|
||||
|
||||
write_n(&ForensicLog::with_path(path.clone()), 1);
|
||||
|
||||
let kept = path.with_extension("jsonl.1");
|
||||
assert!(kept.exists(), "the damaged trail is kept");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&kept).unwrap().lines().count(),
|
||||
3,
|
||||
"including the torn line"
|
||||
);
|
||||
let fresh = chain_of(&path);
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].0, 0, "the new chain starts clean");
|
||||
assert_eq!(fresh[0].1, "", "and does not claim a predecessor it cannot verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trail_with_nowhere_to_write_still_answers_ipc() {
|
||||
// Memory-only is the honest answer when there is no home; it must not
|
||||
// take the in-memory buffer down with it, because the IPC query is how
|
||||
// the shell reads the trail.
|
||||
let log = ForensicLog::new();
|
||||
assert_eq!(log.chain_state(), "memory-only");
|
||||
write_n(&log, 3);
|
||||
assert_eq!(log.recent(10).len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue