use std::path::Path;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::state::ResourceCatalogSnapshot;
const MAGIC_V1: &[u8] = b"engenho-catalog-snapshot v1\n";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CatalogSnapshot {
pub catalog: ResourceCatalogSnapshot,
pub last_applied_index: u64,
}
impl CatalogSnapshot {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_catalog(catalog: ResourceCatalogSnapshot, last_applied_index: u64) -> Self {
Self {
catalog,
last_applied_index,
}
}
pub fn encode(&self) -> Result<Vec<u8>, SnapshotError> {
let payload = serde_json::to_vec(self)
.map_err(|e| SnapshotError::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(
MAGIC_V1.len() + 8 + blake3::OUT_LEN + payload.len(),
);
out.extend_from_slice(MAGIC_V1);
out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
out.extend_from_slice(blake3::hash(&payload).as_bytes());
out.extend_from_slice(&payload);
Ok(out)
}
pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotError> {
if bytes.len() < MAGIC_V1.len() + 8 + blake3::OUT_LEN {
return Err(SnapshotError::Truncated);
}
if &bytes[..MAGIC_V1.len()] != MAGIC_V1 {
return Err(SnapshotError::BadMagic);
}
let len_offset = MAGIC_V1.len();
let hash_offset = len_offset + 8;
let payload_offset = hash_offset + blake3::OUT_LEN;
let payload_len = u64::from_le_bytes(
bytes[len_offset..hash_offset]
.try_into()
.map_err(|_| SnapshotError::Truncated)?,
) as usize;
if bytes.len() < payload_offset + payload_len {
return Err(SnapshotError::Truncated);
}
let stored_hash = &bytes[hash_offset..payload_offset];
let payload = &bytes[payload_offset..payload_offset + payload_len];
let actual_hash = blake3::hash(payload);
if actual_hash.as_bytes() != stored_hash {
return Err(SnapshotError::HashMismatch);
}
serde_json::from_slice(payload).map_err(|e| SnapshotError::Decode(e.to_string()))
}
pub fn save_to(&self, path: &Path) -> Result<(), SnapshotError> {
use std::io::Write;
let bytes = self.encode()?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
SnapshotError::Io(format!("mkdir {}: {e}", parent.display()))
})?;
}
}
let tmp = path.with_extension("snap.tmp");
{
let mut f = std::fs::File::create(&tmp).map_err(|e| {
SnapshotError::Io(format!("create {}: {e}", tmp.display()))
})?;
f.write_all(&bytes).map_err(|e| {
SnapshotError::Io(format!("write {}: {e}", tmp.display()))
})?;
f.sync_all().map_err(|e| {
SnapshotError::Io(format!("fsync {}: {e}", tmp.display()))
})?;
}
std::fs::rename(&tmp, path).map_err(|e| {
SnapshotError::Io(format!(
"rename {} → {}: {e}",
tmp.display(),
path.display()
))
})?;
Ok(())
}
pub fn load_from(path: &Path) -> Result<Self, SnapshotError> {
let bytes = std::fs::read(path).map_err(|e| {
SnapshotError::Io(format!("read {}: {e}", path.display()))
})?;
Self::decode(&bytes)
}
}
#[derive(Debug, Clone, Error)]
pub enum SnapshotError {
#[error("io: {0}")]
Io(String),
#[error("bad magic header (wrong format or unsupported version)")]
BadMagic,
#[error("file truncated")]
Truncated,
#[error("hash mismatch — snapshot is corrupt")]
HashMismatch,
#[error("encode: {0}")]
Encode(String),
#[error("decode: {0}")]
Decode(String),
}
impl SnapshotError {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::Io(_) => "io",
Self::BadMagic => "bad_magic",
Self::Truncated => "truncated",
Self::HashMismatch => "hash_mismatch",
Self::Encode(_) => "encode",
Self::Decode(_) => "decode",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::{Reason, ResourceCommand};
use crate::resource::ResourceKey;
use crate::state::ResourceCatalog;
use serde_json::json;
fn sample_catalog() -> ResourceCatalogSnapshot {
let mut catalog = ResourceCatalog::default();
catalog.apply(
&ResourceCommand::Put {
key: ResourceKey::namespaced("", "v1", "Pod", "default", "p1"),
value: json!({"spec": {"containers": [{"image": "podinfo:6"}]}}),
reason: Reason::Operator,
},
1,
1,
);
catalog.apply(
&ResourceCommand::Put {
key: ResourceKey::namespaced("", "v1", "Pod", "default", "p2"),
value: json!({"spec": {"containers": [{"image": "nginx:latest"}]}}),
reason: Reason::Operator,
},
1,
2,
);
ResourceCatalogSnapshot { catalog }
}
#[test]
fn snapshot_round_trips_through_bytes() {
let original = CatalogSnapshot::from_catalog(sample_catalog(), 42);
let encoded = original.encode().unwrap();
let back = CatalogSnapshot::decode(&encoded).unwrap();
assert_eq!(back.last_applied_index, 42);
let p1 = back
.catalog
.catalog
.get(&ResourceKey::namespaced("", "v1", "Pod", "default", "p1"))
.unwrap();
assert_eq!(
p1.get("spec").unwrap().get("containers").unwrap()[0]
.get("image")
.unwrap(),
"podinfo:6"
);
}
#[test]
fn snapshot_magic_header_present() {
let snap = CatalogSnapshot::from_catalog(sample_catalog(), 1);
let bytes = snap.encode().unwrap();
assert!(bytes.starts_with(MAGIC_V1));
}
#[test]
fn decode_rejects_bad_magic() {
let mut bad = MAGIC_V1.to_vec();
bad[0] = b'X';
bad.extend_from_slice(&[0u8; 40]); let err = CatalogSnapshot::decode(&bad).unwrap_err();
assert_eq!(err.kind(), "bad_magic");
}
#[test]
fn decode_rejects_truncated() {
let snap = CatalogSnapshot::from_catalog(sample_catalog(), 1);
let bytes = snap.encode().unwrap();
let truncated = &bytes[..bytes.len() / 2];
let err = CatalogSnapshot::decode(truncated).unwrap_err();
assert_eq!(err.kind(), "truncated");
}
#[test]
fn decode_rejects_hash_mismatch() {
let snap = CatalogSnapshot::from_catalog(sample_catalog(), 1);
let mut bytes = snap.encode().unwrap();
let payload_offset = MAGIC_V1.len() + 8 + blake3::OUT_LEN;
bytes[payload_offset + 5] ^= 0xff;
let err = CatalogSnapshot::decode(&bytes).unwrap_err();
assert_eq!(err.kind(), "hash_mismatch");
}
#[test]
fn save_load_round_trip_on_disk() {
let dir = std::env::temp_dir().join(format!(
"engenho-snap-test-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("catalog.snap");
let _ = std::fs::remove_file(&path);
let snap = CatalogSnapshot::from_catalog(sample_catalog(), 7);
snap.save_to(&path).unwrap();
assert!(path.exists());
let back = CatalogSnapshot::load_from(&path).unwrap();
assert_eq!(back.last_applied_index, 7);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn save_creates_parent_dir() {
let nested = std::env::temp_dir().join(format!(
"engenho-snap-nested-{}/sub/dir/catalog.snap",
std::process::id()
));
let _ = std::fs::remove_dir_all(nested.parent().unwrap());
let snap = CatalogSnapshot::new();
snap.save_to(&nested).unwrap();
assert!(nested.exists());
let _ = std::fs::remove_dir_all(nested.parent().unwrap().parent().unwrap().parent().unwrap());
}
#[test]
fn load_from_missing_file_returns_io_error() {
let path = std::env::temp_dir().join("definitely-does-not-exist.snap");
let _ = std::fs::remove_file(&path);
let err = CatalogSnapshot::load_from(&path).unwrap_err();
assert_eq!(err.kind(), "io");
}
#[test]
fn empty_catalog_snapshot_round_trips() {
let snap = CatalogSnapshot::new();
let bytes = snap.encode().unwrap();
let back = CatalogSnapshot::decode(&bytes).unwrap();
assert_eq!(back.last_applied_index, 0);
}
#[test]
fn save_to_is_atomic_via_temp_rename() {
let dir = std::env::temp_dir().join(format!(
"engenho-snap-atomic-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("catalog.snap");
let tmp = path.with_extension("snap.tmp");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&tmp);
let snap = CatalogSnapshot::from_catalog(sample_catalog(), 3);
snap.save_to(&path).unwrap();
assert!(path.exists(), "final path exists");
assert!(!tmp.exists(), "tmp was renamed (not lingering)");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn error_kinds_are_stable() {
assert_eq!(SnapshotError::Io("x".into()).kind(), "io");
assert_eq!(SnapshotError::BadMagic.kind(), "bad_magic");
assert_eq!(SnapshotError::Truncated.kind(), "truncated");
assert_eq!(SnapshotError::HashMismatch.kind(), "hash_mismatch");
assert_eq!(SnapshotError::Encode("x".into()).kind(), "encode");
assert_eq!(SnapshotError::Decode("x".into()).kind(), "decode");
}
}