sessiond: a torn tail is a hard reboot, not tampering — resume the chain
Measured: the hard reboot left the trail at exactly 4096 bytes with the last entry cut in half. Rotating that aside cost chain continuity at the one moment it is worth most.
This commit is contained in:
parent
43eee1e3c5
commit
f2a4b93ee3
1 changed files with 107 additions and 33 deletions
|
|
@ -694,37 +694,82 @@ impl TrailWriter {
|
|||
}
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
// Walk back to the last line that parses.
|
||||
//
|
||||
// A torn *final* line is the expected result of losing power mid-write,
|
||||
// not evidence of tampering — measured 2026-07-26, when a hard reboot
|
||||
// left the file at exactly 4096 bytes, a page boundary, with the last
|
||||
// entry cut in half. Treating that as tampering cost the chain its
|
||||
// continuity across precisely the event most worth investigating.
|
||||
//
|
||||
// So: discard the torn tail, resume from the last good entry, and say
|
||||
// so. A bad hash in the *middle* is a different claim and this does not
|
||||
// hide it — the verifier still reads every line, and everything up to
|
||||
// the tear stays intact and chained.
|
||||
let mut good_end = 0usize; // byte offset just past the last good line
|
||||
let mut last_good: Option<serde_json::Value> = None;
|
||||
for line in existing.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n');
|
||||
if trimmed.trim().is_empty() {
|
||||
good_end += line.len();
|
||||
continue;
|
||||
}
|
||||
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.
|
||||
match serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
Ok(v) => {
|
||||
good_end += line.len();
|
||||
last_good = Some(v);
|
||||
}
|
||||
Err(_) => break, // torn or damaged — everything from here goes
|
||||
}
|
||||
}
|
||||
|
||||
let discarded = existing.len() - good_end;
|
||||
if discarded > 0 {
|
||||
let Some(_) = last_good.as_ref() else {
|
||||
// Nothing in the file parses at all. That is not a torn tail;
|
||||
// it is a file we cannot chain onto. Keep it as evidence.
|
||||
warn!(
|
||||
"[device-state] the tail of {} is not parseable ({e}) — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
|
||||
"[device-state] no parseable entry in {} — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
|
||||
path.display()
|
||||
);
|
||||
w.rotate();
|
||||
return w;
|
||||
};
|
||||
warn!(
|
||||
"[device-state] discarding {discarded} torn bytes from the tail of {} — a write that did not reach disk, almost certainly a hard reboot; the chain resumes from the last intact entry",
|
||||
path.display()
|
||||
);
|
||||
if let Err(e) = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.and_then(|f| f.set_len(good_end as u64))
|
||||
{
|
||||
warn!(
|
||||
"[device-state] could not truncate the torn tail of {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
||||
path.display()
|
||||
);
|
||||
w.path = None;
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(v) = last_good else {
|
||||
return w; // present but empty: a fresh chain, not a damaged one
|
||||
};
|
||||
|
||||
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 = good_end as u64;
|
||||
info!(
|
||||
"[device-state] forensic trail resumed at {} (seq {}, {} KiB)",
|
||||
path.display(),
|
||||
w.next_seq,
|
||||
w.bytes / 1024
|
||||
);
|
||||
w
|
||||
}
|
||||
|
||||
|
|
@ -3260,30 +3305,59 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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.
|
||||
fn a_torn_tail_is_discarded_and_the_chain_resumes() {
|
||||
// Measured on hardware 2026-07-26: a hard reboot left the trail at
|
||||
// exactly 4096 bytes — a page boundary — with the final entry cut in
|
||||
// half. The first version treated that as tampering and rotated the
|
||||
// whole file aside, so every hard reboot started a new chain at the
|
||||
// one moment continuity is worth most.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forensic.jsonl");
|
||||
write_n(&ForensicLog::with_path(path.clone()), 2);
|
||||
write_n(&ForensicLog::with_path(path.clone()), 3);
|
||||
|
||||
// Tear the last write the way losing power does: a partial line, no
|
||||
// newline, mid-object.
|
||||
{
|
||||
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!(f, "{{\"seq\":3,\"prev\":\"abc\",\"ts\":17850").expect("tear it");
|
||||
}
|
||||
|
||||
write_n(&ForensicLog::with_path(path.clone()), 1);
|
||||
|
||||
assert!(
|
||||
!path.with_extension("jsonl.1").exists(),
|
||||
"a torn tail is not a rotation event"
|
||||
);
|
||||
let chain = chain_of(&path);
|
||||
assert_eq!(chain.len(), 4, "three intact entries plus the new one");
|
||||
assert_eq!(chain[3].0, 3, "seq continues rather than restarting");
|
||||
assert_eq!(
|
||||
chain[3].1, chain[2].2,
|
||||
"the entry after the tear chains onto the last intact one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_nothing_parseable_is_still_rotated_aside() {
|
||||
// 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");
|
||||
std::fs::write(&path, "this is not JSON at all\nnor is this\n").expect("write junk");
|
||||
|
||||
write_n(&ForensicLog::with_path(path.clone()), 1);
|
||||
|
||||
let kept = path.with_extension("jsonl.1");
|
||||
assert!(kept.exists(), "the damaged trail is kept");
|
||||
assert!(kept.exists(), "the unusable trail is kept as evidence");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&kept).unwrap().lines().count(),
|
||||
3,
|
||||
"including the torn line"
|
||||
2,
|
||||
"kept whole, not truncated"
|
||||
);
|
||||
let fresh = chain_of(&path);
|
||||
assert_eq!(fresh.len(), 1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue