use serde::{Deserialize, Serialize};
pub const MANIFEST_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
pub lineage: String,
pub created_at: String,
pub commit_seq: u64,
pub oldest_commit_seq: u64,
pub files: Vec<FileEntry>,
pub wal_generation: u64,
pub segments: Vec<SegmentEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileEntry {
pub name: String,
pub key: String,
pub size: u64,
pub crc32: String,
#[serde(default)]
pub frames: u64,
pub lineage: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SegmentEntry {
pub key: String,
pub crc32: String,
#[serde(default)]
pub frames: u64,
pub lineage: String,
}
impl Manifest {
pub fn to_json(&self) -> Vec<u8> {
serde_json::to_vec_pretty(self).expect("a manifest always serialises")
}
pub fn from_json(bytes: &[u8]) -> Result<Self, String> {
let m: Manifest =
serde_json::from_slice(bytes).map_err(|e| format!("manifest is not valid: {e}"))?;
if m.version != MANIFEST_VERSION {
return Err(format!(
"manifest version {} is not supported by this release (expected {MANIFEST_VERSION})",
m.version
));
}
m.verify_lineage()?;
Ok(m)
}
pub fn verify_lineage(&self) -> Result<(), String> {
if self.lineage.is_empty() {
return Err("manifest names no lineage".into());
}
let foreign = self
.files
.iter()
.map(|f| (&f.key, &f.lineage))
.chain(self.segments.iter().map(|s| (&s.key, &s.lineage)))
.find(|(_, l)| **l != self.lineage);
match foreign {
Some((key, l)) => Err(format!(
"manifest of lineage {} names {key}, uploaded from lineage {l}: \
a restore point mixing two stores is refused",
self.lineage
)),
None => Ok(()),
}
}
}
pub fn crc32_hex(bytes: &[u8]) -> String {
format_crc32(crc32fast::hash(bytes))
}
pub fn format_crc32(crc: u32) -> String {
format!("{crc:08x}")
}
pub fn object_key(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
}
}
pub fn segment_key(prefix: &str, generation: u64, start: u64, end: u64) -> String {
object_key(prefix, &format!("wal/{generation}/{start:020}-{end:020}"))
}
pub fn parse_segment_key(prefix: &str, key: &str) -> Option<(u64, u64, u64)> {
let rest = key.strip_prefix(&object_key(prefix, "wal/"))?;
let (generation, range) = rest.split_once('/')?;
let (start, end) = range.split_once('-')?;
Some((
generation.parse().ok()?,
start.parse().ok()?,
end.parse().ok()?,
))
}
pub fn manifest_key(prefix: &str, commit_seq: u64) -> String {
object_key(prefix, &format!("manifest/{commit_seq:020}.json"))
}
pub fn latest_key(prefix: &str) -> String {
object_key(prefix, "LATEST")
}
pub fn snapshot_key(prefix: &str, generation: u64, commit_seq: u64) -> String {
object_key(prefix, &format!("snapshots/{generation}-{commit_seq}.bin"))
}
pub fn archive_key(prefix: &str, generation: u64, n: u64) -> String {
object_key(prefix, &format!("archives/{generation}-wal.{n}.archive"))
}
pub fn side_key(prefix: &str, name: &str, crc32: &str, size: u64) -> String {
object_key(prefix, &format!("side/{name}/{crc32}-{size}"))
}
pub fn parse_versioned_generation(prefix: &str, key: &str) -> Option<u64> {
let rest = key
.strip_prefix(&object_key(prefix, "snapshots/"))
.or_else(|| key.strip_prefix(&object_key(prefix, "archives/")))?;
rest.split_once('-')?.0.parse().ok()
}
pub fn parse_archive_name(name: &str) -> Option<u64> {
name.strip_prefix("wal.")?
.strip_suffix(".archive")?
.parse()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_zero_padded_and_parse_back() {
let k = segment_key("p", 3, 0, 65536);
assert_eq!(k, "p/wal/3/00000000000000000000-00000000000000065536");
assert_eq!(parse_segment_key("p", &k), Some((3, 0, 65536)));
assert_eq!(parse_segment_key("q", &k), None);
assert_eq!(
segment_key("", 0, 1, 2),
"wal/0/00000000000000000001-00000000000000000002"
);
assert_eq!(
manifest_key("p", 42),
"p/manifest/00000000000000000042.json"
);
assert_eq!(latest_key(""), "LATEST");
assert_eq!(archive_key("a/b", 3, 7), "a/b/archives/3-wal.7.archive");
assert_eq!(snapshot_key("p", 4, 99), "p/snapshots/4-99.bin");
assert_eq!(
side_key("p", "wal.floor", "0000abcd", 8),
"p/side/wal.floor/0000abcd-8"
);
assert_eq!(
parse_versioned_generation("p", "p/snapshots/4-99.bin"),
Some(4)
);
assert_eq!(
parse_versioned_generation("a/b", "a/b/archives/3-wal.7.archive"),
Some(3)
);
assert_eq!(parse_versioned_generation("p", "p/side/x/1-2"), None);
assert_eq!(parse_archive_name("wal.7.archive"), Some(7));
assert_eq!(parse_archive_name("wal.floor"), None);
}
#[test]
fn an_unknown_version_is_refused() {
let mut m = Manifest {
version: MANIFEST_VERSION + 1,
lineage: "l".into(),
created_at: "2026-10-20T00:00:00Z".into(),
commit_seq: 0,
oldest_commit_seq: 0,
files: vec![],
wal_generation: 0,
segments: vec![],
};
assert!(Manifest::from_json(&m.to_json()).is_err());
m.version = MANIFEST_VERSION;
assert!(Manifest::from_json(&m.to_json()).is_ok());
}
#[test]
fn a_restore_point_mixing_two_lineages_does_not_parse() {
let entry = |lineage: &str| FileEntry {
name: "wal.3.archive".into(),
key: "p/archives/0-wal.3.archive".into(),
size: 1,
crc32: "00000000".into(),
frames: 1,
lineage: lineage.into(),
};
let mut m = Manifest {
version: MANIFEST_VERSION,
lineage: "b".into(),
created_at: "2026-10-22T00:00:00Z".into(),
commit_seq: 1,
oldest_commit_seq: 0,
files: vec![entry("b")],
wal_generation: 0,
segments: vec![],
};
assert!(Manifest::from_json(&m.to_json()).is_ok());
m.files.push(entry("a"));
let e = Manifest::from_json(&m.to_json()).unwrap_err();
assert!(e.contains("lineage a"), "{e}");
m.files.pop();
m.segments.push(SegmentEntry {
key: "p/wal/0/x".into(),
crc32: "00000000".into(),
frames: 0,
lineage: "a".into(),
});
assert!(Manifest::from_json(&m.to_json()).is_err());
m.segments.clear();
m.lineage.clear();
assert!(Manifest::from_json(&m.to_json()).is_err());
}
#[test]
fn crc_is_eight_hex_digits() {
assert_eq!(crc32_hex(b""), "00000000");
assert_eq!(crc32_hex(b"123456789"), "cbf43926");
}
}