Skip to content

Commit 255d17d

Browse files
committed
feat(snapshot): implement create_snapshot / restore over StateConfig contract
Closes #14. - Add crates/wharf-core/src/snapshot.rs with create_snapshot, restore, enforce_retention, SnapshotManifest, Snapshot, SnapshotError. - Each snapshot is materialised under <snapshot_dir>/<id>/ as payload.bin + manifest.json (ledger_id, sequence, payload_sha256). - Retention prunes oldest directories alphabetically until count <= snapshots_to_keep. - Restore is byte-exact and returns SnapshotError::NotFound for missing ids. - Wire sha2 in the workspace and wharf-core dependency tables. - Fix lib.rs: replace dead site/deployment/security/volumes module declarations with the real modules already present in src/, and publish snapshot. - Declare serde_yaml in wharf-core deps so the existing nebula export_yaml call compiles.
1 parent 64ca0d1 commit 255d17d

5 files changed

Lines changed: 208 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ ed448-goldilocks = { version = "0.14.0-pre.10", features = ["signing"] }
5050
# SHAKE3/SHA3 (FIPS 202)
5151
sha3 = "0.10"
5252

53+
# SHA-256 (for snapshot payload digests)
54+
sha2 = "0.10"
55+
5356
# XChaCha20-Poly1305 symmetric encryption
5457
chacha20poly1305 = "0.10"
5558

crates/wharf-core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pqcrypto-mldsa = { workspace = true }
1717
pqcrypto-traits = { workspace = true }
1818
ed448-goldilocks = { workspace = true }
1919
sha3 = { workspace = true }
20+
sha2 = { workspace = true }
2021
chacha20poly1305 = { workspace = true }
2122
hkdf = { workspace = true }
2223
rand_chacha = { workspace = true }
@@ -28,6 +29,7 @@ reqwest = { workspace = true }
2829
# Serialization
2930
serde = { workspace = true }
3031
serde_json = { workspace = true }
32+
serde_yaml = "0.9"
3133
toml = { workspace = true }
3234

3335
# Error Handling

crates/wharf-core/src/lib.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@
1212
//! - `wharf-cli`: Administrative interface for site lifecycle.
1313
//! - `wharf-api`: Integration layer for CI/CD pipelines.
1414
15-
pub mod site;
16-
pub mod deployment;
17-
pub mod security;
18-
pub mod volumes;
15+
pub mod config;
16+
pub mod crypto;
17+
pub mod db_policy;
18+
pub mod errors;
19+
pub mod fleet;
20+
pub mod integrity;
21+
pub mod mooring;
22+
pub mod mooring_client;
23+
pub mod nebula;
24+
pub mod snapshot;
25+
pub mod sync;
26+
pub mod types;
1927

2028
/// The semantic version of the wharf core engine.
2129
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

