mod drop_payload;
mod header;
mod ops;
pub use drop_payload::{parse_drop_section, write_drop_section, EpochDrop};
pub use header::{parse_epoch_header, write_epoch_header, EpochFlags, EpochHeader, EPOCH_MAGIC};
pub use ops::{parse_op_section, write_op_section, EpochOp, EpochOpKind};
use crate::error::CoreError;
pub const EPOCH_FORMAT_VERSION: u16 = 1;
pub type EpochId = [u8; 32];
#[must_use]
pub fn compute_epoch_id(epoch_bytes: &[u8]) -> EpochId {
let mut id = [0u8; 32];
id.copy_from_slice(blake3::hash(epoch_bytes).as_bytes());
id
}
#[must_use]
pub fn hash_section(bytes: &[u8]) -> [u8; 32] {
let mut h = [0u8; 32];
h.copy_from_slice(blake3::hash(bytes).as_bytes());
h
}
#[derive(Clone, Debug)]
pub struct Epoch {
pub header: EpochHeader,
pub ops: Vec<EpochOp>,
pub drops: Vec<EpochDrop>,
}
impl Epoch {
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let ops_section = write_op_section(&self.ops);
let drops_section = write_drop_section(&self.drops);
let mut bytes = Vec::with_capacity(
header::HEADER_SIZE + 4 + ops_section.len() + 4 + drops_section.len(),
);
write_epoch_header(&self.header, &mut bytes);
bytes.extend_from_slice(&ops_section);
bytes.extend_from_slice(&drops_section);
bytes
}
#[must_use]
pub fn id(&self) -> EpochId {
let bytes = self.to_bytes();
compute_epoch_id(&bytes)
}
#[must_use]
pub fn build(mut header: EpochHeader, ops: Vec<EpochOp>, drops: Vec<EpochDrop>) -> Self {
let ops_section = write_op_section(&ops);
let drops_section = write_drop_section(&drops);
header.ops_hash = hash_section(&ops_section);
header.drops_hash = hash_section(&drops_section);
header.own_epoch_id = [0u8; 32];
let mut bytes = Vec::with_capacity(
header::HEADER_SIZE + 4 + ops_section.len() + 4 + drops_section.len(),
);
write_epoch_header(&header, &mut bytes);
bytes.extend_from_slice(&ops_section);
bytes.extend_from_slice(&drops_section);
header.own_epoch_id = compute_epoch_id(&bytes);
Self { header, ops, drops }
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CoreError> {
let mut cursor = crate::cursor::ManifestCursor::new(bytes);
let header = parse_epoch_header(&mut cursor)?;
let recomputed = {
let mut tmp = bytes.to_vec();
let offset = 4 + 2 + 2 + 32 + 32 + 8 + 32 + 32;
tmp[offset..offset + 32].copy_from_slice(&[0u8; 32]);
compute_epoch_id(&tmp)
};
if recomputed != header.own_epoch_id {
return Err(CoreError::Corrupt {
reason: format!(
"epoch own_epoch_id mismatch: header claims {} but bytes hash to {}",
hex(&header.own_epoch_id),
hex(&recomputed)
),
});
}
let ops = parse_op_section(&mut cursor)?;
let drops = parse_drop_section(&mut cursor)?;
Ok(Self { header, ops, drops })
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
write!(&mut s, "{b:02x}").expect("hex write");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_header() -> EpochHeader {
EpochHeader {
version: EPOCH_FORMAT_VERSION,
flags: EpochFlags::empty(),
parent_epoch_id: [0u8; 32],
base_image_root: [0u8; 32],
epoch_sequence: 1,
ops_hash: [0u8; 32],
drops_hash: [0u8; 32],
own_epoch_id: [0u8; 32],
timestamp_unix: 0,
}
}
#[test]
fn empty_epoch_round_trips() {
let original = Epoch::build(empty_header(), Vec::new(), Vec::new());
let bytes = original.to_bytes();
let parsed = Epoch::from_bytes(&bytes).expect("parse");
assert_eq!(parsed.header.version, EPOCH_FORMAT_VERSION);
assert_eq!(parsed.header.epoch_sequence, 1);
assert!(parsed.ops.is_empty());
assert!(parsed.drops.is_empty());
assert_eq!(parsed.header.own_epoch_id, original.header.own_epoch_id);
}
#[test]
fn epoch_id_is_deterministic() {
let epoch = Epoch::build(empty_header(), Vec::new(), Vec::new());
let id1 = epoch.id();
let id2 = epoch.id();
assert_eq!(id1, id2, "epoch id must be deterministic");
}
#[test]
fn ops_round_trip() {
let drop_id = [0xAA; 32];
let ops = vec![
EpochOp::Add {
path: "hello.txt".into(),
drop_id,
len: 11,
mode: 0o644,
mtime: 1_700_000_000,
},
EpochOp::Mkdir {
path: "subdir".into(),
mode: 0o755,
},
EpochOp::Remove {
path: "old.txt".into(),
},
];
let epoch = Epoch::build(empty_header(), ops.clone(), Vec::new());
let bytes = epoch.to_bytes();
let parsed = Epoch::from_bytes(&bytes).expect("parse");
assert_eq!(parsed.ops.len(), 3);
assert!(matches!(parsed.ops[0].kind(), EpochOpKind::Add));
assert!(matches!(parsed.ops[1].kind(), EpochOpKind::Mkdir));
assert!(matches!(parsed.ops[2].kind(), EpochOpKind::Remove));
}
#[test]
fn drops_round_trip() {
let drop_id = [0xBB; 32];
let drops = vec![EpochDrop {
drop_id,
codec: crate::codec::CODEC_STORE,
plaintext_len: 5,
payload: b"hello".to_vec(),
}];
let epoch = Epoch::build(empty_header(), Vec::new(), drops.clone());
let bytes = epoch.to_bytes();
let parsed = Epoch::from_bytes(&bytes).expect("parse");
assert_eq!(parsed.drops.len(), 1);
assert_eq!(parsed.drops[0].drop_id, drop_id);
assert_eq!(parsed.drops[0].payload, b"hello");
assert_eq!(parsed.drops[0].plaintext_len, 5);
}
#[test]
fn tampered_bytes_rejected() {
let epoch = Epoch::build(empty_header(), Vec::new(), Vec::new());
let mut bytes = epoch.to_bytes();
bytes[200] ^= 0x01;
match Epoch::from_bytes(&bytes) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("mismatch"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn wrong_magic_rejected() {
let epoch = Epoch::build(empty_header(), Vec::new(), Vec::new());
let mut bytes = epoch.to_bytes();
bytes[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
match Epoch::from_bytes(&bytes) {
Err(CoreError::Corrupt { reason }) => {
assert!(reason.contains("magic"), "got: {reason}");
}
other => panic!("expected Corrupt, got {other:?}"),
}
}
#[test]
fn chain_links_via_parent_id() {
let epoch1 = Epoch::build(empty_header(), Vec::new(), Vec::new());
let id1 = epoch1.id();
let mut header2 = empty_header();
header2.epoch_sequence = 2;
header2.parent_epoch_id = id1;
let epoch2 = Epoch::build(header2, Vec::new(), Vec::new());
let parsed2 = Epoch::from_bytes(&epoch2.to_bytes()).expect("parse");
assert_eq!(parsed2.header.parent_epoch_id, id1);
assert_eq!(parsed2.header.epoch_sequence, 2);
}
}