//! P12 journal record/replay model. NOT the TurkFS v1 disk format. Only the //! disposable `journal_disk` experiment uses it; no production mount path or //! production-write gate does. //! SHA-256 detects corruption; it is not an authenticated storage protocol. use sha2::{Digest, Sha256}; pub const RECORD_BYTES: usize = 512; const MAGIC: &[u8; 8] = b"TKJRNL01"; const COMMIT: u64 = 0x544b_434f_4d4d_4954; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Error { Length, Magic, Version, Checksum, Uncommitted, Fields, Full, DuplicateInode, Conflict, DataHash, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Record { pub generation: u64, pub inode: u32, pub old_block: u64, pub new_block: u64, pub data_hash: [u8; 32], } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Pointer { pub generation: u64, pub block: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Replay { Apply(Pointer), AlreadyApplied, } fn checksum(bytes: &[u8]) -> [u8; 32] { let mut hash = Sha256::new(); hash.update(&bytes[..80]); hash.update(&bytes[112..504]); hash.finalize().into() } impl Record { fn validate(&self) -> Result<(), Error> { if self.generation == 0 || self.inode == 0 || self.old_block == 0 || self.new_block == 0 || self.old_block == self.new_block { return Err(Error::Fields); } Ok(()) } pub fn encode(&self, committed: bool) -> Result<[u8; RECORD_BYTES], Error> { self.validate()?; let mut bytes = [0; RECORD_BYTES]; bytes[..8].copy_from_slice(MAGIC); bytes[8..12].copy_from_slice(&1u32.to_le_bytes()); bytes[12..16].copy_from_slice(&(RECORD_BYTES as u32).to_le_bytes()); bytes[16..24].copy_from_slice(&self.generation.to_le_bytes()); bytes[24..28].copy_from_slice(&self.inode.to_le_bytes()); bytes[32..40].copy_from_slice(&self.old_block.to_le_bytes()); bytes[40..48].copy_from_slice(&self.new_block.to_le_bytes()); bytes[48..80].copy_from_slice(&self.data_hash); let digest = checksum(&bytes); bytes[80..112].copy_from_slice(&digest); if committed { bytes[504..].copy_from_slice(&COMMIT.to_le_bytes()); } Ok(bytes) } pub fn decode(bytes: &[u8]) -> Result { if bytes.len() != RECORD_BYTES { return Err(Error::Length); } if &bytes[..8] != MAGIC { return Err(Error::Magic); } if bytes[8..12] != 1u32.to_le_bytes() { return Err(Error::Version); } if bytes[12..16] != (RECORD_BYTES as u32).to_le_bytes() { return Err(Error::Length); } if bytes[80..112] != checksum(bytes) { return Err(Error::Checksum); } // K586 fuzz found accepted noncanonical sectors: reserved bytes were // covered by SHA but were silently discarded by decode→encode. A // valid digest cannot authorize unknown future fields in format v1. if bytes[28..32] .iter() .chain(bytes[112..504].iter()) .any(|b| *b != 0) { return Err(Error::Fields); } let record = Self { generation: u64::from_le_bytes(bytes[16..24].try_into().unwrap()), inode: u32::from_le_bytes(bytes[24..28].try_into().unwrap()), old_block: u64::from_le_bytes(bytes[32..40].try_into().unwrap()), new_block: u64::from_le_bytes(bytes[40..48].try_into().unwrap()), data_hash: bytes[48..80].try_into().unwrap(), }; record.validate()?; if bytes[504..] != COMMIT.to_le_bytes() { return Err(Error::Uncommitted); } Ok(record) } /// Replay is a pure decision; callers operate only on a disposable copy. /// No committed record may reference data that fails the recorded digest. pub fn replay(&self, current: Pointer, actual_data_hash: [u8; 32]) -> Result { self.validate()?; if actual_data_hash != self.data_hash { return Err(Error::DataHash); } if current == (Pointer { generation: self.generation, block: self.new_block, }) { return Ok(Replay::AlreadyApplied); } if current.block != self.old_block || current.generation.checked_add(1) != Some(self.generation) { return Err(Error::Conflict); } Ok(Replay::Apply(Pointer { generation: self.generation, block: self.new_block, })) } } pub struct Journal { entries: [Option; N], len: usize, } impl Default for Journal { fn default() -> Self { Self::new() } } impl Journal { pub const fn new() -> Self { Self { entries: [None; N], len: 0, } } pub fn len(&self) -> usize { self.len } pub fn is_empty(&self) -> bool { self.len == 0 } pub fn records(&self) -> impl Iterator { self.entries[..self.len].iter().flatten() } pub fn push(&mut self, record: Record) -> Result<(), Error> { record.validate()?; if self.len == N { return Err(Error::Full); } if self.entries[..self.len] .iter() .flatten() .any(|r| r.inode == record.inode) { return Err(Error::DuplicateInode); } self.entries[self.len] = Some(record); self.len += 1; Ok(()) } } #[cfg(test)] mod tests { extern crate std; use super::*; fn record() -> Record { Record { generation: 2, inode: 1, old_block: 19, new_block: 25, data_hash: Sha256::digest(b"new data").into(), } } #[test] fn p12_k542_journal_header_length_version_and_digest_are_checked() { let r = record(); let good = r.encode(true).unwrap(); assert_eq!(Record::decode(&good), Ok(r)); for offset in [0, 8, 12, 16, 24, 32, 40, 48, 80, 112, 503, 504] { let mut bad = good; bad[offset] ^= 1; assert!(Record::decode(&bad).is_err(), "offset {offset}"); } assert_eq!(Record::decode(&good[..511]), Err(Error::Length)); } #[test] fn p12_k544_full_journal_never_publishes_partial_record() { let mut j = Journal::<1>::new(); j.push(record()).unwrap(); let mut second = record(); second.inode = 2; assert_eq!(j.push(second), Err(Error::Full)); assert_eq!(j.len(), 1); assert_eq!(j.entries[0], Some(record())); assert_eq!(Journal::<0>::new().push(record()), Err(Error::Full)); } #[test] fn p12_k545_replay_uses_read_only_temporary_source_and_disposable_copy() { use std::io::Write; let path = std::env::temp_dir().join(std::format!( "turkos-journal-{}-k545.bin", std::process::id() )); let raw = record().encode(true).unwrap(); let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&path) .unwrap(); file.write_all(&raw).unwrap(); drop(file); let mut permissions = std::fs::metadata(&path).unwrap().permissions(); permissions.set_readonly(true); std::fs::set_permissions(&path, permissions).unwrap(); let source = std::fs::read(&path).unwrap(); let r = Record::decode(&source).unwrap(); let mut scratch = Pointer { generation: 1, block: 19, }; if let Replay::Apply(pointer) = r.replay(scratch, r.data_hash).unwrap() { scratch = pointer; } assert_eq!( scratch, Pointer { generation: 2, block: 25 } ); assert_eq!(std::fs::read(&path).unwrap(), raw); std::fs::remove_file(path).unwrap(); } #[test] fn p12_k546_second_replay_is_idempotent() { let r = record(); let Replay::Apply(next) = r .replay( Pointer { generation: 1, block: 19, }, r.data_hash, ) .unwrap() else { panic!() }; assert_eq!(r.replay(next, r.data_hash), Ok(Replay::AlreadyApplied)); assert_eq!(r.replay(next, [0; 32]), Err(Error::DataHash)); } #[test] fn p12_k547_incomplete_commit_retains_old_pointer() { let r = record(); let current = Pointer { generation: 1, block: 19, }; assert_eq!( Record::decode(&r.encode(false).unwrap()), Err(Error::Uncommitted) ); assert_eq!( current, Pointer { generation: 1, block: 19 } ); assert_eq!( Record::decode(&r.encode(true).unwrap()[..507]), Err(Error::Length) ); // Every legal generation must distinguish zero/uncommitted from a // commit marker; XORing the marker with generation would fail here. let mut edge = r; edge.generation = COMMIT; assert_eq!( Record::decode(&edge.encode(false).unwrap()), Err(Error::Uncommitted) ); } #[test] fn p12_k548_bad_checksum_is_explicit_error_not_replay() { let mut bytes = record().encode(true).unwrap(); bytes[48] ^= 0x80; assert_eq!(Record::decode(&bytes), Err(Error::Checksum)); assert_eq!( record().replay( Pointer { generation: 1, block: 19 }, [0; 32] ), Err(Error::DataHash) ); } #[test] fn p12_k558_corpus_rejects_lengths_cycles_old_versions_and_generations() { let r = record(); let bytes = r.encode(true).unwrap(); for n in [0, 1, 79, 111, 503, 511] { assert_eq!(Record::decode(&bytes[..n]), Err(Error::Length)); } let mut old = bytes; old[8..12].copy_from_slice(&0u32.to_le_bytes()); assert_eq!(Record::decode(&old), Err(Error::Version)); let mut cyclic = r; cyclic.new_block = cyclic.old_block; assert_eq!(cyclic.encode(true), Err(Error::Fields)); assert_eq!( r.replay( Pointer { generation: u64::MAX, block: 19 }, r.data_hash ), Err(Error::Conflict) ); assert_eq!( r.replay( Pointer { generation: 7, block: 25 }, r.data_hash ), Err(Error::Conflict) ); let mut j = Journal::<2>::new(); j.push(r).unwrap(); assert_eq!(j.push(r), Err(Error::DuplicateInode)); assert_eq!(j.len(), 1); } #[test] fn p15_k586_minimized_fuzz_finding_is_a_permanent_reserved_byte_regression() { let hex = include_str!("../tests/corpus/journal-canonical-v1.hex").trim(); assert_eq!(hex.len(), 1024); let mut bytes = [0; 512]; for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() { bytes[index] = u8::from_str_radix(core::str::from_utf8(pair).unwrap(), 16).unwrap(); } assert_eq!(bytes[490], 8); assert_eq!(bytes[80..112], checksum(&bytes)); assert_eq!(Record::decode(&bytes), Err(Error::Fields)); assert_eq!( Record::decode(&record().encode(true).unwrap()), Ok(record()) ); } }