use core::fmt;
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use super::IoCounters;
pub const SEED_NODE_DOMAIN: &[u8] = b"VOLE:PSEED:v1";
pub const SEED_FORMAT_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId([u8; 32]);
impl NodeId {
pub const fn from_bytes(b: [u8; 32]) -> Self {
NodeId(b)
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn of_node(canonical: &[u8]) -> Self {
let mut h = blake3::Hasher::new();
h.update(SEED_NODE_DOMAIN);
h.update(canonical);
NodeId(*h.finalize().as_bytes())
}
pub fn to_hex(&self) -> String {
crate::integrity::to_hex(&self.0)
}
pub fn from_hex(s: &str) -> Result<Self> {
crate::store::Id::from_hex(s).map(|id| NodeId(*id.as_bytes()))
}
}
impl fmt::Display for NodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SeedStoreStats {
pub node_count: u64,
pub total_bytes: u64,
}
pub trait SeedStore {
fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId>;
fn get_node(&self, id: &NodeId) -> Result<Vec<u8>>;
fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>>;
fn contains_node(&self, id: &NodeId) -> Result<bool>;
fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>>;
}
pub struct FsSeedStore {
root: PathBuf,
io: IoCounters,
}
impl std::fmt::Debug for FsSeedStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FsSeedStore")
.field("root", &self.root)
.finish_non_exhaustive()
}
}
impl FsSeedStore {
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
Self::open_with_io(root, IoCounters::new())
}
pub fn open_with_io(root: impl AsRef<Path>, io: IoCounters) -> Result<Self> {
let root = root.as_ref().to_path_buf();
fs::create_dir_all(root.join("seed"))?;
Ok(FsSeedStore { root, io })
}
pub fn root(&self) -> &Path {
&self.root
}
fn node_path(&self, id: &NodeId) -> PathBuf {
let hex = id.to_hex();
self.root
.join("seed")
.join(&hex[0..2])
.join(&hex[2..4])
.join(&hex)
}
}
impl SeedStore for FsSeedStore {
fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
let id = NodeId::of_node(canonical);
let path = self.node_path(&id);
if path.exists() {
return Ok(id);
}
let dir = path
.parent()
.ok_or_else(|| Error::internal_invariant("seed node path has no parent"))?;
fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".{}.tmp-{}", id.to_hex(), std::process::id()));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(canonical)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)?;
Ok(id)
}
fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
let path = self.node_path(id);
let bytes = fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::missing_external_object(format!("seed node {id} is not present"))
} else {
Error::io(format!("reading seed node {id}: {e}"))
}
})?;
let actual = NodeId::of_node(&bytes);
if actual != *id {
return Err(Error::integrity_mismatch(format!(
"seed node {id} content hashes to {actual}"
)));
}
self.io.add_seed(bytes.len() as u64);
Ok(bytes)
}
fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
let path = self.node_path(id);
let mut f = fs::File::open(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::missing_external_object(format!("seed node {id} is not present"))
} else {
Error::io(format!("opening seed node {id}: {e}"))
}
})?;
let file_len = f.metadata()?.len();
if offset.checked_add(len).is_none_or(|end| end > file_len) {
return Err(Error::integrity_mismatch(format!(
"seed node {id} range [{offset}, {}) exceeds stored length {file_len}",
offset.saturating_add(len)
)));
}
f.seek(SeekFrom::Start(offset))?;
let mut out = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
f.read_exact(&mut out)?;
self.io.add_seed(out.len() as u64);
Ok(out)
}
fn contains_node(&self, id: &NodeId) -> Result<bool> {
Ok(self.node_path(id).exists())
}
fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
let seed = self.root.join("seed");
let mut out: Vec<(NodeId, u64)> = Vec::new();
let mut stack = vec![seed];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.len() != 64 {
continue;
}
let Ok(id) = NodeId::from_hex(name) else {
continue;
};
let len = entry.metadata().map(|m| m.len()).unwrap_or(0);
out.push((id, len));
}
}
out.sort_unstable();
Ok(out)
}
}
pub fn closure(
store: &dyn SeedStore,
roots: &[NodeId],
deps_of: impl Fn(&[u8]) -> Result<Vec<NodeId>>,
) -> Result<BTreeSet<NodeId>> {
let mut seen: BTreeSet<NodeId> = BTreeSet::new();
let mut stack: Vec<NodeId> = roots.to_vec();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let bytes = store.get_node(&id).map_err(|_| {
Error::missing_external_object(format!("seed closure missing node {id}"))
})?;
for dep in deps_of(&bytes)? {
if !seen.contains(&dep) {
stack.push(dep);
}
}
}
Ok(seen)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(label: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vole-seed-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn put_get_roundtrip_and_idempotence() {
let root = temp_root("rt");
let mut store = FsSeedStore::open(&root).unwrap();
let bytes = b"canonical node bytes";
let id1 = store.put_node(bytes).unwrap();
let id2 = store.put_node(bytes).unwrap();
assert_eq!(id1, id2);
assert_eq!(store.get_node(&id1).unwrap(), bytes);
assert!(store.contains_node(&id1).unwrap());
assert_eq!(store.list_nodes().unwrap(), vec![(id1, bytes.len() as u64)]);
fs::remove_dir_all(&root).ok();
}
#[test]
fn range_reads_are_strict() {
let root = temp_root("range");
let mut store = FsSeedStore::open(&root).unwrap();
let bytes = b"0123456789";
let id = store.put_node(bytes).unwrap();
assert_eq!(store.get_node_range(&id, 2, 3).unwrap(), b"234");
let e = store.get_node_range(&id, 8, 5).unwrap_err();
assert_eq!(e.class(), crate::ErrorClass::IntegrityMismatch);
fs::remove_dir_all(&root).ok();
}
#[test]
fn node_ids_are_domain_separated_from_object_ids() {
let bytes = b"same bytes";
assert_ne!(
*NodeId::of_node(bytes).as_bytes(),
*crate::store::Id::of(bytes).as_bytes()
);
}
}