Watch
1
0
Fork
You've already forked souveraine
0

sessiond: the forensic trail leaves tmpfs, gains bounds and a hash chain

This commit is contained in:
Fimeg 2026-07-26 11:24:41 -04:00
commit 4d0c7a1c0b

View file

@ -327,11 +327,40 @@ impl SourceHealthTable {
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed, // state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
// last sensor input was proximity-far at T-30s." // 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. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForensicEntry { pub struct ForensicEntry {
/// Monotonic sequence (from the audit trail). /// Monotonic sequence (from the audit trail).
pub seq: u64, 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). /// Wall-clock timestamp (seconds since epoch).
pub ts: u64, pub ts: u64,
/// What happened. /// What happened.
@ -419,23 +448,117 @@ pub struct StateSnapshot {
/// Thread-safe; entries can be added from any thread. /// Thread-safe; entries can be added from any thread.
pub struct ForensicLog { pub struct ForensicLog {
entries: Arc<Mutex<Vec<ForensicEntry>>>, 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 { impl ForensicLog {
/// Open the durable trail at its standard path.
pub fn new() -> Self { 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 { Self {
entries: Arc::new(Mutex::new(Vec::new())), 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. /// Where the trail is being written, or `None` when it is memory-only.
/// The entry is also written to the forensic log file if configured. 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) { 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 { let entry = ForensicEntry {
seq: *seq, seq: w.next_seq,
prev: w.last_hash.clone(),
ts: std::time::SystemTime::now() ts: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@ -444,24 +567,9 @@ impl ForensicLog {
snapshot, snapshot,
reason: reason.to_string(), reason: reason.to_string(),
}; };
*seq += 1; w.next_seq += 1;
drop(seq); w.write(&entry);
drop(w);
// 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);
}
}
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.push(entry); 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. /// Path to the forensic log file.
fn forensic_log_path() -> std::path::PathBuf { ///
let runtime = std::env::var("XDG_RUNTIME_DIR") /// `$XDG_STATE_HOME/souveraine/forensic.jsonl`, which is `~/.local/state` in
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/share", h))) /// practice and is where the XDG spec puts logs — the same directory as
.unwrap_or_else(|_| "/tmp".to_string()); /// `crashes.log`. It was `$XDG_RUNTIME_DIR` until 2026-07-26, which is tmpfs:
std::path::PathBuf::from(runtime).join("souveraine/forensic.jsonl") /// 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 // Re-export for use in protocol.rs
@ -665,7 +974,7 @@ impl SensorEvidence {
impl DeviceStateMachine { impl DeviceStateMachine {
pub fn new() -> Self { pub fn new() -> Self {
Self { let machine = Self {
state: DeviceState::Active, state: DeviceState::Active,
sensor_evidence: SensorEvidence::default(), sensor_evidence: SensorEvidence::default(),
forensic: ForensicLog::new(), forensic: ForensicLog::new(),
@ -679,7 +988,23 @@ impl DeviceStateMachine {
// Loaded, not defaulted: the user's Auto-Lock choices are settings, // Loaded, not defaulted: the user's Auto-Lock choices are settings,
// and a setting that reverts on reboot is not a setting. // and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(), 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. /// 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.source_down_after, SOURCE_DOWN_AFTER);
assert_eq!(p.evidence_ttl, EVIDENCE_TTL); 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);
}
} }