use std::collections::HashMap;
use std::path::{Path, PathBuf};
use cyber_hemera::Hash;
use serde::{Deserialize, Serialize};
use crate::erasure::Shard;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileEntry {
pub name: String,
pub original_len: usize,
pub k: usize,
pub n: usize,
pub shard_hashes: Vec<String>,
pub timestamp: u64,
pub entry_hash: String,
pub device_id: String,
pub das_root: String,
#[serde(default = "default_shard_copies")]
pub shard_copies: usize,
#[serde(default)]
pub deleted: bool,
}
fn default_shard_copies() -> usize { 1 }
pub const MAX_CLOCK_DRIFT_MS: u64 = 3_600_000;
pub const MAX_REGISTRY_ENTRIES: usize = 100_000;
#[derive(Debug, PartialEq)]
pub enum ValidationError {
HashMismatch,
TimestampFuture,
EmptyName,
NoShards,
RegistryFull,
}
impl FileEntry {
pub fn compute_hash(
name: &str,
shard_hashes: &[String],
timestamp: u64,
device_id: &str,
) -> String {
let mut data = Vec::new();
data.extend_from_slice(name.as_bytes());
for h in shard_hashes {
data.extend_from_slice(h.as_bytes());
}
data.extend_from_slice(×tamp.to_le_bytes());
data.extend_from_slice(device_id.as_bytes());
cyber_hemera::hash(&data).to_hex()
}
pub fn verify_hash(&self) -> bool {
let expected = Self::compute_hash(
&self.name,
&self.shard_hashes,
self.timestamp,
&self.device_id,
);
self.entry_hash == expected
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.name.is_empty() {
return Err(ValidationError::EmptyName);
}
if self.shard_hashes.is_empty() {
return Err(ValidationError::NoShards);
}
if !self.verify_hash() {
return Err(ValidationError::HashMismatch);
}
let now = now_ms();
if self.timestamp > now + MAX_CLOCK_DRIFT_MS {
return Err(ValidationError::TimestampFuture);
}
Ok(())
}
}
pub struct ChunkStore {
root: PathBuf,
index: HashMap<String, Vec<u8>>,
capacity: u64,
used: u64,
}
impl ChunkStore {
pub fn new(root: &Path, capacity: u64) -> std::io::Result<Self> {
std::fs::create_dir_all(root)?;
Ok(Self {
root: root.to_path_buf(),
index: HashMap::new(),
capacity,
used: 0,
})
}
pub fn put(&mut self, shard: &Shard) -> std::io::Result<Hash> {
let bytes = shard_to_bytes(shard);
let hash = cyber_hemera::hash(&bytes);
let hex = hash.to_hex();
let size = bytes.len() as u64;
if self.capacity > 0 && self.used + size > self.capacity {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"chunk store capacity exceeded",
));
}
let path = self.root.join(&hex);
if !path.exists() {
std::fs::write(&path, &bytes)?;
self.used += size;
}
self.index.insert(hex, bytes);
Ok(hash)
}
pub fn get(&self, hash: &Hash) -> std::io::Result<Vec<u8>> {
let hex = hash.to_hex();
if let Some(data) = self.index.get(&hex) {
return Ok(data.clone());
}
let path = self.root.join(&hex);
std::fs::read(&path)
}
pub fn has(&self, hash: &Hash) -> bool {
let hex = hash.to_hex();
self.index.contains_key(&hex) || self.root.join(&hex).exists()
}
pub fn remove(&mut self, hash_hex: &str) -> std::io::Result<u64> {
let path = self.root.join(hash_hex);
let size = if path.exists() {
let meta = std::fs::metadata(&path)?;
std::fs::remove_file(&path)?;
meta.len()
} else {
0
};
self.index.remove(hash_hex);
self.used = self.used.saturating_sub(size);
Ok(size)
}
pub fn gc(&mut self, live_hashes: &std::collections::HashSet<String>) -> std::io::Result<(usize, u64)> {
let all_chunks: Vec<String> = std::fs::read_dir(&self.root)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.len() == 64 { Some(name) } else { None }
})
.collect();
let mut removed = 0;
let mut freed = 0u64;
for hash_hex in &all_chunks {
if !live_hashes.contains(hash_hex) {
freed += self.remove(hash_hex)?;
removed += 1;
}
}
Ok((removed, freed))
}
pub fn audit(&self) -> std::io::Result<(usize, usize, Vec<String>)> {
let mut ok = 0;
let mut corrupt = Vec::new();
let entries: Vec<String> = std::fs::read_dir(&self.root)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.len() == 64 { Some(name) } else { None }
})
.collect();
for hash_hex in &entries {
let path = self.root.join(hash_hex);
if let Ok(bytes) = std::fs::read(&path) {
if verify_chunk(&bytes, hash_hex) {
ok += 1;
} else {
corrupt.push(hash_hex.clone());
}
}
}
Ok((ok, corrupt.len(), corrupt))
}
pub fn list_chunks(&self) -> std::io::Result<Vec<String>> {
let entries: Vec<String> = std::fs::read_dir(&self.root)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.len() == 64 { Some(name) } else { None }
})
.collect();
Ok(entries)
}
pub fn used(&self) -> u64 {
self.used
}
pub fn capacity(&self) -> u64 {
self.capacity
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct GSet {
pub files: HashMap<String, FileEntry>,
}
impl GSet {
pub fn new() -> Self {
Self {
files: HashMap::new(),
}
}
pub fn insert(&mut self, entry: FileEntry) {
self.lww_insert(entry);
}
pub fn validated_insert(&mut self, entry: FileEntry) -> Result<(), ValidationError> {
entry.validate()?;
if self.files.len() >= MAX_REGISTRY_ENTRIES && !self.files.contains_key(&entry.name) {
return Err(ValidationError::RegistryFull);
}
self.lww_insert(entry);
Ok(())
}
pub fn validated_merge(&mut self, other: &GSet) -> (usize, usize) {
let mut accepted = 0;
let mut rejected = 0;
for entry in other.files.values() {
match self.validated_insert(entry.clone()) {
Ok(()) => accepted += 1,
Err(_) => rejected += 1,
}
}
(accepted, rejected)
}
pub fn merge(&mut self, other: &GSet) {
for entry in other.files.values() {
self.lww_insert(entry.clone());
}
}
fn lww_insert(&mut self, entry: FileEntry) {
match self.files.get(&entry.name) {
Some(existing) => {
if entry.timestamp > existing.timestamp
|| (entry.timestamp == existing.timestamp
&& entry.entry_hash > existing.entry_hash)
{
self.files.insert(entry.name.clone(), entry);
}
}
None => {
self.files.insert(entry.name.clone(), entry);
}
}
}
pub fn merkle_root(&self) -> String {
let mut hashes: Vec<&str> = self.files.values().map(|e| e.entry_hash.as_str()).collect();
hashes.sort();
let mut data = Vec::new();
for h in &hashes {
data.extend_from_slice(h.as_bytes());
}
if data.is_empty() {
return "0".repeat(64);
}
cyber_hemera::hash(&data).to_hex()
}
pub fn list(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.files
.values()
.filter(|e| !e.deleted)
.map(|e| e.name.as_str())
.collect();
names.sort();
names
}
pub fn get(&self, name: &str) -> Option<&FileEntry> {
self.files.get(name).filter(|e| !e.deleted)
}
pub fn get_raw(&self, name: &str) -> Option<&FileEntry> {
self.files.get(name)
}
pub fn len(&self) -> usize {
self.files.values().filter(|e| !e.deleted).count()
}
pub fn tombstone_count(&self) -> usize {
self.files.values().filter(|e| e.deleted).count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn compact(&mut self, max_age_ms: u64) -> usize {
let now = now_ms();
let before = self.files.len();
self.files.retain(|_, e| {
!(e.deleted && now.saturating_sub(e.timestamp) > max_age_ms)
});
before - self.files.len()
}
pub fn delta_since(&self, since_timestamp: u64) -> GSet {
let mut delta = GSet::new();
for entry in self.files.values() {
if entry.timestamp > since_timestamp {
delta.insert(entry.clone());
}
}
delta
}
pub fn live_shard_hashes(&self) -> std::collections::HashSet<String> {
let mut hashes = std::collections::HashSet::new();
for entry in self.files.values() {
if !entry.deleted {
for h in &entry.shard_hashes {
hashes.insert(h.clone());
}
}
}
hashes
}
}
fn shard_to_bytes(shard: &Shard) -> Vec<u8> {
let mut bytes = Vec::with_capacity(shard.data.len() * 8);
for &elem in &shard.data {
bytes.extend_from_slice(&elem.as_u64().to_le_bytes());
}
bytes
}
pub fn bytes_to_shard(index: usize, bytes: &[u8]) -> Shard {
let mut data = Vec::with_capacity(bytes.len() / 8);
for chunk in bytes.chunks(8) {
if chunk.len() == 8 {
let val = u64::from_le_bytes(chunk.try_into().unwrap());
data.push(nebu::Goldilocks::new(val));
}
}
Shard { index, data }
}
pub fn verify_chunk(bytes: &[u8], expected_hash_hex: &str) -> bool {
let computed = cyber_hemera::hash(bytes);
computed.to_hex() == expected_hash_hex
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::erasure;
fn make_entry(name: &str, ts: u64) -> FileEntry {
let shard_hashes = vec!["aaa".into(), "bbb".into()];
let entry_hash = FileEntry::compute_hash(name, &shard_hashes, ts, "dev1");
FileEntry {
name: name.into(),
original_len: 100,
k: 2,
n: 4,
shard_hashes,
timestamp: ts,
entry_hash,
device_id: "dev1".into(),
das_root: "0".repeat(64),
shard_copies: 1, deleted: false,
}
}
#[test]
fn store_and_retrieve() {
let dir = tempfile::tempdir().unwrap();
let mut store = ChunkStore::new(dir.path(), 0).unwrap();
let data = b"chunk store test data";
let shards = erasure::encode(data, 2, 4);
let hash = store.put(&shards[0]).unwrap();
assert!(store.has(&hash));
let retrieved = store.get(&hash).unwrap();
let expected = shard_to_bytes(&shards[0]);
assert_eq!(retrieved, expected);
}
#[test]
fn capacity_limit() {
let dir = tempfile::tempdir().unwrap();
let mut store = ChunkStore::new(dir.path(), 10).unwrap();
let data = b"this data is definitely more than 10 bytes when encoded";
let shards = erasure::encode(data, 2, 4);
assert!(store.put(&shards[0]).is_err());
}
#[test]
fn lww_merge_higher_timestamp_wins() {
let mut a = GSet::new();
let mut b = GSet::new();
a.insert(make_entry("file.txt", 100));
b.insert(make_entry("file.txt", 200));
a.merge(&b);
assert_eq!(a.get("file.txt").unwrap().timestamp, 200);
b.merge(&GSet {
files: [("file.txt".into(), make_entry("file.txt", 100))]
.into_iter()
.collect(),
});
assert_eq!(b.get("file.txt").unwrap().timestamp, 200);
}
#[test]
fn gset_merge_union() {
let mut a = GSet::new();
let mut b = GSet::new();
a.insert(make_entry("x", 1));
b.insert(make_entry("y", 2));
a.merge(&b);
assert_eq!(a.len(), 2);
a.merge(&b); assert_eq!(a.len(), 2);
}
#[test]
fn gset_commutativity() {
let mut a = GSet::new();
let mut b = GSet::new();
a.insert(make_entry("x", 1));
b.insert(make_entry("y", 2));
let mut ab = a.clone();
ab.merge(&b);
let mut ba = b.clone();
ba.merge(&a);
assert_eq!(ab.list(), ba.list());
}
#[test]
fn merkle_root_deterministic() {
let mut a = GSet::new();
let mut b = GSet::new();
let e1 = make_entry("x", 1);
let e2 = make_entry("y", 2);
a.insert(e1.clone());
a.insert(e2.clone());
b.insert(e2);
b.insert(e1);
assert_eq!(a.merkle_root(), b.merkle_root());
}
#[test]
fn merkle_root_changes_on_insert() {
let mut g = GSet::new();
let r1 = g.merkle_root();
g.insert(make_entry("x", 1));
let r2 = g.merkle_root();
assert_ne!(r1, r2);
}
#[test]
fn verify_chunk_works() {
let data = b"test chunk verification";
let shards = erasure::encode(data, 2, 4);
let bytes = shard_to_bytes(&shards[0]);
let hash = cyber_hemera::hash(&bytes).to_hex();
assert!(verify_chunk(&bytes, &hash));
let mut bad = bytes.clone();
bad[0] ^= 0xFF;
assert!(!verify_chunk(&bad, &hash));
}
}