LeVCS/crates/levcs-store/src/format.rs

1803 lines
70 KiB
Rust

//! Physical on-disk format: frames, journal headers, segment footers,
//! manifests, and checkpoints.
//!
//! **Shared file.** The lead owns the type definitions, constants, domain
//! strings, and function signatures in this file (D0). **A1 JournalWriter**
//! fills the bodies and owns the golden vectors. **A2 RecoveryIndex** consumes
//! this file read-only and files an interface change request rather than
//! editing it. That split is what keeps A1 and A2 genuinely parallel
//! (scope 2.1).
//!
//! Plan §13 keeps the physical format internal: nothing outside this crate
//! binds to these bytes, and future workflow code binds only to logical
//! snapshots and events.
use levcs_core::{ObjectId, ObjectType};
use levcs_protocol::v2::{
ProjectionMode, RefMutation, RefTarget, SignedCommittedTransactionV1, SourceKindV1,
StagedProjectionInstallV1, TransactionEvidenceV1, TypedRefCas, MAX_REF_UPDATES,
};
use levcs_protocol::CanonicalCodec;
use crate::types::StoreError;
// ---------------------------------------------------------------------------
// Constants — frozen in D0. Changing any of these invalidates the golden
// frame corpus and the crash fixtures.
// ---------------------------------------------------------------------------
pub const STORAGE_VERSION: u16 = 1;
pub const FRAME_MAGIC: [u8; 8] = *b"LVCSFRM\0";
pub const FRAME_TRAILER_MAGIC: [u8; 8] = *b"LVCSEND\0";
pub const JOURNAL_MAGIC: [u8; 8] = *b"LVCSJRN\0";
pub const SEGMENT_FOOTER_MAGIC: [u8; 8] = *b"LVCSSEG\0";
pub const MANIFEST_MAGIC: [u8; 8] = *b"LVCSMAN\0";
pub const CURRENT_MAGIC: [u8; 8] = *b"LVCSCUR\0";
pub const CHECKPOINT_MAGIC: [u8; 8] = *b"LVCSCKP\0";
pub const FORMAT_MAGIC: [u8; 8] = *b"LVCSFMT\0";
pub const FRAME_HEADER_LEN: usize = 176;
pub const FRAME_TRAILER_LEN: usize = 48;
pub const MIN_FRAME_LEN: u64 = (FRAME_HEADER_LEN + FRAME_TRAILER_LEN) as u64;
pub const FRAME_ALIGNMENT: u64 = 8;
pub const JOURNAL_HEADER_LEN: usize = 512;
/// Domain-separated digest inputs. Distinct domains keep a frame digest from
/// ever equalling a payload digest over related bytes.
pub const FRAME_DIGEST_DOMAIN: &[u8] = b"levcs-frame/v1\0";
pub const FRAME_PAYLOAD_DIGEST_DOMAIN: &[u8] = b"levcs-frame-payload/v1\0";
pub const JOURNAL_HEADER_DIGEST_DOMAIN: &[u8] = b"levcs-journal-header/v1\0";
pub const SEGMENT_FOOTER_DIGEST_DOMAIN: &[u8] = b"levcs-segment-footer/v1\0";
pub const MANIFEST_DIGEST_DOMAIN: &[u8] = b"levcs-manifest/v1\0";
pub const CURRENT_DIGEST_DOMAIN: &[u8] = b"levcs-current/v1\0";
pub const CHECKPOINT_DIGEST_DOMAIN: &[u8] = b"levcs-checkpoint/v1\0";
pub const FORMAT_DIGEST_DOMAIN: &[u8] = b"levcs-format/v1\0";
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Why a byte sequence is not a valid frame.
///
/// One variant per completeness condition, so a test can assert *which*
/// condition rejected a mutation rather than only that something did. The
/// scope 5 charter forbids a catch-all arm; this taxonomy is what makes
/// individual assertion possible.
#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum FrameError {
#[error("frame length is malformed or exceeds the containing file")]
Length,
#[error("frame header magic or storage version does not match")]
HeaderMagic,
#[error("frame header_len is not the value for this storage version")]
HeaderLen,
#[error("frame journal_id does not match the containing file")]
JournalId,
#[error("frame trailer magic or repeated total_len does not match")]
Trailer,
#[error("frame digest does not recompute")]
FrameDigest,
#[error("payload digest does not recompute")]
PayloadDigest,
#[error("frame flags must be zero in storage version 1")]
Flags,
#[error("frame padding must be zero")]
Padding,
#[error("frame payload is not canonical: {0}")]
Payload(&'static str),
#[error("root uuid does not match this store root")]
RootUuid,
}
impl From<FrameError> for StoreError {
fn from(e: FrameError) -> Self {
StoreError::Corruption(e.to_string())
}
}
// ---------------------------------------------------------------------------
// Frame
// ---------------------------------------------------------------------------
/// Fixed 176-byte frame header. Field order and offsets are frozen; see
/// scope 3.3 for the byte table.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrameHeader {
/// Must be zero in storage version 1. Any set bit rejects: without this,
/// 2^32 flag values yield a frame satisfying every other condition that
/// the writer never produced.
pub flags: u32,
/// Header + payload + padding + trailer.
pub total_len: u64,
pub journal_id: [u8; 16],
pub shard_sequence: u64,
pub repo_sequence: u64,
pub namespace: [u8; 32],
pub operation_id: [u8; 16],
pub operation_digest: ObjectId,
pub payload_len: u64,
pub payload_digest: ObjectId,
}
/// A whole frame: header, canonical payload, and the trailer that certifies
/// both.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Frame {
pub header: FrameHeader,
pub payload: Vec<u8>,
}
impl Frame {
/// Encode to the exact on-disk bytes.
///
/// Deterministic: every field is written from `self`, the padding is
/// zeros, and both digests are recomputed rather than carried, so the same
/// `Frame` yields identical bytes on every run. That is what makes the
/// golden corpus stable.
///
/// `total_len` and `payload_len` in `self.header` are **derived, not
/// trusted**: they are recomputed from `self.payload` and any disagreement
/// is rejected rather than silently corrected. A writer that could hand in
/// a header whose lengths disagree with its payload could produce a frame
/// that fails its own completeness check after the fence.
///
/// There is no `root_uuid` parameter and no `root_uuid` field: the
/// storage-version-1 header table of scope 3.3 has neither. Root binding
/// is already sound without one — a frame carries `journal_id`, the
/// journal header carries `root_uuid`, and a frame is only ever read out
/// of a file whose header was validated against the root that opened it.
/// Adding a header field would change the frozen 176-byte table for no
/// gain. (The D0 signature took a `root_uuid` it could not use; the lead
/// approved dropping it.)
pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
if self.header.flags != 0 {
return Err(FrameError::Flags);
}
let payload_len = self.payload.len() as u64;
if self.header.payload_len != payload_len {
return Err(FrameError::Length);
}
let total_len = frame_total_len(payload_len);
if self.header.total_len != total_len {
return Err(FrameError::Length);
}
let total = usize::try_from(total_len).map_err(|_| FrameError::Length)?;
let padding = frame_padding_len(payload_len) as usize;
let mut bytes = vec![0u8; total];
{
let header = &mut bytes[..FRAME_HEADER_LEN];
header[off::MAGIC..off::MAGIC + 8].copy_from_slice(&FRAME_MAGIC);
header[off::STORAGE_VERSION..off::STORAGE_VERSION + 2]
.copy_from_slice(&STORAGE_VERSION.to_le_bytes());
header[off::HEADER_LEN..off::HEADER_LEN + 2]
.copy_from_slice(&(FRAME_HEADER_LEN as u16).to_le_bytes());
header[off::FLAGS..off::FLAGS + 4].copy_from_slice(&0u32.to_le_bytes());
header[off::TOTAL_LEN..off::TOTAL_LEN + 8].copy_from_slice(&total_len.to_le_bytes());
header[off::JOURNAL_ID..off::JOURNAL_ID + 16].copy_from_slice(&self.header.journal_id);
header[off::SHARD_SEQUENCE..off::SHARD_SEQUENCE + 8]
.copy_from_slice(&self.header.shard_sequence.to_le_bytes());
header[off::REPO_SEQUENCE..off::REPO_SEQUENCE + 8]
.copy_from_slice(&self.header.repo_sequence.to_le_bytes());
header[off::NAMESPACE..off::NAMESPACE + 32].copy_from_slice(&self.header.namespace);
header[off::OPERATION_ID..off::OPERATION_ID + 16]
.copy_from_slice(&self.header.operation_id);
header[off::OPERATION_DIGEST..off::OPERATION_DIGEST + 32]
.copy_from_slice(self.header.operation_digest.as_bytes());
header[off::PAYLOAD_LEN..off::PAYLOAD_LEN + 8]
.copy_from_slice(&payload_len.to_le_bytes());
header[off::PAYLOAD_DIGEST..off::PAYLOAD_DIGEST + 32]
.copy_from_slice(digest(FRAME_PAYLOAD_DIGEST_DOMAIN, &self.payload).as_bytes());
}
let payload_end = FRAME_HEADER_LEN + self.payload.len();
bytes[FRAME_HEADER_LEN..payload_end].copy_from_slice(&self.payload);
// The padding stays as the zeros `vec![0u8; total]` already wrote;
// condition 8 pins it, so it is never a covert region.
debug_assert!(bytes[payload_end..payload_end + padding]
.iter()
.all(|b| *b == 0));
let trailer_at = total - FRAME_TRAILER_LEN;
bytes[trailer_at + off::TRAILER_MAGIC..trailer_at + off::TRAILER_MAGIC + 8]
.copy_from_slice(&FRAME_TRAILER_MAGIC);
bytes[trailer_at + off::TRAILER_TOTAL_LEN..trailer_at + off::TRAILER_TOTAL_LEN + 8]
.copy_from_slice(&total_len.to_le_bytes());
// The frame digest is the last thing written and covers everything
// before it, so the final bytes on the device certify the whole frame.
let digest_at = trailer_at + off::TRAILER_DIGEST;
let frame_digest = digest(FRAME_DIGEST_DOMAIN, &bytes[..digest_at]);
bytes[digest_at..].copy_from_slice(frame_digest.as_bytes());
Ok(bytes)
}
/// Decode a frame from exactly its own bytes.
///
/// `bytes.len()` must equal the frame's `total_len`: a caller that has a
/// larger buffer proves completeness with [`verify_complete`] first and
/// then slices. Trailing bytes are a rejection, in the Phase 0 discipline.
pub fn decode(bytes: &[u8], journal_id: &[u8; 16]) -> Result<Self, FrameError> {
let header = verify_complete(bytes, journal_id, bytes.len() as u64)?;
if bytes.len() as u64 != header.total_len {
return Err(FrameError::Length);
}
let payload_len = header.payload_len as usize;
let payload = bytes[FRAME_HEADER_LEN..FRAME_HEADER_LEN + payload_len].to_vec();
Ok(Self { header, payload })
}
}
/// Prove a byte range is a complete frame.
///
/// **This is the definition recovery uses and the first thing the scope 5
/// charter directs an adversarial reviewer to attack.** All conditions in
/// scope 3.3 must hold; each rejection returns its own `FrameError` variant so
/// that a test can assert which condition fired.
///
/// The trailer exists precisely so a torn write cannot leave a
/// self-consistent frame: the last bytes written are the ones that certify the
/// whole.
///
/// `bytes` may be longer than the frame — a tail scanner hands in whatever it
/// read — but never shorter: a short buffer is condition 1 failing.
/// `file_remaining` is the number of bytes between this frame's offset and the
/// journal's preallocated length.
///
/// # Evaluation order
///
/// The conditions are **not** evaluated in the order scope 3.3 lists them, and
/// they cannot be. `frame_digest` (condition 5) covers the header, the
/// padding, and the trailer, so it also fails for any mutation of `flags`
/// (condition 7), of the padding (condition 8), of `journal_id` (condition 3),
/// or of the trailer (condition 4). Evaluating in the listed order would
/// report `FrameDigest` for every one of them, and scope 4-A1 deliverable 7
/// requires each condition to be independently assertable. Every condition
/// whose bytes the digest covers is therefore checked *before* the digest.
/// This is an evaluation-order requirement the scope document does not state;
/// see the A1 report.
pub fn verify_complete(
bytes: &[u8],
journal_id: &[u8; 16],
file_remaining: u64,
) -> Result<FrameHeader, FrameError> {
// --- condition 1 (partial): enough bytes to read a header at all -------
if bytes.len() < FRAME_HEADER_LEN {
return Err(FrameError::Length);
}
let header = &bytes[..FRAME_HEADER_LEN];
// --- condition 2: header magic and storage version --------------------
if header[off::MAGIC..off::MAGIC + 8] != FRAME_MAGIC {
return Err(FrameError::HeaderMagic);
}
if u16::from_le_bytes([
header[off::STORAGE_VERSION],
header[off::STORAGE_VERSION + 1],
]) != STORAGE_VERSION
{
return Err(FrameError::HeaderMagic);
}
// --- condition 2 (header_len half), given its own variant --------------
if u16::from_le_bytes([header[off::HEADER_LEN], header[off::HEADER_LEN + 1]])
!= FRAME_HEADER_LEN as u16
{
return Err(FrameError::HeaderLen);
}
// --- condition 1: total_len is well formed and inside the file ---------
let total_len = read_u64(header, off::TOTAL_LEN);
let payload_len = read_u64(header, off::PAYLOAD_LEN);
if total_len < MIN_FRAME_LEN
|| total_len % FRAME_ALIGNMENT != 0
|| total_len > file_remaining
|| total_len > bytes.len() as u64
{
return Err(FrameError::Length);
}
// The three declared lengths must agree exactly. Without this the padding
// region of condition 8 would not have a single well-defined extent.
if payload_len > total_len || frame_total_len(payload_len) != total_len {
return Err(FrameError::Length);
}
let total = total_len as usize;
let payload_end = FRAME_HEADER_LEN + payload_len as usize;
let trailer_at = total - FRAME_TRAILER_LEN;
// --- condition 3: journal identity ------------------------------------
if header[off::JOURNAL_ID..off::JOURNAL_ID + 16] != journal_id[..] {
return Err(FrameError::JournalId);
}
// --- condition 4: trailer magic and the repeated total_len ------------
let trailer = &bytes[trailer_at..total];
if trailer[off::TRAILER_MAGIC..off::TRAILER_MAGIC + 8] != FRAME_TRAILER_MAGIC {
return Err(FrameError::Trailer);
}
if read_u64(trailer, off::TRAILER_TOTAL_LEN) != total_len {
return Err(FrameError::Trailer);
}
// --- condition 7: flags -----------------------------------------------
// Checked before the digest so that a frame whose only defect is a set
// flag bit reports `Flags` and not `FrameDigest`.
let flags = u32::from_le_bytes([
header[off::FLAGS],
header[off::FLAGS + 1],
header[off::FLAGS + 2],
header[off::FLAGS + 3],
]);
if flags != 0 {
return Err(FrameError::Flags);
}
// --- condition 8: padding ---------------------------------------------
if bytes[payload_end..trailer_at].iter().any(|b| *b != 0) {
return Err(FrameError::Padding);
}
// --- condition 5: the frame digest ------------------------------------
let digest_at = trailer_at + off::TRAILER_DIGEST;
if digest(FRAME_DIGEST_DOMAIN, &bytes[..digest_at]).as_bytes() != &bytes[digest_at..total] {
return Err(FrameError::FrameDigest);
}
// --- condition 6: the payload digest ----------------------------------
let payload_digest = digest(
FRAME_PAYLOAD_DIGEST_DOMAIN,
&bytes[FRAME_HEADER_LEN..payload_end],
);
if payload_digest.as_bytes() != &header[off::PAYLOAD_DIGEST..off::PAYLOAD_DIGEST + 32] {
return Err(FrameError::PayloadDigest);
}
Ok(FrameHeader {
flags,
total_len,
journal_id: *journal_id,
shard_sequence: read_u64(header, off::SHARD_SEQUENCE),
repo_sequence: read_u64(header, off::REPO_SEQUENCE),
namespace: read_array::<32>(header, off::NAMESPACE),
operation_id: read_array::<16>(header, off::OPERATION_ID),
operation_digest: ObjectId(read_array::<32>(header, off::OPERATION_DIGEST)),
payload_len,
payload_digest,
})
}
fn read_u64(bytes: &[u8], at: usize) -> u64 {
let mut value = [0u8; 8];
value.copy_from_slice(&bytes[at..at + 8]);
u64::from_le_bytes(value)
}
fn read_array<const N: usize>(bytes: &[u8], at: usize) -> [u8; N] {
let mut value = [0u8; N];
value.copy_from_slice(&bytes[at..at + N]);
value
}
/// Zero-padding count placing the trailer on an 8-byte boundary.
pub const fn frame_padding_len(payload_len: u64) -> u64 {
let unpadded = FRAME_HEADER_LEN as u64 + payload_len;
(FRAME_ALIGNMENT - (unpadded % FRAME_ALIGNMENT)) % FRAME_ALIGNMENT
}
/// Total on-disk length for a payload of `payload_len` bytes.
pub const fn frame_total_len(payload_len: u64) -> u64 {
FRAME_HEADER_LEN as u64
+ payload_len
+ frame_padding_len(payload_len)
+ FRAME_TRAILER_LEN as u64
}
// ---------------------------------------------------------------------------
// Journal file header
// ---------------------------------------------------------------------------
/// 512-byte journal file header.
///
/// `journal_id` is fresh random per file and appears in every frame. It is the
/// defense against a frame from a previous incarnation of a recycled or
/// non-zeroing extent being accepted as live, and it also catches a segment or
/// journal misfiled under the wrong name in a manifest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JournalHeader {
pub shard_index: u16,
pub root_uuid: [u8; 16],
pub journal_id: [u8; 16],
pub first_shard_sequence: u64,
pub preallocated_len: u64,
pub created_at_micros: i64,
}
impl JournalHeader {
/// Every byte of the 512 that is not a named field is padding and is
/// written as zero; `header_digest` covers the whole 512 with the digest
/// field itself zeroed. Leaving reserved space unconstrained is how a
/// format acquires undocumented variants, so the padding is part of the
/// authenticated image.
pub fn encode(&self) -> Result<[u8; JOURNAL_HEADER_LEN], FrameError> {
let mut bytes = [0u8; JOURNAL_HEADER_LEN];
bytes[joff::MAGIC..joff::MAGIC + 8].copy_from_slice(&JOURNAL_MAGIC);
bytes[joff::STORAGE_VERSION..joff::STORAGE_VERSION + 2]
.copy_from_slice(&STORAGE_VERSION.to_le_bytes());
bytes[joff::HEADER_LEN..joff::HEADER_LEN + 2]
.copy_from_slice(&(JOURNAL_HEADER_LEN as u16).to_le_bytes());
bytes[joff::SHARD_INDEX..joff::SHARD_INDEX + 2]
.copy_from_slice(&self.shard_index.to_le_bytes());
bytes[joff::ROOT_UUID..joff::ROOT_UUID + 16].copy_from_slice(&self.root_uuid);
bytes[joff::JOURNAL_ID..joff::JOURNAL_ID + 16].copy_from_slice(&self.journal_id);
bytes[joff::FIRST_SHARD_SEQUENCE..joff::FIRST_SHARD_SEQUENCE + 8]
.copy_from_slice(&self.first_shard_sequence.to_le_bytes());
bytes[joff::PREALLOCATED_LEN..joff::PREALLOCATED_LEN + 8]
.copy_from_slice(&self.preallocated_len.to_le_bytes());
bytes[joff::CREATED_AT_MICROS..joff::CREATED_AT_MICROS + 8]
.copy_from_slice(&self.created_at_micros.to_le_bytes());
// Digest over the full 512 with the digest field zeroed, which it
// already is.
let header_digest = digest(JOURNAL_HEADER_DIGEST_DOMAIN, &bytes);
bytes[joff::HEADER_DIGEST..joff::HEADER_DIGEST + 32]
.copy_from_slice(header_digest.as_bytes());
Ok(bytes)
}
/// Rejects a wrong magic or version, a wrong `header_len`, a non-zero
/// padding byte anywhere in the 512, and a digest that does not recompute.
///
/// `root_uuid` is returned rather than checked: the caller knows the root
/// it opened and compares, returning [`FrameError::RootUuid`].
pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
if bytes.len() != JOURNAL_HEADER_LEN {
return Err(FrameError::Length);
}
if bytes[joff::MAGIC..joff::MAGIC + 8] != JOURNAL_MAGIC {
return Err(FrameError::HeaderMagic);
}
if u16::from_le_bytes([
bytes[joff::STORAGE_VERSION],
bytes[joff::STORAGE_VERSION + 1],
]) != STORAGE_VERSION
{
return Err(FrameError::HeaderMagic);
}
if u16::from_le_bytes([bytes[joff::HEADER_LEN], bytes[joff::HEADER_LEN + 1]])
!= JOURNAL_HEADER_LEN as u16
{
return Err(FrameError::HeaderLen);
}
if bytes[joff::PAD0..joff::ROOT_UUID].iter().any(|b| *b != 0)
|| bytes[joff::PAD1..].iter().any(|b| *b != 0)
{
return Err(FrameError::Padding);
}
let mut zeroed = [0u8; JOURNAL_HEADER_LEN];
zeroed.copy_from_slice(bytes);
zeroed[joff::HEADER_DIGEST..joff::HEADER_DIGEST + 32].fill(0);
if digest(JOURNAL_HEADER_DIGEST_DOMAIN, &zeroed).as_bytes()
!= &bytes[joff::HEADER_DIGEST..joff::HEADER_DIGEST + 32]
{
return Err(FrameError::FrameDigest);
}
Ok(Self {
shard_index: u16::from_le_bytes([
bytes[joff::SHARD_INDEX],
bytes[joff::SHARD_INDEX + 1],
]),
root_uuid: read_array::<16>(bytes, joff::ROOT_UUID),
journal_id: read_array::<16>(bytes, joff::JOURNAL_ID),
first_shard_sequence: read_u64(bytes, joff::FIRST_SHARD_SEQUENCE),
preallocated_len: read_u64(bytes, joff::PREALLOCATED_LEN),
created_at_micros: i64::from_le_bytes(read_array::<8>(bytes, joff::CREATED_AT_MICROS)),
})
}
}
// ---------------------------------------------------------------------------
// Segment footer
// ---------------------------------------------------------------------------
/// Appended when a journal is sealed. Its presence distinguishes a sealed
/// segment from an active journal.
///
/// Frames are byte-identical between journal and segment: sealing appends this
/// footer and renames, and never re-encodes. One golden frame corpus therefore
/// covers both readers (scope 2.5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SegmentFooter {
pub root_uuid: [u8; 16],
pub journal_id: [u8; 16],
pub generation: u64,
pub first_shard_sequence: u64,
pub last_shard_sequence: u64,
pub frame_count: u64,
/// `shard_sequence -> (offset, len)`, ascending by sequence.
pub offsets: Vec<(u64, u64, u64)>,
}
/// Bytes at the end of every digested variable-length structure: the digest
/// itself.
const TRAILING_DIGEST_LEN: usize = 32;
/// Bytes a segment-footer locator occupies at end of file: `footer_len`, the
/// repeated magic, and the digest.
pub const SEGMENT_FOOTER_LOCATOR_LEN: usize = 8 + 8 + TRAILING_DIGEST_LEN;
/// Start a digested structure: magic, storage version, and a zero reserved
/// half-word so the body begins 8-byte aligned.
fn begin_digested(magic: [u8; 8], capacity: usize) -> W {
let mut writer = W::with_capacity(capacity);
writer.fixed(&magic);
writer.u16(STORAGE_VERSION);
writer.u16(0);
writer
}
/// Finish a digested structure by appending the digest over everything before
/// it.
fn finish_digested(writer: W, domain: &'static [u8]) -> Vec<u8> {
let mut bytes = writer.bytes;
let value = digest(domain, &bytes);
bytes.extend_from_slice(value.as_bytes());
bytes
}
/// Open a digested structure: verify length, magic, version, reserved zeros,
/// and the digest, then return a reader over the body only.
fn open_digested<'a>(
bytes: &'a [u8],
magic: [u8; 8],
domain: &'static [u8],
) -> Result<R<'a>, FrameError> {
if bytes.len() < 12 + TRAILING_DIGEST_LEN {
return Err(FrameError::Length);
}
if bytes[0..8] != magic {
return Err(FrameError::HeaderMagic);
}
let body_end = bytes.len() - TRAILING_DIGEST_LEN;
if digest(domain, &bytes[..body_end]).as_bytes() != &bytes[body_end..] {
return Err(FrameError::FrameDigest);
}
let mut reader = R::new(&bytes[..body_end]);
let _magic = reader.fixed::<8>()?;
if reader.u16()? != STORAGE_VERSION {
return Err(FrameError::HeaderMagic);
}
if reader.u16()? != 0 {
return Err(FrameError::Padding);
}
Ok(reader)
}
impl SegmentFooter {
/// The footer is self-locating from the end of the file: its final 48
/// bytes are `footer_len`, the repeated magic, and the digest, so a reader
/// can find the footer start with one bounded `pread` of
/// [`SEGMENT_FOOTER_LOCATOR_LEN`] bytes and never has to scan.
pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
if self.offsets.len() as u64 != self.frame_count {
return Err(FrameError::Payload(
"segment frame_count disagrees with the offset table",
));
}
if self.frame_count > MAX_TABLE_ENTRIES as u64 {
return Err(FrameError::Length);
}
let mut previous: Option<u64> = None;
for (sequence, offset, len) in &self.offsets {
if let Some(previous) = previous {
if *sequence <= previous {
return Err(FrameError::Payload("segment offsets must strictly ascend"));
}
}
previous = Some(*sequence);
if *len < MIN_FRAME_LEN || len % FRAME_ALIGNMENT != 0 {
return Err(FrameError::Length);
}
offset.checked_add(*len).ok_or(FrameError::Length)?;
}
if self.frame_count > 0 {
let first = self.offsets.first().expect("non-empty").0;
let last = self.offsets.last().expect("non-empty").0;
if first != self.first_shard_sequence || last != self.last_shard_sequence {
return Err(FrameError::Payload(
"segment range disagrees with the offset table",
));
}
}
let mut writer = begin_digested(SEGMENT_FOOTER_MAGIC, 128 + self.offsets.len() * 24);
writer.fixed(&self.root_uuid);
writer.fixed(&self.journal_id);
writer.u64(self.generation);
writer.u64(self.first_shard_sequence);
writer.u64(self.last_shard_sequence);
writer.u64(self.frame_count);
writer.count(self.offsets.len())?;
for (sequence, offset, len) in &self.offsets {
writer.u64(*sequence);
writer.u64(*offset);
writer.u64(*len);
}
// Self-locating tail. `footer_len` counts itself, the repeated magic,
// and the digest.
let footer_len = (writer.bytes.len() + SEGMENT_FOOTER_LOCATOR_LEN) as u64;
writer.u64(footer_len);
writer.fixed(&SEGMENT_FOOTER_MAGIC);
Ok(finish_digested(writer, SEGMENT_FOOTER_DIGEST_DOMAIN))
}
pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
let mut reader = open_digested(bytes, SEGMENT_FOOTER_MAGIC, SEGMENT_FOOTER_DIGEST_DOMAIN)?;
let root_uuid = reader.fixed::<16>()?;
let journal_id = reader.fixed::<16>()?;
let generation = reader.u64()?;
let first_shard_sequence = reader.u64()?;
let last_shard_sequence = reader.u64()?;
let frame_count = reader.u64()?;
let count = reader.count(24)?;
if count as u64 != frame_count {
return Err(FrameError::Payload(
"segment frame_count disagrees with the offset table",
));
}
let mut offsets = Vec::with_capacity(count);
let mut previous: Option<u64> = None;
for _ in 0..count {
let sequence = reader.u64()?;
let offset = reader.u64()?;
let len = reader.u64()?;
if let Some(previous) = previous {
if sequence <= previous {
return Err(FrameError::Payload("segment offsets must strictly ascend"));
}
}
previous = Some(sequence);
if len < MIN_FRAME_LEN || len % FRAME_ALIGNMENT != 0 {
return Err(FrameError::Length);
}
offset.checked_add(len).ok_or(FrameError::Length)?;
offsets.push((sequence, offset, len));
}
if reader.u64()? != bytes.len() as u64 {
return Err(FrameError::Length);
}
if reader.fixed::<8>()? != SEGMENT_FOOTER_MAGIC {
return Err(FrameError::Trailer);
}
reader.finish()?;
let out = Self {
root_uuid,
journal_id,
generation,
first_shard_sequence,
last_shard_sequence,
frame_count,
offsets,
};
if out.frame_count > 0 {
let first = out.offsets.first().expect("non-empty").0;
let last = out.offsets.last().expect("non-empty").0;
if first != out.first_shard_sequence || last != out.last_shard_sequence {
return Err(FrameError::Payload(
"segment range disagrees with the offset table",
));
}
}
Ok(out)
}
/// Parse the fixed tail every sealed segment ends with, returning the
/// footer's total length. The caller then reads exactly that many bytes
/// ending at end of file and hands them to [`SegmentFooter::decode`].
pub fn decode_locator(tail: &[u8]) -> Result<u64, FrameError> {
if tail.len() != SEGMENT_FOOTER_LOCATOR_LEN {
return Err(FrameError::Length);
}
if tail[8..16] != SEGMENT_FOOTER_MAGIC {
return Err(FrameError::HeaderMagic);
}
let footer_len = read_u64(tail, 0);
if footer_len < SEGMENT_FOOTER_LOCATOR_LEN as u64 + 12 {
return Err(FrameError::Length);
}
Ok(footer_len)
}
}
// ---------------------------------------------------------------------------
// Manifest and CURRENT
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TailRange {
pub generation: u64,
pub first_shard_sequence: u64,
pub last_shard_sequence: u64,
pub filename: String,
}
/// A durable, immutable, versioned shard manifest.
///
/// Written under a new `<generation>` name with `rename_noreplace`, with at
/// least `manifest_retain` generations kept. `CURRENT` is the atomically
/// replaced pointer naming the active generation; recovery falls back to the
/// newest valid manifest when `CURRENT` or its referent does not validate
/// (scope 3.1, 3.5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Manifest {
pub root_uuid: [u8; 16],
pub generation: u64,
/// Always 0 in Phase 1. The field is the seam for Phase 4 compaction's
/// `BaselineStateV1` base plus retained tails, so installing a baseline
/// later needs no format change.
pub base_generation: u64,
pub retained_tail_ranges: Vec<TailRange>,
pub index_runs: Vec<(u64, String)>,
pub checkpoints: Vec<(u64, String)>,
pub committed_shard_sequence: u64,
}
impl Manifest {
pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
let mut writer = begin_digested(MANIFEST_MAGIC, 256);
writer.fixed(&self.root_uuid);
writer.u64(self.generation);
writer.u64(self.base_generation);
writer.u64(self.committed_shard_sequence);
// Every list is strictly ascending by its key, so one manifest state
// has exactly one encoding and a duplicate generation cannot hide.
writer.count(self.retained_tail_ranges.len())?;
let mut previous: Option<u64> = None;
for range in &self.retained_tail_ranges {
if previous.is_some_and(|p| range.generation <= p) {
return Err(FrameError::Payload("tail ranges must strictly ascend"));
}
previous = Some(range.generation);
if range.last_shard_sequence < range.first_shard_sequence {
return Err(FrameError::Payload("tail range is inverted"));
}
writer.u64(range.generation);
writer.u64(range.first_shard_sequence);
writer.u64(range.last_shard_sequence);
writer.name(&range.filename)?;
}
writer.count(self.index_runs.len())?;
let mut previous: Option<u64> = None;
for (generation, filename) in &self.index_runs {
if previous.is_some_and(|p| *generation <= p) {
return Err(FrameError::Payload("index runs must strictly ascend"));
}
previous = Some(*generation);
writer.u64(*generation);
writer.name(filename)?;
}
writer.count(self.checkpoints.len())?;
let mut previous: Option<u64> = None;
for (shard_sequence, filename) in &self.checkpoints {
if previous.is_some_and(|p| *shard_sequence <= p) {
return Err(FrameError::Payload("checkpoints must strictly ascend"));
}
previous = Some(*shard_sequence);
writer.u64(*shard_sequence);
writer.name(filename)?;
}
Ok(finish_digested(writer, MANIFEST_DIGEST_DOMAIN))
}
pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
let mut reader = open_digested(bytes, MANIFEST_MAGIC, MANIFEST_DIGEST_DOMAIN)?;
let root_uuid = reader.fixed::<16>()?;
let generation = reader.u64()?;
let base_generation = reader.u64()?;
let committed_shard_sequence = reader.u64()?;
let count = reader.count(29)?;
let mut retained_tail_ranges = Vec::with_capacity(count);
let mut previous: Option<u64> = None;
for _ in 0..count {
let range = TailRange {
generation: reader.u64()?,
first_shard_sequence: reader.u64()?,
last_shard_sequence: reader.u64()?,
filename: reader.name()?,
};
if previous.is_some_and(|p| range.generation <= p) {
return Err(FrameError::Payload("tail ranges must strictly ascend"));
}
previous = Some(range.generation);
if range.last_shard_sequence < range.first_shard_sequence {
return Err(FrameError::Payload("tail range is inverted"));
}
retained_tail_ranges.push(range);
}
let count = reader.count(13)?;
let mut index_runs = Vec::with_capacity(count);
let mut previous: Option<u64> = None;
for _ in 0..count {
let generation = reader.u64()?;
let filename = reader.name()?;
if previous.is_some_and(|p| generation <= p) {
return Err(FrameError::Payload("index runs must strictly ascend"));
}
previous = Some(generation);
index_runs.push((generation, filename));
}
let count = reader.count(13)?;
let mut checkpoints = Vec::with_capacity(count);
let mut previous: Option<u64> = None;
for _ in 0..count {
let shard_sequence = reader.u64()?;
let filename = reader.name()?;
if previous.is_some_and(|p| shard_sequence <= p) {
return Err(FrameError::Payload("checkpoints must strictly ascend"));
}
previous = Some(shard_sequence);
checkpoints.push((shard_sequence, filename));
}
reader.finish()?;
Ok(Self {
root_uuid,
generation,
base_generation,
retained_tail_ranges,
index_runs,
checkpoints,
committed_shard_sequence,
})
}
}
/// The `CURRENT` pointer: names the active manifest generation and nothing
/// else. Small on purpose — the less it carries, the less a corrupt pointer
/// costs.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct CurrentPointer {
pub root_uuid: [u8; 16],
pub generation: u64,
}
/// Exact encoded length of a `CURRENT` pointer: 12 header + 16 root_uuid +
/// 8 generation + 32 digest.
pub const CURRENT_POINTER_LEN: usize = 68;
impl CurrentPointer {
pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
let mut writer = begin_digested(CURRENT_MAGIC, CURRENT_POINTER_LEN);
writer.fixed(&self.root_uuid);
writer.u64(self.generation);
let bytes = finish_digested(writer, CURRENT_DIGEST_DOMAIN);
debug_assert_eq!(bytes.len(), CURRENT_POINTER_LEN);
Ok(bytes)
}
pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
if bytes.len() != CURRENT_POINTER_LEN {
return Err(FrameError::Length);
}
let mut reader = open_digested(bytes, CURRENT_MAGIC, CURRENT_DIGEST_DOMAIN)?;
let out = Self {
root_uuid: reader.fixed::<16>()?,
generation: reader.u64()?,
};
reader.finish()?;
Ok(out)
}
}
// ---------------------------------------------------------------------------
// FORMAT
// ---------------------------------------------------------------------------
/// The root format marker. `shard_count` is frozen here at initialization and
/// is immutable for the life of the root: opening with a different configured
/// value is a hard `FormatMismatch`, never a silent reroute (scope 2.5).
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FormatMarker {
pub format_version: u16,
pub storage_version: u16,
pub shard_count: u16,
pub root_uuid: [u8; 16],
pub created_at_micros: i64,
}
/// Exact encoded length of `FORMAT`: 12 header + 2 format_version +
/// 2 shard_count + 4 reserved + 16 root_uuid + 8 created_at + 32 digest.
pub const FORMAT_MARKER_LEN: usize = 76;
impl FormatMarker {
pub fn encode(&self) -> Result<Vec<u8>, FrameError> {
if self.storage_version != STORAGE_VERSION {
return Err(FrameError::HeaderMagic);
}
if self.shard_count == 0 {
return Err(FrameError::Payload("shard_count must be nonzero"));
}
// `begin_digested` already writes the storage version; `format_version`
// is the separate root-layout version and follows it.
let mut writer = begin_digested(FORMAT_MAGIC, FORMAT_MARKER_LEN);
writer.u16(self.format_version);
writer.u16(self.shard_count);
writer.u32(0);
writer.fixed(&self.root_uuid);
writer.i64(self.created_at_micros);
let bytes = finish_digested(writer, FORMAT_DIGEST_DOMAIN);
debug_assert_eq!(bytes.len(), FORMAT_MARKER_LEN);
Ok(bytes)
}
pub fn decode(bytes: &[u8]) -> Result<Self, FrameError> {
if bytes.len() != FORMAT_MARKER_LEN {
return Err(FrameError::Length);
}
let mut reader = open_digested(bytes, FORMAT_MAGIC, FORMAT_DIGEST_DOMAIN)?;
let format_version = reader.u16()?;
let shard_count = reader.u16()?;
if reader.u32()? != 0 {
return Err(FrameError::Padding);
}
let out = Self {
format_version,
storage_version: STORAGE_VERSION,
shard_count,
root_uuid: reader.fixed::<16>()?,
created_at_micros: reader.i64()?,
};
reader.finish()?;
if out.shard_count == 0 {
return Err(FrameError::Payload("shard_count must be nonzero"));
}
Ok(out)
}
}
/// Domain-separated BLAKE3 over `bytes`.
pub fn digest(domain: &'static [u8], bytes: &[u8]) -> ObjectId {
let mut hasher = blake3::Hasher::new();
hasher.update(domain);
hasher.update(bytes);
ObjectId(*hasher.finalize().as_bytes())
}
// ---------------------------------------------------------------------------
// A1 additions: bounds and the checked canonical codec
//
// Additive only. No lead-authored type, constant, domain string, or signature
// above this point is modified.
// ---------------------------------------------------------------------------
/// Maximum bytes in one on-disk name recorded in a manifest.
pub const MAX_STORE_NAME_LEN: usize = 255;
/// Maximum inline objects in one frame payload. Matches the default
/// `max_objects_per_transaction` and the frozen `MAX_EVENT_OBJECT_IDS`.
pub const MAX_FRAME_OBJECTS: usize = 65_536;
/// Maximum raw bytes of one inline object in a frame payload.
pub const MAX_FRAME_OBJECT_BYTES: usize = levcs_protocol::codec::MAX_CANONICAL_BYTES;
/// Maximum entries in a segment footer offset table, a manifest tail range
/// list, an index-run list, or a checkpoint list.
pub const MAX_TABLE_ENTRIES: usize = 16_000_000;
/// Byte offsets of the frozen 176-byte frame header (scope 3.3).
mod off {
pub(super) const MAGIC: usize = 0;
pub(super) const STORAGE_VERSION: usize = 8;
pub(super) const HEADER_LEN: usize = 10;
pub(super) const FLAGS: usize = 12;
pub(super) const TOTAL_LEN: usize = 16;
pub(super) const JOURNAL_ID: usize = 24;
pub(super) const SHARD_SEQUENCE: usize = 40;
pub(super) const REPO_SEQUENCE: usize = 48;
pub(super) const NAMESPACE: usize = 56;
pub(super) const OPERATION_ID: usize = 88;
pub(super) const OPERATION_DIGEST: usize = 104;
pub(super) const PAYLOAD_LEN: usize = 136;
pub(super) const PAYLOAD_DIGEST: usize = 144;
pub(super) const TRAILER_MAGIC: usize = 0;
pub(super) const TRAILER_TOTAL_LEN: usize = 8;
pub(super) const TRAILER_DIGEST: usize = 16;
}
/// Byte offsets of the frozen 512-byte journal header (scope 3.2).
mod joff {
pub(super) const MAGIC: usize = 0;
pub(super) const STORAGE_VERSION: usize = 8;
pub(super) const HEADER_LEN: usize = 10;
pub(super) const SHARD_INDEX: usize = 12;
pub(super) const PAD0: usize = 14;
pub(super) const ROOT_UUID: usize = 16;
pub(super) const JOURNAL_ID: usize = 32;
pub(super) const FIRST_SHARD_SEQUENCE: usize = 48;
pub(super) const PREALLOCATED_LEN: usize = 56;
pub(super) const CREATED_AT_MICROS: usize = 64;
pub(super) const HEADER_DIGEST: usize = 72;
/// Everything from here to 512 is padding and must be zero.
pub(super) const PAD1: usize = 104;
}
/// A small checked canonical binary codec, in the Phase 0 discipline: no
/// `serde`, one representation per value, every length checked before it is
/// used to allocate, and trailing bytes rejected.
///
/// It is deliberately a second implementation rather than a re-export of
/// `levcs_protocol::codec`, whose `Reader`/`Writer` are `pub(crate)` to that
/// crate. Where a protocol type is embedded it is embedded as its own
/// canonical bytes and re-decoded through `CanonicalCodec`, so the protocol's
/// representation is never restated here.
mod wire {
use super::{FrameError, MAX_STORE_NAME_LEN, MAX_TABLE_ENTRIES};
pub(super) struct W {
pub(super) bytes: Vec<u8>,
}
impl W {
pub(super) fn with_capacity(capacity: usize) -> Self {
Self {
bytes: Vec::with_capacity(capacity),
}
}
pub(super) fn u8(&mut self, value: u8) {
self.bytes.push(value);
}
pub(super) fn u16(&mut self, value: u16) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(super) fn u32(&mut self, value: u32) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(super) fn u64(&mut self, value: u64) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(super) fn i64(&mut self, value: i64) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(super) fn fixed(&mut self, value: &[u8]) {
self.bytes.extend_from_slice(value);
}
pub(super) fn bool(&mut self, value: bool) {
self.u8(u8::from(value));
}
pub(super) fn count(&mut self, count: usize) -> Result<(), FrameError> {
if count > MAX_TABLE_ENTRIES {
return Err(FrameError::Length);
}
self.u32(count as u32);
Ok(())
}
pub(super) fn blob(&mut self, value: &[u8], limit: usize) -> Result<(), FrameError> {
if value.len() > limit || value.len() > u32::MAX as usize {
return Err(FrameError::Length);
}
self.u32(value.len() as u32);
self.fixed(value);
Ok(())
}
/// A single path component recorded in a manifest.
///
/// A manifest names files inside one shard directory. A name carrying
/// `/`, `..`, NUL, or a leading `.` would let a corrupt or hostile
/// manifest reference a file outside the shard, so the constraint is
/// enforced on the encode side as well as the decode side.
pub(super) fn name(&mut self, value: &str) -> Result<(), FrameError> {
validate_name(value)?;
self.blob(value.as_bytes(), MAX_STORE_NAME_LEN)
}
}
pub(super) fn validate_name(value: &str) -> Result<(), FrameError> {
if value.is_empty() || value.len() > MAX_STORE_NAME_LEN {
return Err(FrameError::Payload("store name length"));
}
if value.starts_with('.') {
return Err(FrameError::Payload("store name starts with a dot"));
}
for byte in value.bytes() {
let ok = byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_');
if !ok {
return Err(FrameError::Payload("store name character"));
}
}
Ok(())
}
pub(super) struct R<'a> {
bytes: &'a [u8],
cursor: usize,
}
impl<'a> R<'a> {
pub(super) fn new(bytes: &'a [u8]) -> Self {
Self { bytes, cursor: 0 }
}
pub(super) fn remaining(&self) -> usize {
self.bytes.len() - self.cursor
}
fn take(&mut self, len: usize) -> Result<&'a [u8], FrameError> {
let end = self.cursor.checked_add(len).ok_or(FrameError::Length)?;
let out = self.bytes.get(self.cursor..end).ok_or(FrameError::Length)?;
self.cursor = end;
Ok(out)
}
pub(super) fn u8(&mut self) -> Result<u8, FrameError> {
Ok(self.take(1)?[0])
}
pub(super) fn u16(&mut self) -> Result<u16, FrameError> {
Ok(u16::from_le_bytes(self.fixed::<2>()?))
}
pub(super) fn u32(&mut self) -> Result<u32, FrameError> {
Ok(u32::from_le_bytes(self.fixed::<4>()?))
}
pub(super) fn u64(&mut self) -> Result<u64, FrameError> {
Ok(u64::from_le_bytes(self.fixed::<8>()?))
}
pub(super) fn i64(&mut self) -> Result<i64, FrameError> {
Ok(i64::from_le_bytes(self.fixed::<8>()?))
}
pub(super) fn fixed<const N: usize>(&mut self) -> Result<[u8; N], FrameError> {
let mut out = [0u8; N];
out.copy_from_slice(self.take(N)?);
Ok(out)
}
pub(super) fn bool(&mut self) -> Result<bool, FrameError> {
match self.u8()? {
0 => Ok(false),
1 => Ok(true),
_ => Err(FrameError::Payload("boolean must be 0 or 1")),
}
}
/// Element count, bounded before it is used to reserve. The second
/// bound — one element cannot be smaller than `min_element_bytes` —
/// is what stops a four-byte count from reserving gigabytes.
pub(super) fn count(&mut self, min_element_bytes: usize) -> Result<usize, FrameError> {
let count = self.u32()? as usize;
if count > MAX_TABLE_ENTRIES {
return Err(FrameError::Length);
}
let affordable = self.remaining() / min_element_bytes.max(1);
if count > affordable {
return Err(FrameError::Length);
}
Ok(count)
}
pub(super) fn blob(&mut self, limit: usize) -> Result<Vec<u8>, FrameError> {
let len = self.u32()? as usize;
if len > limit || len > self.remaining() {
return Err(FrameError::Length);
}
Ok(self.take(len)?.to_vec())
}
pub(super) fn name(&mut self) -> Result<String, FrameError> {
let bytes = self.blob(MAX_STORE_NAME_LEN)?;
let value = String::from_utf8(bytes).map_err(|_| FrameError::Payload("name utf-8"))?;
validate_name(&value)?;
Ok(value)
}
/// Trailing-byte rejection. A canonical encoding has exactly one
/// representation, so unconsumed input is a rejection and never a
/// tolerated extension.
pub(super) fn finish(self) -> Result<(), FrameError> {
if self.cursor == self.bytes.len() {
Ok(())
} else {
Err(FrameError::Length)
}
}
}
}
use wire::{R, W};
/// The single in-crate statement of the store's object-type codes.
///
/// `pub(crate)` rather than private because `recovery.rs` and the engine both
/// have to write the same code into an index entry, and a private mapping does
/// not prevent a second table — it only guarantees the second table is written
/// somewhere else and compared to this one by review. The recovered index entry
/// and the submitted object are compared end to end by reopening a store, and
/// that comparison is only meaningful while there is one table to disagree
/// with.
pub(crate) fn object_type_code(value: ObjectType) -> u8 {
// Exhaustive on purpose: a new object type must break this build rather
// than acquire an undocumented on-disk code.
match value {
ObjectType::Blob => 1,
ObjectType::Tree => 2,
ObjectType::Commit => 3,
ObjectType::Release => 4,
ObjectType::Authority => 5,
}
}
fn object_type_from_code(code: u8) -> Result<ObjectType, FrameError> {
match code {
1 => Ok(ObjectType::Blob),
2 => Ok(ObjectType::Tree),
3 => Ok(ObjectType::Commit),
4 => Ok(ObjectType::Release),
5 => Ok(ObjectType::Authority),
_ => Err(FrameError::Payload("object type discriminant")),
}
}
fn projection_code(value: ProjectionMode) -> u8 {
match value {
ProjectionMode::Full => 1,
ProjectionMode::Release => 2,
ProjectionMode::Metadata => 3,
}
}
fn projection_from_code(code: u8) -> Result<ProjectionMode, FrameError> {
match code {
1 => Ok(ProjectionMode::Full),
2 => Ok(ProjectionMode::Release),
3 => Ok(ProjectionMode::Metadata),
_ => Err(FrameError::Payload("projection mode discriminant")),
}
}
// ---------------------------------------------------------------------------
// Frame payload
// ---------------------------------------------------------------------------
/// One inline new object: embedded type, ObjectId, exact raw length, exact raw
/// bytes (plan §5.2).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrameObjectV1 {
pub object_type: ObjectType,
pub object_id: ObjectId,
pub raw: Vec<u8>,
}
/// A frame carries **either** inline new objects **or** exactly one
/// `StagedProjectionInstallV1` descriptor — never both, and the encoding makes
/// the exclusivity structural rather than a checked invariant.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FrameObjectsV1 {
Inline(Vec<FrameObjectV1>),
StagedProjectionInstall(StagedProjectionInstallV1),
}
/// Repository-create metadata, present only on the transaction that creates a
/// repository.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct RepositoryCreateV1 {
pub genesis_authority: ObjectId,
pub genesis_len: u64,
pub genesis_hash: ObjectId,
pub projection: ProjectionMode,
}
/// The deterministic receipt fields a frame carries so that a receipt survives
/// recovery without consulting anything else.
///
/// `refs`, `current_authority`, and `repo_sequence` are not repeated here: they
/// are already in the embedded `CommittedTransactionV1` and the frame header,
/// and a second copy could disagree with the first.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FrameReceiptFieldsV1 {
pub objects_new: u64,
pub retry_until_micros: i64,
pub first_receipt_visibility_micros: i64,
}
/// The canonical frame payload of scope 3.3.
///
/// Embedded frozen protocol values are stored as their own canonical bytes and
/// re-decoded through `CanonicalCodec` on the way out, so this codec never
/// restates a frozen representation and cannot drift from one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransactionFramePayloadV1 {
pub repository_create: Option<RepositoryCreateV1>,
pub objects: FrameObjectsV1,
pub ref_cas: Vec<TypedRefCas>,
pub expected_authority: ObjectId,
pub new_authority: ObjectId,
pub evidence: TransactionEvidenceV1,
pub committed: SignedCommittedTransactionV1,
pub receipt: FrameReceiptFieldsV1,
}
/// Store-local encoding of the typed ref CAS set.
///
/// `TypedRefCas`, `RefTarget`, and `RefMutation` are frozen protocol types
/// whose canonical codecs are private to `levcs-protocol` — they implement no
/// public `CanonicalCodec`. The store therefore has to define its own physical
/// encoding for the "complete typed ref CAS set" the frame must carry. Plan
/// §13 keeps the physical format internal, so this is a store-private byte
/// layout and not a second protocol encoding; the *validation* rules are not
/// restated, they are delegated to `levcs_core::refs::validate_ref_name`, the
/// same function the protocol delegates to.
fn encode_ref_target(writer: &mut W, target: &RefTarget) -> Result<(), FrameError> {
let (tag, name) = match target {
RefTarget::Branch(name) => (1u8, name),
RefTarget::Release(name) => (2u8, name),
};
validate_ref_name(name)?;
writer.u8(tag);
writer.blob(name.as_bytes(), MAX_STORE_NAME_LEN)
}
fn validate_ref_name(name: &str) -> Result<(), FrameError> {
if name.is_empty() || name.len() > MAX_STORE_NAME_LEN {
return Err(FrameError::Payload("ref name length"));
}
if name.as_bytes().contains(&0) {
return Err(FrameError::Payload("ref name contains NUL"));
}
levcs_core::refs::validate_ref_name(name).map_err(|_| FrameError::Payload("ref name"))
}
fn decode_ref_target(reader: &mut R<'_>) -> Result<RefTarget, FrameError> {
let tag = reader.u8()?;
let bytes = reader.blob(MAX_STORE_NAME_LEN)?;
let name = String::from_utf8(bytes).map_err(|_| FrameError::Payload("ref name utf-8"))?;
validate_ref_name(&name)?;
match tag {
1 => Ok(RefTarget::Branch(name)),
2 => Ok(RefTarget::Release(name)),
_ => Err(FrameError::Payload("ref target discriminant")),
}
}
fn encode_optional_id(writer: &mut W, value: Option<ObjectId>) {
match value {
Some(id) => {
writer.u8(1);
writer.fixed(id.as_bytes());
}
None => writer.u8(0),
}
}
fn decode_optional_id(reader: &mut R<'_>) -> Result<Option<ObjectId>, FrameError> {
match reader.u8()? {
0 => Ok(None),
1 => Ok(Some(ObjectId(reader.fixed::<32>()?))),
_ => Err(FrameError::Payload("optional object id discriminant")),
}
}
fn encode_ref_cas(writer: &mut W, updates: &[TypedRefCas]) -> Result<(), FrameError> {
if updates.len() > MAX_REF_UPDATES {
return Err(FrameError::Length);
}
// Duplicate targets in one transaction would make "exact same-ref winner"
// (Phase 1 exit) ambiguous inside a single frame, so the canonical form
// forbids them.
let mut seen = std::collections::BTreeSet::new();
for update in updates {
if !seen.insert(update.target.clone()) {
return Err(FrameError::Payload("duplicate ref target"));
}
if update.mutation == RefMutation::Delete && update.expected.is_none() {
return Err(FrameError::Payload("delete requires an expected object"));
}
}
writer.count(updates.len())?;
for update in updates {
encode_ref_target(writer, &update.target)?;
encode_optional_id(writer, update.expected);
match update.mutation {
RefMutation::Set(id) => {
writer.u8(1);
writer.fixed(id.as_bytes());
}
RefMutation::Delete => writer.u8(2),
}
writer.bool(update.force);
}
Ok(())
}
fn decode_ref_cas(reader: &mut R<'_>) -> Result<Vec<TypedRefCas>, FrameError> {
// Smallest possible element: tag + 4-byte name length + 1 name byte +
// absent-expected + Delete tag + force = 9 bytes.
let count = reader.count(9)?;
if count > MAX_REF_UPDATES {
return Err(FrameError::Length);
}
let mut updates = Vec::with_capacity(count);
let mut seen = std::collections::BTreeSet::new();
for _ in 0..count {
let target = decode_ref_target(reader)?;
let expected = decode_optional_id(reader)?;
let mutation = match reader.u8()? {
1 => RefMutation::Set(ObjectId(reader.fixed::<32>()?)),
2 => RefMutation::Delete,
_ => return Err(FrameError::Payload("ref mutation discriminant")),
};
let force = reader.bool()?;
if mutation == RefMutation::Delete && expected.is_none() {
return Err(FrameError::Payload("delete requires an expected object"));
}
if !seen.insert(target.clone()) {
return Err(FrameError::Payload("duplicate ref target"));
}
updates.push(TypedRefCas {
target,
expected,
mutation,
force,
});
}
Ok(updates)
}
/// The facts recovery and the crash matrix need out of a frame payload,
/// extracted once so neither has to reach into the embedded protocol values
/// and neither can disagree with the other about where they live.
///
/// Interface change request from A2 RecoveryIndex, approved by the lead:
/// recovery step 9 verifies the per-repository event chain and needs
/// `previous_event_digest` and `event_digest`; the matrix needs `source_kind`.
/// Everything here is read off the embedded canonical
/// `CommittedTransactionV1` or the receipt fields — nothing is recomputed from
/// a second source, so a fact cannot disagree with the bytes it came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadFacts {
pub source_kind: SourceKindV1,
pub repo_id: ObjectId,
pub repo_sequence: u64,
/// The chain link recovery step 9 walks.
pub previous_event_digest: ObjectId,
/// `BLAKE3("levcs-event/v1\0" || canonical CommittedTransactionV1)`, from
/// the frozen protocol function. The event digest excludes its signature,
/// so later reservations chain deterministically (plan §5.2).
pub event_digest: ObjectId,
pub resulting_state_digest: ObjectId,
pub old_authority: ObjectId,
pub new_authority: ObjectId,
pub source_key_epoch: u64,
pub objects_new: u64,
pub retry_until_micros: i64,
pub first_receipt_visibility_micros: i64,
}
impl TransactionFramePayloadV1 {
/// The source of the mutation, as recorded in the embedded event.
pub fn source_kind(&self) -> SourceKindV1 {
self.committed.transaction.source_kind
}
/// The previous link in this repository's event chain.
pub fn previous_event_digest(&self) -> ObjectId {
self.committed.transaction.previous_event_digest
}
/// This frame's event digest, computed by the frozen protocol function
/// rather than restated here.
pub fn event_digest(&self) -> Result<ObjectId, FrameError> {
self.committed
.transaction
.event_digest()
.map_err(|_| FrameError::Payload("event digest"))
}
pub fn repo_id(&self) -> ObjectId {
self.committed.transaction.repo_id
}
pub fn repo_sequence(&self) -> u64 {
self.committed.transaction.repo_sequence
}
pub fn resulting_state_digest(&self) -> ObjectId {
self.committed.transaction.resulting_state_digest
}
/// Everything the recovery chain check and the crash matrix bind to.
pub fn facts(&self) -> Result<PayloadFacts, FrameError> {
let transaction = &self.committed.transaction;
Ok(PayloadFacts {
source_kind: transaction.source_kind,
repo_id: transaction.repo_id,
repo_sequence: transaction.repo_sequence,
previous_event_digest: transaction.previous_event_digest,
event_digest: self.event_digest()?,
resulting_state_digest: transaction.resulting_state_digest,
old_authority: transaction.old_authority,
new_authority: transaction.new_authority,
source_key_epoch: self.committed.source_key_epoch,
objects_new: self.receipt.objects_new,
retry_until_micros: self.receipt.retry_until_micros,
first_receipt_visibility_micros: self.receipt.first_receipt_visibility_micros,
})
}
/// Decode a payload and extract its facts in one step.
///
/// This is a full canonical decode, not a partial one: a payload whose
/// facts are read is a payload the protocol crate also accepts, so a
/// recovered frame can never contribute a chain link that came out of
/// bytes nothing revalidated.
pub fn decode_facts(bytes: &[u8]) -> Result<PayloadFacts, FrameError> {
Self::decode_canonical(bytes)?.facts()
}
/// Canonical payload bytes.
///
/// This is deliberately *not* invoked by `Frame::encode` or
/// `Frame::decode`: a `Frame` carries opaque payload bytes so that
/// completeness (scope 3.3) stays a purely physical property and recovery
/// never has to parse a payload to decide whether a frame is whole.
/// Interpretation is a separate, revalidating step.
pub fn encode_canonical(&self) -> Result<Vec<u8>, FrameError> {
let mut writer = W::with_capacity(1024);
match &self.repository_create {
None => writer.u8(0),
Some(create) => {
writer.u8(1);
writer.fixed(create.genesis_authority.as_bytes());
writer.u64(create.genesis_len);
writer.fixed(create.genesis_hash.as_bytes());
writer.u8(projection_code(create.projection));
}
}
match &self.objects {
FrameObjectsV1::Inline(objects) => {
if objects.len() > MAX_FRAME_OBJECTS {
return Err(FrameError::Length);
}
let mut previous: Option<&ObjectId> = None;
for object in objects {
// Strictly ascending by ObjectId: one membership set has
// exactly one encoding, and a duplicate cannot hide in it.
if let Some(previous) = previous {
if object.object_id.as_bytes() <= previous.as_bytes() {
return Err(FrameError::Payload("frame objects must strictly ascend"));
}
}
previous = Some(&object.object_id);
}
writer.u8(1);
writer.count(objects.len())?;
for object in objects {
writer.u8(object_type_code(object.object_type));
writer.fixed(object.object_id.as_bytes());
writer.blob(&object.raw, MAX_FRAME_OBJECT_BYTES)?;
}
}
FrameObjectsV1::StagedProjectionInstall(install) => {
writer.u8(2);
let bytes = install
.encode_canonical()
.map_err(|_| FrameError::Payload("staged projection install"))?;
writer.blob(&bytes, MAX_FRAME_OBJECT_BYTES)?;
}
}
encode_ref_cas(&mut writer, &self.ref_cas)?;
writer.fixed(self.expected_authority.as_bytes());
writer.fixed(self.new_authority.as_bytes());
let evidence = self
.evidence
.encode_canonical()
.map_err(|_| FrameError::Payload("transaction evidence"))?;
writer.blob(&evidence, MAX_FRAME_OBJECT_BYTES)?;
let committed = self
.committed
.encode_canonical()
.map_err(|_| FrameError::Payload("signed committed transaction"))?;
writer.blob(&committed, MAX_FRAME_OBJECT_BYTES)?;
writer.u64(self.receipt.objects_new);
writer.i64(self.receipt.retry_until_micros);
writer.i64(self.receipt.first_receipt_visibility_micros);
Ok(writer.bytes)
}
/// Decode and revalidate. Every embedded frozen value is re-decoded
/// through its own canonical codec, so a payload that decodes here is one
/// the protocol crate also accepts.
pub fn decode_canonical(bytes: &[u8]) -> Result<Self, FrameError> {
let mut reader = R::new(bytes);
let repository_create = match reader.u8()? {
0 => None,
1 => Some(RepositoryCreateV1 {
genesis_authority: ObjectId(reader.fixed::<32>()?),
genesis_len: reader.u64()?,
genesis_hash: ObjectId(reader.fixed::<32>()?),
projection: projection_from_code(reader.u8()?)?,
}),
_ => return Err(FrameError::Payload("repository create discriminant")),
};
let objects = match reader.u8()? {
1 => {
// Smallest inline object: type + id + 4-byte length = 37.
let count = reader.count(37)?;
if count > MAX_FRAME_OBJECTS {
return Err(FrameError::Length);
}
let mut objects = Vec::with_capacity(count);
let mut previous: Option<ObjectId> = None;
for _ in 0..count {
let object_type = object_type_from_code(reader.u8()?)?;
let object_id = ObjectId(reader.fixed::<32>()?);
if let Some(previous) = previous {
if object_id.as_bytes() <= previous.as_bytes() {
return Err(FrameError::Payload("frame objects must strictly ascend"));
}
}
previous = Some(object_id);
let raw = reader.blob(MAX_FRAME_OBJECT_BYTES)?;
objects.push(FrameObjectV1 {
object_type,
object_id,
raw,
});
}
FrameObjectsV1::Inline(objects)
}
2 => {
let bytes = reader.blob(MAX_FRAME_OBJECT_BYTES)?;
FrameObjectsV1::StagedProjectionInstall(
StagedProjectionInstallV1::decode_canonical(&bytes)
.map_err(|_| FrameError::Payload("staged projection install"))?,
)
}
_ => return Err(FrameError::Payload("frame objects discriminant")),
};
let ref_cas = decode_ref_cas(&mut reader)?;
let expected_authority = ObjectId(reader.fixed::<32>()?);
let new_authority = ObjectId(reader.fixed::<32>()?);
let evidence_bytes = reader.blob(MAX_FRAME_OBJECT_BYTES)?;
let evidence = TransactionEvidenceV1::decode_canonical(&evidence_bytes)
.map_err(|_| FrameError::Payload("transaction evidence"))?;
let committed_bytes = reader.blob(MAX_FRAME_OBJECT_BYTES)?;
let committed = SignedCommittedTransactionV1::decode_canonical(&committed_bytes)
.map_err(|_| FrameError::Payload("signed committed transaction"))?;
let receipt = FrameReceiptFieldsV1 {
objects_new: reader.u64()?,
retry_until_micros: reader.i64()?,
first_receipt_visibility_micros: reader.i64()?,
};
reader.finish()?;
Ok(Self {
repository_create,
objects,
ref_cas,
expected_authority,
new_authority,
evidence,
committed,
receipt,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The header table in scope 3.3 sums to exactly 176 and the minimum frame
/// to 224. Pinning the arithmetic here means a field added without
/// resizing the header breaks the build rather than the format.
#[test]
fn fixed_lengths_are_pinned() {
assert_eq!(FRAME_HEADER_LEN, 176);
assert_eq!(FRAME_TRAILER_LEN, 48);
assert_eq!(MIN_FRAME_LEN, 224);
assert_eq!(JOURNAL_HEADER_LEN, 512);
}
#[test]
fn padding_places_the_trailer_on_an_eight_byte_boundary() {
for payload_len in 0u64..64 {
let total = frame_total_len(payload_len);
assert_eq!(
total % FRAME_ALIGNMENT,
0,
"total_len must be 8-aligned for payload_len {payload_len}"
);
assert!(total >= MIN_FRAME_LEN);
let pad = frame_padding_len(payload_len);
assert!(pad < FRAME_ALIGNMENT);
assert_eq!(
FRAME_HEADER_LEN as u64 + payload_len + pad + FRAME_TRAILER_LEN as u64,
total
);
}
}
#[test]
fn every_domain_string_is_distinct_and_nul_terminated() {
let domains = [
FRAME_DIGEST_DOMAIN,
FRAME_PAYLOAD_DIGEST_DOMAIN,
JOURNAL_HEADER_DIGEST_DOMAIN,
SEGMENT_FOOTER_DIGEST_DOMAIN,
MANIFEST_DIGEST_DOMAIN,
CURRENT_DIGEST_DOMAIN,
CHECKPOINT_DIGEST_DOMAIN,
FORMAT_DIGEST_DOMAIN,
];
let unique: std::collections::BTreeSet<_> = domains.iter().collect();
assert_eq!(unique.len(), domains.len(), "domains must be distinct");
for domain in domains {
assert_eq!(
domain.last(),
Some(&0u8),
"domain {:?} must be NUL-terminated so no domain is a prefix of another",
std::str::from_utf8(domain).unwrap_or("<non-utf8>")
);
}
}
#[test]
fn every_file_magic_is_distinct() {
let magics = [
FRAME_MAGIC,
FRAME_TRAILER_MAGIC,
JOURNAL_MAGIC,
SEGMENT_FOOTER_MAGIC,
MANIFEST_MAGIC,
CURRENT_MAGIC,
CHECKPOINT_MAGIC,
FORMAT_MAGIC,
];
let unique: std::collections::BTreeSet<_> = magics.iter().collect();
assert_eq!(unique.len(), magics.len(), "magics must be distinct");
}
#[test]
fn digest_is_domain_separated() {
let a = digest(FRAME_DIGEST_DOMAIN, b"same bytes");
let b = digest(FRAME_PAYLOAD_DIGEST_DOMAIN, b"same bytes");
assert_ne!(a, b, "identical bytes under different domains must differ");
}
}