crates/wharf-core/src/snapshot.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
3+
//! # Snapshot Subsystem
4+
//!
5+
//! Materialises state snapshots on disk under `<snapshot_dir>/<snapshot_id>/`,
6+
//! enforcing a fixed retention bound and providing a byte-exact round-trip
7+
//! restore path.
8+
//!
9+
//! Layout per snapshot:
10+
//! - `payload.bin` — the raw state bytes
11+
//! - `manifest.json` — `{ ledger_id, sequence, payload_sha256 }`
12+
13+
use crate::config::StateConfig;
14+
use serde::{Deserialize, Serialize};
15+
use sha2::{Digest, Sha256};
16+
use std::path::Path;
17+
use thiserror::Error;
18+
19+
#[derive(Error, Debug)]
20+
pub enum SnapshotError {
21+
#[error("I/O error: {0}")]
22+
Io(#[from] std::io::Error),
23+
#[error("Serialization error: {0}")]
24+
Json(#[from] serde_json::Error),
25+
#[error("Snapshot not found: {id}")]
26+
NotFound { id: String },
27+
}
28+
29+
#[derive(Debug, Clone, Serialize, Deserialize)]
30+
pub struct SnapshotManifest {
31+
pub ledger_id: String,
32+
pub sequence: u64,
33+
pub payload_sha256: String,
34+
}
35+
36+
#[derive(Debug, Clone)]
37+
pub struct Snapshot {
38+
pub id: String,
39+
pub manifest: SnapshotManifest,
40+
}
41+
42+
pub fn create_snapshot(
43+
state: &[u8],
44+
id: &str,
45+
config: &StateConfig,
46+
) -> Result<Snapshot, SnapshotError> {
47+
let snap_dir = config.snapshot_dir.join(id);
48+
std::fs::create_dir_all(&snap_dir)?;
49+
std::fs::write(snap_dir.join("payload.bin"), state)?;
50+
51+
// Sequence = count of *existing* snapshots before this one was added.
52+
let sequence = count_snapshots(&config.snapshot_dir).saturating_sub(1) as u64;
53+
54+
let manifest = SnapshotManifest {
55+
ledger_id: id.to_string(),
56+
sequence,
57+
payload_sha256: sha256_hex(state),
58+
};
59+
let manifest_json = serde_json::to_string_pretty(&manifest)?;
60+
std::fs::write(snap_dir.join("manifest.json"), manifest_json)?;
61+
62+
enforce_retention(&config.snapshot_dir, config.snapshots_to_keep)?;
63+
64+
Ok(Snapshot {
65+
id: id.to_string(),
66+
manifest,
67+
})
68+
}
69+
70+
pub fn restore(id: &str, config: &StateConfig) -> Result<Vec<u8>, SnapshotError> {
71+
let payload_path = config.snapshot_dir.join(id).join("payload.bin");
72+
if !payload_path.exists() {
73+
return Err(SnapshotError::NotFound { id: id.to_string() });
74+
}
75+
Ok(std::fs::read(payload_path)?)
76+
}
77+
78+
fn enforce_retention(snapshot_dir: &Path, keep: usize) -> Result<(), SnapshotError> {
79+
if !snapshot_dir.exists() {
80+
return Ok(());
81+
}
82+
let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(snapshot_dir)?
83+
.filter_map(|e| e.ok().map(|e| e.path()))
84+
.filter(|p| p.is_dir())
85+
.collect();
86+
entries.sort();
87+
while entries.len() > keep {
88+
let victim = entries.remove(0);
89+
std::fs::remove_dir_all(&victim)?;
90+
}
91+
Ok(())
92+
}
93+
94+
fn count_snapshots(snapshot_dir: &Path) -> usize {
95+
if !snapshot_dir.exists() {
96+
return 0;
97+
}
98+
std::fs::read_dir(snapshot_dir)
99+
.map(|it| {
100+
it.filter_map(|e| e.ok())
101+
.filter(|e| e.path().is_dir())
102+
.count()
103+
})
104+
.unwrap_or(0)
105+
}
106+
107+
fn sha256_hex(bytes: &[u8]) -> String {
108+
let mut h = Sha256::new();
109+
h.update(bytes);
110+
hex::encode(h.finalize())
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use tempfile::TempDir;
117+
118+
fn test_config(tmp: &TempDir) -> StateConfig {
119+
StateConfig {
120+
snapshots_to_keep: 3,
121+
snapshot_dir: tmp.path().join("snapshots"),
122+
}
123+
}
124+
125+
#[test]
126+
fn round_trip_byte_exact() {
127+
let tmp = TempDir::new().unwrap();
128+
let cfg = test_config(&tmp);
129+
let payload = b"hello wharf snapshot";
130+
let snap = create_snapshot(payload, "snap-000001", &cfg).unwrap();
131+
assert_eq!(snap.id, "snap-000001");
132+
let recovered = restore("snap-000001", &cfg).unwrap();
133+
assert_eq!(recovered, payload);
134+
}
135+
136+
#[test]
137+
fn retention_never_exceeded() {
138+
let tmp = TempDir::new().unwrap();
139+
let cfg = test_config(&tmp); // keep = 3
140+
for i in 0..6usize {
141+
let id = format!("snap-{:06}", i);
142+
create_snapshot(format!("payload-{}", i).as_bytes(), &id, &cfg).unwrap();
143+
let count = count_snapshots(&cfg.snapshot_dir);
144+
assert!(
145+
count <= cfg.snapshots_to_keep,
146+
"count {} > keep {}",
147+
count,
148+
cfg.snapshots_to_keep
149+
);
150+
}
151+
}
152+
153+
#[test]
154+
fn restore_missing_returns_err() {
155+
let tmp = TempDir::new().unwrap();
156+
let cfg = test_config(&tmp);
157+
assert!(restore("snap-999999", &cfg).is_err());
158+
}
159+
}

0 commit comments

Comments
 (0)