K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
380 lines
12 KiB
Rust
380 lines
12 KiB
Rust
//! K11 durable log framing, and the `LogStore` port (GameKernel §2.4, AM-11).
|
|
//!
|
|
//! K11: *"Log format is append-only, length-prefixed, versioned; a
|
|
//! truncated tail is detected, not silently accepted."* Until CB-WP-0006
|
|
//! T05 the only `fs::` call in the workspace was `read_to_string` for
|
|
//! scenario YAML — the rule was satisfied by a `Vec`.
|
|
//!
|
|
//! **Reimplemented, not assimilated** (ADR-0005 §2). "Own the semantics;
|
|
//! assimilate the implementation" exists so we do not reimplement *hard*
|
|
//! things — SHA-256 and ChaCha cost 12 crates between them and are kept.
|
|
//! A length prefix is not in that category, and an embedded store would
|
|
//! spend AM-4a headroom on a format that fits in a paragraph.
|
|
//!
|
|
//! **The operative clause is *detected*.** A reader that accepts a
|
|
//! truncated tail is worse than no format at all, because it silently
|
|
//! returns a short history that looks complete. Every corruption below has
|
|
//! a negative control in the conformance suite.
|
|
|
|
use std::fs::OpenOptions;
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// File header: magic + format version. Present exactly once, at offset 0.
|
|
pub const MAGIC: &[u8; 5] = b"CBLOG";
|
|
pub const FORMAT_VERSION: u8 = 1;
|
|
pub const HEADER_LEN: usize = MAGIC.len() + 1;
|
|
|
|
/// Sanity cap. A corrupt length prefix is far more likely than a genuine
|
|
/// 64 MiB record, and without a cap it would drive an enormous allocation
|
|
/// before the truncation check could fire.
|
|
pub const MAX_RECORD: u32 = 64 << 20;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum StoreError {
|
|
/// Not one of our logs at all.
|
|
BadMagic,
|
|
/// A format we do not know how to read. Never guess.
|
|
UnsupportedVersion(u8),
|
|
/// K11's operative clause: the tail is short, and we say so.
|
|
TruncatedTail {
|
|
offset: usize,
|
|
need: usize,
|
|
have: usize,
|
|
},
|
|
/// A length prefix beyond the sanity cap — corruption, not a big record.
|
|
RecordTooLarge(u32),
|
|
Io(String),
|
|
}
|
|
|
|
impl std::fmt::Display for StoreError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::BadMagic => write!(f, "not a CBLOG file (bad magic)"),
|
|
Self::UnsupportedVersion(v) => write!(f, "unsupported format version {v}"),
|
|
Self::TruncatedTail { offset, need, have } => write!(
|
|
f,
|
|
"truncated tail at byte {offset}: need {need} bytes, have {have}"
|
|
),
|
|
Self::RecordTooLarge(n) => {
|
|
write!(f, "record length {n} exceeds the {MAX_RECORD}-byte cap")
|
|
}
|
|
Self::Io(e) => write!(f, "io: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serialize a header. Written once when a log is created.
|
|
#[must_use]
|
|
pub fn header() -> Vec<u8> {
|
|
let mut out = MAGIC.to_vec();
|
|
out.push(FORMAT_VERSION);
|
|
out
|
|
}
|
|
|
|
/// Frame one record: 4-byte little-endian length, then the payload.
|
|
#[must_use]
|
|
pub fn frame(record: &[u8]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(4 + record.len());
|
|
out.extend_from_slice(
|
|
&u32::try_from(record.len())
|
|
.unwrap_or(u32::MAX)
|
|
.to_le_bytes(),
|
|
);
|
|
out.extend_from_slice(record);
|
|
out
|
|
}
|
|
|
|
/// Parse a whole log. Shared by every `LogStore` impl, so the truncation
|
|
/// contract cannot differ between them.
|
|
pub fn parse(bytes: &[u8]) -> Result<Vec<Vec<u8>>, StoreError> {
|
|
if bytes.len() < HEADER_LEN {
|
|
return Err(StoreError::TruncatedTail {
|
|
offset: 0,
|
|
need: HEADER_LEN,
|
|
have: bytes.len(),
|
|
});
|
|
}
|
|
if &bytes[..MAGIC.len()] != MAGIC {
|
|
return Err(StoreError::BadMagic);
|
|
}
|
|
let version = bytes[MAGIC.len()];
|
|
if version != FORMAT_VERSION {
|
|
return Err(StoreError::UnsupportedVersion(version));
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
let mut at = HEADER_LEN;
|
|
while at < bytes.len() {
|
|
let remaining = bytes.len() - at;
|
|
if remaining < 4 {
|
|
return Err(StoreError::TruncatedTail {
|
|
offset: at,
|
|
need: 4,
|
|
have: remaining,
|
|
});
|
|
}
|
|
let len = u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
|
|
if len > MAX_RECORD {
|
|
return Err(StoreError::RecordTooLarge(len));
|
|
}
|
|
let len = len as usize;
|
|
at += 4;
|
|
if bytes.len() - at < len {
|
|
return Err(StoreError::TruncatedTail {
|
|
offset: at,
|
|
need: len,
|
|
have: bytes.len() - at,
|
|
});
|
|
}
|
|
out.push(bytes[at..at + len].to_vec());
|
|
at += len;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The capability port (AM-11 / M-D4-SWAP). Two impls, one suite.
|
|
pub trait LogStore {
|
|
/// Append one record. Append-only: there is no update or delete.
|
|
fn append(&mut self, record: &[u8]) -> Result<(), StoreError>;
|
|
|
|
/// Every record, in append order.
|
|
fn records(&self) -> Result<Vec<Vec<u8>>, StoreError>;
|
|
|
|
/// Raw framed bytes. Exists so the conformance suite can corrupt a
|
|
/// log and require the *same* rejection from every impl — a format
|
|
/// contract that only one impl enforces is not a contract.
|
|
fn raw(&self) -> Result<Vec<u8>, StoreError>;
|
|
|
|
/// Replace the raw bytes. Test-facing, and the reason truncation is
|
|
/// part of the shared suite rather than a file-only special case.
|
|
fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError>;
|
|
}
|
|
|
|
/// In-memory impl — the fast, non-durable half of the AM-11 pair.
|
|
#[derive(Debug)]
|
|
pub struct MemLogStore {
|
|
bytes: Vec<u8>,
|
|
}
|
|
|
|
impl Default for MemLogStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl MemLogStore {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self { bytes: header() }
|
|
}
|
|
}
|
|
|
|
impl LogStore for MemLogStore {
|
|
fn append(&mut self, record: &[u8]) -> Result<(), StoreError> {
|
|
self.bytes.extend_from_slice(&frame(record));
|
|
Ok(())
|
|
}
|
|
|
|
fn records(&self) -> Result<Vec<Vec<u8>>, StoreError> {
|
|
parse(&self.bytes)
|
|
}
|
|
|
|
fn raw(&self) -> Result<Vec<u8>, StoreError> {
|
|
Ok(self.bytes.clone())
|
|
}
|
|
|
|
fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError> {
|
|
self.bytes = bytes.to_vec();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// File-backed impl — the durable half. Opens in append mode per write, so
|
|
/// a crash between writes leaves a well-formed prefix rather than a
|
|
/// half-updated file.
|
|
#[derive(Debug)]
|
|
pub struct FileLogStore {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl FileLogStore {
|
|
/// Create or open a log at `path`, writing the header if new.
|
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
|
|
let path = path.as_ref().to_path_buf();
|
|
if !path.exists() {
|
|
std::fs::write(&path, header()).map_err(|e| StoreError::Io(e.to_string()))?;
|
|
}
|
|
Ok(Self { path })
|
|
}
|
|
}
|
|
|
|
impl LogStore for FileLogStore {
|
|
fn append(&mut self, record: &[u8]) -> Result<(), StoreError> {
|
|
let mut f = OpenOptions::new()
|
|
.append(true)
|
|
.open(&self.path)
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
f.write_all(&frame(record))
|
|
.map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
|
|
fn records(&self) -> Result<Vec<Vec<u8>>, StoreError> {
|
|
parse(&self.raw()?)
|
|
}
|
|
|
|
fn raw(&self) -> Result<Vec<u8>, StoreError> {
|
|
let mut buf = Vec::new();
|
|
std::fs::File::open(&self.path)
|
|
.and_then(|mut f| f.read_to_end(&mut buf))
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
Ok(buf)
|
|
}
|
|
|
|
fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError> {
|
|
std::fs::write(&self.path, bytes).map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// The AM-11 conformance suite: **one** suite, driven by every impl.
|
|
///
|
|
/// Before this existed, `M-D4-SWAP` was a bool over "impls passing the
|
|
/// same conformance suite" and no suite existed — the RNG pair was
|
|
/// exercised by two separate, non-shared tests, so `AM-11 | met, narrow`
|
|
/// was never earned. ADR-0005 §4 downgraded it to unmet; this is what
|
|
/// re-earns it.
|
|
///
|
|
/// Panics with a labelled message so a failure names the impl.
|
|
pub fn conformance<S: LogStore>(label: &str, mut make: impl FnMut() -> S) {
|
|
// A fresh log holds no records but is still well-formed.
|
|
let s = make();
|
|
assert_eq!(
|
|
s.records().unwrap(),
|
|
Vec::<Vec<u8>>::new(),
|
|
"{label}: a new log must read back as zero records"
|
|
);
|
|
|
|
// Append order is read order.
|
|
let mut s = make();
|
|
let records: Vec<Vec<u8>> = vec![b"first".to_vec(), b"second".to_vec(), b"third".to_vec()];
|
|
for r in &records {
|
|
s.append(r).unwrap();
|
|
}
|
|
assert_eq!(
|
|
s.records().unwrap(),
|
|
records,
|
|
"{label}: records must read back in append order"
|
|
);
|
|
|
|
// Binary payloads, including empty and high bytes, survive intact.
|
|
let mut s = make();
|
|
let tricky: Vec<Vec<u8>> = vec![
|
|
Vec::new(),
|
|
vec![0u8; 8],
|
|
vec![0xFF; 8],
|
|
(0u8..=255).collect(),
|
|
];
|
|
for r in &tricky {
|
|
s.append(r).unwrap();
|
|
}
|
|
assert_eq!(
|
|
s.records().unwrap(),
|
|
tricky,
|
|
"{label}: binary payloads must round-trip byte-for-byte"
|
|
);
|
|
|
|
// --- K11's operative clause: corruption is DETECTED, not accepted ---
|
|
|
|
// A tail short by one byte.
|
|
let mut s = make();
|
|
s.append(b"payload").unwrap();
|
|
let good = s.raw().unwrap();
|
|
s.set_raw(&good[..good.len() - 1]).unwrap();
|
|
assert!(
|
|
matches!(s.records(), Err(StoreError::TruncatedTail { .. })),
|
|
"{label}: a tail short by one byte must be rejected, got {:?}",
|
|
s.records()
|
|
);
|
|
|
|
// A length prefix cut in half.
|
|
let mut s = make();
|
|
s.append(b"payload").unwrap();
|
|
let good = s.raw().unwrap();
|
|
s.set_raw(&good[..HEADER_LEN + 2]).unwrap();
|
|
assert!(
|
|
matches!(s.records(), Err(StoreError::TruncatedTail { .. })),
|
|
"{label}: a half-written length prefix must be rejected"
|
|
);
|
|
|
|
// A corrupted length prefix claiming more than the file holds.
|
|
let mut s = make();
|
|
s.append(b"payload").unwrap();
|
|
let mut bad = s.raw().unwrap();
|
|
bad[HEADER_LEN] = 0xFF;
|
|
bad[HEADER_LEN + 1] = 0xFF;
|
|
s.set_raw(&bad).unwrap();
|
|
assert!(
|
|
matches!(
|
|
s.records(),
|
|
Err(StoreError::TruncatedTail { .. }) | Err(StoreError::RecordTooLarge(_))
|
|
),
|
|
"{label}: a corrupt length prefix must be rejected"
|
|
);
|
|
|
|
// Wrong magic and unknown version are refused rather than guessed at.
|
|
let mut s = make();
|
|
s.set_raw(b"NOPE!\x01").unwrap();
|
|
assert_eq!(
|
|
s.records(),
|
|
Err(StoreError::BadMagic),
|
|
"{label}: foreign bytes must not be read as a log"
|
|
);
|
|
|
|
let mut s = make();
|
|
let mut future = header();
|
|
future[MAGIC.len()] = FORMAT_VERSION + 1;
|
|
s.set_raw(&future).unwrap();
|
|
assert_eq!(
|
|
s.records(),
|
|
Err(StoreError::UnsupportedVersion(FORMAT_VERSION + 1)),
|
|
"{label}: a newer format must be refused, not guessed at"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// AM-11: the SAME suite, both impls. This is the whole point — two
|
|
/// separate tests would reproduce the defect ADR-0005 §4 recorded.
|
|
#[test]
|
|
fn mem_store_passes_the_conformance_suite() {
|
|
conformance("MemLogStore", MemLogStore::new);
|
|
}
|
|
|
|
#[test]
|
|
fn file_store_passes_the_conformance_suite() {
|
|
let dir = std::env::temp_dir().join(format!("cb-log-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut n = 0;
|
|
conformance("FileLogStore", || {
|
|
n += 1;
|
|
let p = dir.join(format!("{n}.cblog"));
|
|
let _ = std::fs::remove_file(&p);
|
|
FileLogStore::open(&p).unwrap()
|
|
});
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// K11 framing is shared, so it is also pinned directly.
|
|
#[test]
|
|
fn framing_round_trips_and_rejects_a_truncated_tail() {
|
|
let mut bytes = header();
|
|
bytes.extend_from_slice(&frame(b"abc"));
|
|
assert_eq!(parse(&bytes).unwrap(), vec![b"abc".to_vec()]);
|
|
assert!(matches!(
|
|
parse(&bytes[..bytes.len() - 1]),
|
|
Err(StoreError::TruncatedTail { .. })
|
|
));
|
|
}
|
|
}
|