//! `witness` — append-only hash-chained event log for the cog. //! //! ADR-116 §2.2 promises a tamper-evident audit log so regulated //! deployments (healthcare, education, shared housing) can prove //! that the state transitions a Seed reported were actually emitted //! by the cog at the time they were emitted, not retroactively //! rewritten. //! //! This module is the **pure hash-chain primitive**: //! //! * SHA-256 over deterministic canonical bytes, //! * `prev_hash` chains each event to its predecessor, //! * `WitnessChain::append` is the only mutator — no random //! access, no replace, no delete. //! //! Ed25519 signing layers on top once the key-management story //! lands (probably as `witness_signing.rs` reading a key from the //! Seed's secure store). Keeping the hash chain and the signature //! in separate modules means the chain primitive can be tested //! without a key fixture, and a future key rotation doesn't //! invalidate the chain itself — only the signature over each //! event. //! //! ## Why hash-chain first, not Merkle tree? //! //! The cog emits witness events at the rate of semantic-primitive //! transitions — a few per minute in steady state, dozens during //! a fall-detection / room-transition event. Linear scan is fine //! at that rate; we save the Merkle complexity for a future tier //! when the chain spans days and the auditor wants O(log n) //! inclusion proofs. use std::io::{self, BufRead, Write}; use sha2::{Digest, Sha256}; /// 32-byte hash output. Lifted into a newtype so a future migration /// to Blake3 / SHA-512 surfaces as a type change instead of a /// silent length difference in serialized witness bundles. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WitnessHash(pub [u8; 32]); impl WitnessHash { /// Genesis hash — the predecessor of the first event. Sentinel /// "no prior event" value. pub const GENESIS: WitnessHash = WitnessHash([0u8; 32]); /// Lowercase hex without `0x` prefix. Matches the format the /// `cog-pose-estimation` manifest uses for `binary_sha256` so /// downstream tooling can apply one parser. pub fn to_hex(&self) -> String { let mut s = String::with_capacity(64); for b in self.0 { s.push_str(&format!("{b:02x}")); } s } /// Parse a 64-char lowercase-hex string back into a `WitnessHash`. /// Rejects wrong-length input and non-hex characters — used by /// the JSONL parser when reading audit bundles. pub fn from_hex(s: &str) -> Result { if s.len() != 64 { return Err(WitnessParseError::HashLength { found: s.len() }); } let mut out = [0u8; 32]; for (i, byte) in out.iter_mut().enumerate() { let lo = i * 2; *byte = u8::from_str_radix(&s[lo..lo + 2], 16) .map_err(|_| WitnessParseError::HashHex { at: lo })?; } Ok(WitnessHash(out)) } } /// A single witnessed event. Append-only — once committed to a /// `WitnessChain`, the fields here are immutable. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WitnessEvent { /// Zero-based sequence number. Strictly monotonically /// increasing within a chain — gaps mean the chain was /// truncated. pub seq: u64, /// Hash of the previous event, or [`WitnessHash::GENESIS`] for /// the first. pub prev_hash: WitnessHash, /// Unix epoch seconds at append time. Caller-supplied so the /// test suite isn't time-coupled; production uses /// `SystemTime::now()`. pub timestamp_unix_s: u64, /// Short stable kind tag — e.g. `"fall_risk_elevated"`, /// `"bed_exit"`, `"privacy_mode_toggled"`. Locked vocabulary /// in the future; free-form here until the semantic-primitive /// catalog stabilises. pub kind: String, /// Opaque payload bytes. Typically the JSON of the emitted MQTT /// state message so an auditor can re-derive what HA was told. pub payload: Vec, /// Hash of *this* event, computed over canonical bytes that /// include `prev_hash` — so reconstructing the chain proves /// nothing in the past was rewritten. pub this_hash: WitnessHash, } /// Compute the canonical-bytes form an event is hashed over. /// /// The format is intentionally simple and length-prefixed so a /// future migration can be staged with a `version` byte in front /// without ambiguity: /// /// ```text /// prev_hash[32] | seq:u64-be | ts:u64-be | kind_len:u32-be | kind | payload_len:u32-be | payload /// ``` /// /// Length-prefixing prevents the classic "concatenation forgery" /// attack where `"abc" + "def"` and `"ab" + "cdef"` would hash the /// same. pub fn canonical_bytes( prev_hash: WitnessHash, seq: u64, timestamp_unix_s: u64, kind: &str, payload: &[u8], ) -> Vec { let kind_bytes = kind.as_bytes(); let mut out = Vec::with_capacity(32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len()); out.extend_from_slice(&prev_hash.0); out.extend_from_slice(&seq.to_be_bytes()); out.extend_from_slice(×tamp_unix_s.to_be_bytes()); out.extend_from_slice(&(kind_bytes.len() as u32).to_be_bytes()); out.extend_from_slice(kind_bytes); out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); out.extend_from_slice(payload); out } /// Compute the SHA-256 hash for an event. pub fn hash_event( prev_hash: WitnessHash, seq: u64, timestamp_unix_s: u64, kind: &str, payload: &[u8], ) -> WitnessHash { let mut h = Sha256::new(); h.update(canonical_bytes(prev_hash, seq, timestamp_unix_s, kind, payload)); let digest = h.finalize(); let mut out = [0u8; 32]; out.copy_from_slice(&digest); WitnessHash(out) } /// In-memory append-only chain. Persistence (write to the Seed's /// `~/cognitum/witness//events.jsonl`) is a separate concern /// kept out of this module. #[derive(Debug, Default, Clone)] pub struct WitnessChain { events: Vec, } impl WitnessChain { pub fn new() -> Self { Self::default() } /// Last committed hash, or `GENESIS` if the chain is empty. pub fn tip(&self) -> WitnessHash { self.events .last() .map(|e| e.this_hash) .unwrap_or(WitnessHash::GENESIS) } pub fn len(&self) -> usize { self.events.len() } pub fn is_empty(&self) -> bool { self.events.is_empty() } /// Append a new event. Caller supplies the wall-clock so tests /// stay deterministic. pub fn append(&mut self, kind: &str, payload: &[u8], timestamp_unix_s: u64) -> &WitnessEvent { let prev_hash = self.tip(); let seq = self.events.len() as u64; let this_hash = hash_event(prev_hash, seq, timestamp_unix_s, kind, payload); self.events.push(WitnessEvent { seq, prev_hash, timestamp_unix_s, kind: kind.to_string(), payload: payload.to_vec(), this_hash, }); self.events.last().expect("just pushed") } pub fn events(&self) -> &[WitnessEvent] { &self.events } /// Stream every event to a JSONL sink. Each event becomes one /// line terminated by `\n`. Empty chains write zero bytes. /// /// The caller owns the writer — `File`, `BufWriter`, an /// in-memory `Vec` for tests — so this method never /// allocates beyond per-event line buffers. pub fn write_jsonl(&self, w: &mut W) -> io::Result<()> { for ev in &self.events { w.write_all(ev.to_jsonl_line().as_bytes())?; w.write_all(b"\n")?; } Ok(()) } /// Read a JSONL audit bundle into a fresh `WitnessChain`. Each /// non-empty line is parsed via `WitnessEvent::from_jsonl_line` /// (which re-verifies the stored hash), then the loaded chain /// is end-to-end verified via [`WitnessChain::verify`] to catch /// out-of-order events or replayed prefixes. /// /// Bundle errors surface with their `line_no` (1-indexed) so an /// auditor can point at the bad record. pub fn read_jsonl(r: R) -> Result { let mut chain = WitnessChain::new(); for (i, line_res) in r.lines().enumerate() { let line_no = i + 1; let line = line_res.map_err(|e| WitnessReadError::Io { line_no, msg: e.to_string(), })?; if line.trim().is_empty() { continue; // tolerate blank lines / trailing \n } let ev = WitnessEvent::from_jsonl_line(&line) .map_err(|source| WitnessReadError::Parse { line_no, source })?; chain.events.push(ev); } chain .verify() .map_err(|source| WitnessReadError::Verify { source })?; Ok(chain) } /// Verify every event's `this_hash` matches the canonical bytes, /// every `prev_hash` matches the predecessor's `this_hash`, and /// `seq` is gap-free starting at 0. /// /// Returns `Ok(())` on a sound chain or an `Err` with the first /// failing index + reason — auditor-friendly. pub fn verify(&self) -> Result<(), WitnessVerifyError> { let mut prev = WitnessHash::GENESIS; for (i, ev) in self.events.iter().enumerate() { if ev.seq != i as u64 { return Err(WitnessVerifyError::SeqGap { at: i, found: ev.seq }); } if ev.prev_hash != prev { return Err(WitnessVerifyError::PrevHashMismatch { at: i }); } let recomputed = hash_event( ev.prev_hash, ev.seq, ev.timestamp_unix_s, &ev.kind, &ev.payload, ); if recomputed != ev.this_hash { return Err(WitnessVerifyError::HashMismatch { at: i }); } prev = ev.this_hash; } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum WitnessVerifyError { #[error("seq gap at index {at}: expected {at}, found {found}")] SeqGap { at: usize, found: u64 }, #[error("prev_hash mismatch at index {at}")] PrevHashMismatch { at: usize }, #[error("this_hash mismatch at index {at} — event tampered")] HashMismatch { at: usize }, } #[derive(Debug, thiserror::Error)] pub enum WitnessReadError { #[error("io error at line {line_no}: {msg}")] Io { line_no: usize, msg: String }, #[error("parse error at line {line_no}: {source}")] Parse { line_no: usize, #[source] source: WitnessParseError, }, #[error("chain-level verify failed: {source}")] Verify { #[source] source: WitnessVerifyError, }, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum WitnessParseError { #[error("invalid JSON: {0}")] Json(String), #[error("missing required field `{0}`")] MissingField(&'static str), #[error("field `{field}` has wrong type")] WrongType { field: &'static str }, #[error("hash hex must be 64 chars, got {found}")] HashLength { found: usize }, #[error("hash hex parse error at byte offset {at}")] HashHex { at: usize }, #[error("payload hex parse error at byte offset {at}")] PayloadHex { at: usize }, #[error("payload hex must be even length, got {found}")] PayloadLength { found: usize }, #[error("recomputed hash does not match this_hash — bundle is forged or corrupted")] HashMismatch, } fn hex_encode(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push_str(&format!("{b:02x}")); } s } fn hex_decode(s: &str) -> Result, WitnessParseError> { if s.len() % 2 != 0 { return Err(WitnessParseError::PayloadLength { found: s.len() }); } let mut out = Vec::with_capacity(s.len() / 2); for i in (0..s.len()).step_by(2) { let byte = u8::from_str_radix(&s[i..i + 2], 16) .map_err(|_| WitnessParseError::PayloadHex { at: i })?; out.push(byte); } Ok(out) } impl WitnessEvent { /// Serialize one event to a single JSONL line (no trailing /// newline). The format is the audit-bundle wire shape; tools /// downstream parse it line-by-line with [`Self::from_jsonl_line`]. /// /// Field ordering is locked alphabetically for byte-stable /// output across rebuilds — auditors hash whole bundles, so a /// rebuild that reordered fields would silently invalidate /// archival hashes. /// /// Wire shape: /// /// ```json /// {"kind":"...","payload_hex":"...","prev_hash":"...","seq":N,"this_hash":"...","timestamp_unix_s":N} /// ``` pub fn to_jsonl_line(&self) -> String { // Hand-rolled instead of serde_derive so the wire-format // ordering is under direct test control. format!( "{{\"kind\":{kind},\"payload_hex\":\"{payload}\",\"prev_hash\":\"{prev}\",\"seq\":{seq},\"this_hash\":\"{this}\",\"timestamp_unix_s\":{ts}}}", kind = serde_json::to_string(&self.kind).expect("string is always serializable"), payload = hex_encode(&self.payload), prev = self.prev_hash.to_hex(), seq = self.seq, this = self.this_hash.to_hex(), ts = self.timestamp_unix_s, ) } /// Parse one JSONL line back into a `WitnessEvent`. Re-verifies /// the stored `this_hash` against the canonical bytes — a /// tampered bundle fires [`WitnessParseError::HashMismatch`] /// instead of silently loading forged events. pub fn from_jsonl_line(line: &str) -> Result { let v: serde_json::Value = serde_json::from_str(line).map_err(|e| WitnessParseError::Json(e.to_string()))?; let obj = v .as_object() .ok_or(WitnessParseError::WrongType { field: "" })?; let seq = obj .get("seq") .ok_or(WitnessParseError::MissingField("seq"))? .as_u64() .ok_or(WitnessParseError::WrongType { field: "seq" })?; let timestamp_unix_s = obj .get("timestamp_unix_s") .ok_or(WitnessParseError::MissingField("timestamp_unix_s"))? .as_u64() .ok_or(WitnessParseError::WrongType { field: "timestamp_unix_s", })?; let kind = obj .get("kind") .ok_or(WitnessParseError::MissingField("kind"))? .as_str() .ok_or(WitnessParseError::WrongType { field: "kind" })? .to_string(); let prev_hash = WitnessHash::from_hex( obj.get("prev_hash") .ok_or(WitnessParseError::MissingField("prev_hash"))? .as_str() .ok_or(WitnessParseError::WrongType { field: "prev_hash" })?, )?; let this_hash = WitnessHash::from_hex( obj.get("this_hash") .ok_or(WitnessParseError::MissingField("this_hash"))? .as_str() .ok_or(WitnessParseError::WrongType { field: "this_hash" })?, )?; let payload = hex_decode( obj.get("payload_hex") .ok_or(WitnessParseError::MissingField("payload_hex"))? .as_str() .ok_or(WitnessParseError::WrongType { field: "payload_hex", })?, )?; // Re-verify the stored hash. The on-disk hash is purely // declarative; this is what makes the JSONL a witness. let recomputed = hash_event(prev_hash, seq, timestamp_unix_s, &kind, &payload); if recomputed != this_hash { return Err(WitnessParseError::HashMismatch); } Ok(WitnessEvent { seq, prev_hash, timestamp_unix_s, kind, payload, this_hash, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn genesis_hash_is_all_zeros() { assert_eq!(WitnessHash::GENESIS.0, [0u8; 32]); } #[test] fn empty_chain_tip_is_genesis() { let c = WitnessChain::new(); assert_eq!(c.tip(), WitnessHash::GENESIS); assert!(c.is_empty()); } #[test] fn canonical_bytes_length_prefixing_prevents_ambiguity() { // Classic concatenation forgery: without length prefixes, // ("abc","def") and ("ab","cdef") would produce the same // hash. With them, they don't. let a = canonical_bytes(WitnessHash::GENESIS, 0, 0, "abc", b"def"); let b = canonical_bytes(WitnessHash::GENESIS, 0, 0, "ab", b"cdef"); assert_ne!(a, b); } #[test] fn canonical_bytes_starts_with_prev_hash() { // Locks the on-wire format. A future migration that flips // field order must bump a version byte and update this test. let bytes = canonical_bytes(WitnessHash([7u8; 32]), 1, 2, "k", b"p"); assert_eq!(&bytes[..32], &[7u8; 32]); } #[test] fn append_links_to_prev_hash() { let mut c = WitnessChain::new(); let h1 = c.append("a", b"1", 100).this_hash; let e2 = c.append("b", b"2", 101); assert_eq!(e2.prev_hash, h1); assert_eq!(e2.seq, 1); } #[test] fn sequence_is_monotonic_starting_at_zero() { let mut c = WitnessChain::new(); for i in 0..5 { c.append("k", &[i], 0); } for (i, ev) in c.events().iter().enumerate() { assert_eq!(ev.seq, i as u64); } } #[test] fn verify_passes_on_clean_chain() { let mut c = WitnessChain::new(); c.append("fall_risk_elevated", b"{}", 100); c.append("bed_exit", b"{}", 101); c.append("privacy_mode_toggled", br#"{"on":true}"#, 102); c.verify().expect("clean chain verifies"); } #[test] fn verify_catches_tampered_payload() { let mut c = WitnessChain::new(); c.append("a", b"original", 100); c.append("b", b"original2", 101); // Tamper with event 0's payload directly. c.events[0].payload = b"forged".to_vec(); let err = c.verify().unwrap_err(); assert!(matches!(err, WitnessVerifyError::HashMismatch { at: 0 })); } #[test] fn verify_catches_broken_prev_link() { let mut c = WitnessChain::new(); c.append("a", b"1", 100); c.append("b", b"2", 101); c.events[1].prev_hash = WitnessHash([0xff; 32]); let err = c.verify().unwrap_err(); assert!(matches!(err, WitnessVerifyError::PrevHashMismatch { at: 1 })); } #[test] fn verify_catches_seq_gap() { let mut c = WitnessChain::new(); c.append("a", b"1", 100); c.append("b", b"2", 101); c.events[1].seq = 99; let err = c.verify().unwrap_err(); assert!(matches!(err, WitnessVerifyError::SeqGap { at: 1, found: 99 })); } #[test] fn hash_to_hex_is_64_lowercase_chars() { let h = hash_event(WitnessHash::GENESIS, 0, 0, "k", b"p"); let hex = h.to_hex(); assert_eq!(hex.len(), 64); assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); } #[test] fn first_event_prev_hash_is_genesis() { // Auditor relies on this: a witness bundle that doesn't start // with prev_hash == GENESIS is either truncated or stitched // together from two chains. let mut c = WitnessChain::new(); let e = c.append("init", b"", 0); assert_eq!(e.prev_hash, WitnessHash::GENESIS); assert_eq!(e.seq, 0); } #[test] fn different_payloads_produce_different_hashes() { let h1 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"a"); let h2 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"b"); assert_ne!(h1, h2); } // ---- JSONL persistence ---- fn fresh_event() -> WitnessEvent { let mut c = WitnessChain::new(); c.append("fall_risk_elevated", br#"{"node":"kitchen"}"#, 1779512400); c.events()[0].clone() } #[test] fn jsonl_round_trip_preserves_all_fields() { let original = fresh_event(); let line = original.to_jsonl_line(); let parsed = WitnessEvent::from_jsonl_line(&line).expect("clean line round-trips"); assert_eq!(parsed, original); } #[test] fn jsonl_line_has_no_embedded_newline() { // JSONL is one record per line; an embedded \n in the // serialized form would corrupt the file format. let line = fresh_event().to_jsonl_line(); assert!(!line.contains('\n')); assert!(!line.contains('\r')); } #[test] fn jsonl_field_order_is_alphabetical_for_byte_stability() { // Auditors archive whole bundles and hash them — reordered // fields would silently invalidate archival hashes. Lock // the order with a substring check on a known event. let line = fresh_event().to_jsonl_line(); let order = ["kind", "payload_hex", "prev_hash", "seq", "this_hash", "timestamp_unix_s"]; let mut last = 0usize; for field in order { let pos = line.find(field).unwrap_or_else(|| panic!("missing field `{field}`")); assert!(pos > last, "field `{field}` out of alphabetical order"); last = pos; } } #[test] fn jsonl_parser_rejects_tampered_payload() { let original = fresh_event(); let line = original.to_jsonl_line(); // Flip one nibble in the payload hex — the stored hash // won't match the recomputed hash. let tampered = line.replacen("payload_hex\":\"7b", "payload_hex\":\"6b", 1); assert_ne!(line, tampered, "test fixture didn't flip a byte"); let err = WitnessEvent::from_jsonl_line(&tampered).unwrap_err(); assert!( matches!(err, WitnessParseError::HashMismatch), "expected HashMismatch, got {err:?}" ); } #[test] fn jsonl_parser_rejects_non_hex_hash() { // Replace the hex hash with non-hex chars — must fire a // structured error, not a panic. let original = fresh_event(); let line = original.to_jsonl_line(); let broken = line.replacen( &original.this_hash.to_hex()[..4], "ZZZZ", 1, ); let err = WitnessEvent::from_jsonl_line(&broken).unwrap_err(); assert!(matches!(err, WitnessParseError::HashHex { .. })); } #[test] fn jsonl_parser_rejects_missing_field() { let bad = r#"{"seq":0,"kind":"k","prev_hash":"00","this_hash":"00","timestamp_unix_s":1}"#; let err = WitnessEvent::from_jsonl_line(bad).unwrap_err(); // Missing payload_hex; should fire MissingField before any // hex decode happens. assert!(matches!(err, WitnessParseError::MissingField("payload_hex") | WitnessParseError::HashLength { .. })); } #[test] fn hex_encode_decode_round_trip() { let cases: &[&[u8]] = &[ b"", b"\x00", b"\xff", b"hello world", &[0x00, 0x01, 0xab, 0xcd, 0xef], ]; for c in cases { let encoded = hex_encode(c); let decoded = hex_decode(&encoded).unwrap(); assert_eq!(&decoded[..], *c, "round-trip failed for {c:?}"); } } #[test] fn hex_decode_rejects_odd_length() { let err = hex_decode("abc").unwrap_err(); assert!(matches!(err, WitnessParseError::PayloadLength { found: 3 })); } #[test] fn witness_hash_from_hex_round_trip() { let h = WitnessHash([0x12; 32]); let hex = h.to_hex(); let parsed = WitnessHash::from_hex(&hex).unwrap(); assert_eq!(parsed, h); } #[test] fn witness_hash_from_hex_rejects_wrong_length() { let err = WitnessHash::from_hex("ab").unwrap_err(); assert!(matches!(err, WitnessParseError::HashLength { found: 2 })); } // ---- file persistence (write_jsonl / read_jsonl) ---- #[test] fn write_jsonl_empty_chain_writes_zero_bytes() { let c = WitnessChain::new(); let mut buf = Vec::new(); c.write_jsonl(&mut buf).unwrap(); assert_eq!(buf, b""); } #[test] fn write_then_read_round_trips_multi_event_chain() { let mut written = WitnessChain::new(); written.append("a", b"first", 100); written.append("b", b"second", 101); written.append("c", br#"{"x":1}"#, 102); let mut buf = Vec::new(); written.write_jsonl(&mut buf).unwrap(); let read_back = WitnessChain::read_jsonl(buf.as_slice()).unwrap(); assert_eq!(read_back.len(), 3); assert_eq!(read_back.events(), written.events()); assert_eq!(read_back.tip(), written.tip()); } #[test] fn write_jsonl_separates_events_with_newline() { let mut c = WitnessChain::new(); c.append("a", b"1", 100); c.append("b", b"2", 101); let mut buf = Vec::new(); c.write_jsonl(&mut buf).unwrap(); let s = std::str::from_utf8(&buf).unwrap(); // Exactly N newlines for N events. assert_eq!(s.matches('\n').count(), 2); assert!(s.ends_with('\n')); } #[test] fn read_jsonl_tolerates_blank_lines() { let mut c = WitnessChain::new(); c.append("a", b"1", 100); c.append("b", b"2", 101); let mut buf = Vec::new(); c.write_jsonl(&mut buf).unwrap(); // Inject blanks — sometimes happens when files are edited. let with_blanks = format!( "\n{}\n\n", std::str::from_utf8(&buf).unwrap().trim_end() ); let read = WitnessChain::read_jsonl(with_blanks.as_bytes()).unwrap(); assert_eq!(read.len(), 2); } #[test] fn read_jsonl_surfaces_line_no_on_parse_error() { // Two good events, then one with a flipped payload byte. let mut c = WitnessChain::new(); c.append("a", b"1", 100); c.append("b", b"2", 101); let mut buf = Vec::new(); c.write_jsonl(&mut buf).unwrap(); let mut text = String::from_utf8(buf).unwrap(); let forged = c.events()[0].to_jsonl_line().replacen( "payload_hex\":\"31", "payload_hex\":\"32", 1, ); text.push_str(&forged); text.push('\n'); let err = WitnessChain::read_jsonl(text.as_bytes()).unwrap_err(); match err { WitnessReadError::Parse { line_no, .. } => assert_eq!(line_no, 3), other => panic!("expected Parse error at line 3, got {other:?}"), } } #[test] fn read_jsonl_chain_verify_catches_reordered_events() { // Build a chain, then write it out with the events swapped. // Each individual event still verifies its own hash (because // its prev_hash is internally consistent with what *it* // claimed), but the cross-event chain check fires. let mut original = WitnessChain::new(); original.append("a", b"1", 100); original.append("b", b"2", 101); let mut buf = Vec::new(); original.write_jsonl(&mut buf).unwrap(); let lines: Vec<&[u8]> = buf.split(|&b| b == b'\n').filter(|s| !s.is_empty()).collect(); // Reverse order, send through reader. let mut reversed: Vec = Vec::new(); reversed.extend_from_slice(lines[1]); reversed.push(b'\n'); reversed.extend_from_slice(lines[0]); reversed.push(b'\n'); let err = WitnessChain::read_jsonl(reversed.as_slice()).unwrap_err(); assert!(matches!(err, WitnessReadError::Verify { .. })); } #[test] fn read_jsonl_no_trailing_newline_still_works() { // BufRead's lines() handles the no-final-newline case; lock // the behavior so a future swap to a different reader can't // silently truncate the last event. let mut c = WitnessChain::new(); c.append("only", b"x", 100); let mut buf = Vec::new(); c.write_jsonl(&mut buf).unwrap(); // Strip the trailing \n. if buf.last() == Some(&b'\n') { buf.pop(); } let read = WitnessChain::read_jsonl(buf.as_slice()).unwrap(); assert_eq!(read.len(), 1); } }