use crate::imp::core::{Bytes32, StoreConfig, Visibility};
use crate::imp::store::{FixedClock, StagingArea, Store};
use std::io::Write;
use tempfile::tempdir;
fn test_chunker() -> crate::imp::core::ChunkerConfig {
crate::imp::core::ChunkerConfig {
min_size: 16 * 1024,
target_size: 64 * 1024,
max_size: 256 * 1024,
mask: (1u64 << 16) - 1,
}
}
fn expected_resource_leaves(
store_id: Bytes32,
salt: Option<&crate::imp::core::SecretSalt>,
staged: &[(&str, &[u8])],
) -> Vec<Bytes32> {
use crate::imp::chunker::chunk_slice;
use crate::imp::core::serving::concat_output;
use dig_urn_protocol::{Bytes32 as UrnBytes32, DigUrn};
let mut keyed: Vec<([u8; 32], Bytes32)> = staged
.iter()
.map(|(rk, content)| {
let urn = DigUrn {
chain: "chia".to_string(),
store_id: UrnBytes32(store_id.0),
root_hash: None,
resource_key: Some(rk.to_string()),
};
let aes_key = crate::imp::crypto::derive_decryption_key(&urn.canonical(), salt);
let chunks = chunk_slice(content, &test_chunker());
let cts: Vec<Vec<u8>> = chunks
.iter()
.map(|c| crate::imp::crypto::encrypt_chunk(&aes_key, &c.data))
.collect();
let slices: Vec<&[u8]> = cts.iter().map(|c| c.as_slice()).collect();
let blob = concat_output(&slices);
(urn.retrieval_key().0, crate::imp::crypto::sha256(&blob))
})
.collect();
keyed.sort_by(|a, b| a.0.cmp(&b.0));
keyed.into_iter().map(|(_, leaf)| leaf).collect()
}
fn config(dir: &std::path::Path) -> StoreConfig {
StoreConfig {
store_id: Bytes32([0x44u8; 32]),
data_dir: dir.to_string_lossy().to_string(),
max_size: 10_000_000,
visibility: Visibility::Public,
label: None,
description: None,
}
}
#[test]
fn stage_file_appends_to_staging() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
store
.stage_file("index.html", b"<html>hello</html>")
.unwrap();
let staged = StagingArea::open(store.paths().staging_file())
.unwrap()
.records()
.unwrap();
assert_eq!(staged.len(), 1);
assert_eq!(staged[0].resource_key, "index.html");
assert_eq!(staged[0].content, b"<html>hello</html>");
}
#[test]
fn add_uses_relative_path_as_resource_key() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let src_dir = tempdir().unwrap();
let nested = src_dir.path().join("assets");
std::fs::create_dir_all(&nested).unwrap();
let file = nested.join("logo.svg");
let mut f = std::fs::File::create(&file).unwrap();
f.write_all(b"<svg/>").unwrap();
store.add(&file, src_dir.path()).unwrap();
let staged = StagingArea::open(store.paths().staging_file())
.unwrap()
.records()
.unwrap();
assert_eq!(staged.len(), 1);
assert_eq!(staged[0].resource_key, "assets/logo.svg");
assert_eq!(staged[0].content, b"<svg/>");
}
#[test]
fn add_rejects_file_outside_base() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let base = tempdir().unwrap();
let other = tempdir().unwrap();
let file = other.path().join("x.txt");
std::fs::write(&file, b"x").unwrap();
let err = store.add(&file, base.path()).unwrap_err();
assert!(matches!(err, crate::imp::store::StoreError::PathEscape(_)));
}
#[test]
fn commit_creates_generation_and_advances_history() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1_717_000_000)).unwrap();
store
.stage_file("index.html", &vec![0xABu8; 200_000])
.unwrap();
let root = store.commit().unwrap();
let hist = store.root_history().unwrap();
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].id, 0);
assert_eq!(hist[0].root, root);
assert_eq!(hist[0].timestamp, 1_717_000_000);
let root_hex = root.to_hex();
assert!(store.paths().generation_manifest(&root_hex).exists());
assert!(store.paths().generation_chunks_dir(&root_hex).is_dir());
assert!(
crate::imp::store::StagingArea::open(store.paths().staging_file())
.unwrap()
.is_empty()
.unwrap()
);
}
#[test]
fn commit_generation_root_equals_recomputed_tree_root() {
use crate::imp::core::merkle::MerkleTree;
use crate::imp::core::serving::concat_output;
use crate::imp::core::Bytes32 as CoreBytes32;
use crate::imp::store::GenerationManifest;
use std::collections::BTreeMap;
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
store
.stage_file("data.bin", &vec![0x5Au8; 300_000])
.unwrap();
store.stage_file("note.txt", b"a small note").unwrap();
let _returned = store.commit().unwrap();
let hist = store.root_history().unwrap();
let persisted_state_root = hist.last().unwrap().root;
let manifest = GenerationManifest::read_from(
store
.paths()
.generation_manifest(&persisted_state_root.to_hex()),
)
.unwrap();
let by_index: BTreeMap<u32, CoreBytes32> =
manifest.chunks.iter().map(|c| (c.index, c.hash)).collect();
let mut keyed: Vec<([u8; 32], CoreBytes32)> = manifest
.key_table
.iter()
.map(|kt| {
let cts: Vec<Vec<u8>> = kt
.chunk_indices
.iter()
.map(|i| {
let hash = by_index[i];
store.resolve_chunk(hash).unwrap() })
.collect();
let slices: Vec<&[u8]> = cts.iter().map(|b| b.as_slice()).collect();
let blob = concat_output(&slices);
(kt.static_key.0, crate::imp::crypto::sha256(&blob))
})
.collect();
keyed.sort_by(|a, b| a.0.cmp(&b.0));
let leaves: Vec<CoreBytes32> = keyed.into_iter().map(|(_, leaf)| leaf).collect();
let recomputed = MerkleTree::from_leaves(leaves).root();
assert_eq!(
persisted_state_root, recomputed,
"persisted GenerationState.root must equal the freshly recomputed per-resource tree root"
);
assert_eq!(manifest.root, recomputed);
}
#[test]
fn commit_state_root_equals_per_resource_ciphertext_tree_root() {
use crate::imp::core::merkle::MerkleTree;
let dir = tempdir().unwrap();
let store_id = Bytes32([0x44u8; 32]);
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let big = vec![0x5Au8; 300_000];
store.stage_file("data.bin", &big).unwrap();
store.stage_file("note.txt", b"a small note").unwrap();
let returned = store.commit().unwrap();
let hist = store.root_history().unwrap();
let persisted_state_root = hist.last().unwrap().root;
assert_eq!(persisted_state_root, returned);
let leaves = expected_resource_leaves(
store_id,
None, &[("data.bin", &big), ("note.txt", b"a small note")],
);
let expected_root = MerkleTree::from_leaves(leaves).root();
assert_eq!(
persisted_state_root, expected_root,
"GenerationState.root must equal the per-resource ciphertext tree root"
);
}
#[test]
fn commit_refuses_empty_staging() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let err = store.commit().unwrap_err();
assert!(matches!(err, crate::imp::store::StoreError::EmptyStaging));
}
#[test]
fn commit_is_deterministic_for_fixed_input() {
fn build() -> Bytes32 {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(42)).unwrap();
store
.stage_file("a.txt", b"deterministic content here")
.unwrap();
store.stage_file("b.txt", &vec![7u8; 100_000]).unwrap();
store.commit().unwrap()
}
assert_eq!(build(), build());
}
#[test]
fn commit_static_key_matches_client_url_retrieval_key() {
use crate::imp::store::GenerationManifest;
use dig_urn_protocol::{Bytes32 as UrnBytes32, DigUrn};
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
store.stage_file("index.html", b"hello").unwrap();
let root = store.commit().unwrap();
let manifest =
GenerationManifest::read_from(store.paths().generation_manifest(&root.to_hex())).unwrap();
let rec = manifest
.key_table
.iter()
.find(|r| r.resource_key == "index.html")
.unwrap();
let client_urn = DigUrn {
chain: "chia".to_string(),
store_id: UrnBytes32([0x44u8; 32]),
root_hash: None,
resource_key: Some("index.html".to_string()),
};
assert_eq!(rec.static_key, Bytes32(client_urn.retrieval_key().0));
assert_eq!(rec.generation, root);
}
fn count_all_chunk_files(generations_dir: &std::path::Path) -> usize {
let mut n = 0;
for gen in std::fs::read_dir(generations_dir).unwrap() {
let chunks = gen.unwrap().path().join("chunks");
if chunks.is_dir() {
for e in std::fs::read_dir(&chunks).unwrap() {
let e = e.unwrap();
if e.file_type().unwrap().is_file()
&& !e.file_name().to_string_lossy().ends_with(".tmp")
{
n += 1;
}
}
}
}
n
}
#[test]
fn shared_chunk_is_stored_once_across_generations() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let payload = vec![0x5Au8; 300_000];
store.stage_file("data.bin", &payload).unwrap();
let root0 = store.commit().unwrap();
store.stage_file("data.bin", &payload).unwrap();
store
.stage_file("note.txt", b"a unique small note")
.unwrap();
let root1 = store.commit().unwrap();
assert_ne!(root0, root1, "different generations have different roots");
use crate::imp::store::GenerationManifest;
let m0 =
GenerationManifest::read_from(store.paths().generation_manifest(&root0.to_hex())).unwrap();
let m1 =
GenerationManifest::read_from(store.paths().generation_manifest(&root1.to_hex())).unwrap();
let mut union = m0.chunk_hashes();
union.extend(m1.chunk_hashes());
let on_disk = count_all_chunk_files(&store.paths().generations_dir());
assert_eq!(
on_disk,
union.len(),
"each unique chunk stored exactly once across all generations"
);
}
#[test]
fn resolve_chunk_reads_deduplicated_chunk_across_generations() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
let payload = vec![0x5Au8; 300_000];
store.stage_file("data.bin", &payload).unwrap();
let root0 = store.commit().unwrap();
store.stage_file("data.bin", &payload).unwrap();
store
.stage_file("note.txt", b"a unique small note")
.unwrap();
let root1 = store.commit().unwrap();
assert_ne!(root0, root1, "generation 1 must have a distinct root");
use crate::imp::store::GenerationManifest;
let m1 =
GenerationManifest::read_from(store.paths().generation_manifest(&root1.to_hex())).unwrap();
let shared = m1.chunks[0].hash;
let gen1_local = store.paths().chunk_file(&root1.to_hex(), &shared.to_hex());
assert!(
!gen1_local.exists(),
"dedup leaves generation 1 chunks/ sparse"
);
let bytes = store.resolve_chunk(shared).unwrap();
assert_eq!(bytes.len(), m1.chunks[0].size as usize);
assert_eq!(crate::imp::crypto::sha256(&bytes), shared);
}
#[test]
fn resolve_unknown_chunk_errors() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(1)).unwrap();
store.stage_file("a.txt", b"x").unwrap();
let _r = store.commit().unwrap();
let bogus = Bytes32([0xCDu8; 32]);
let err = store.resolve_chunk(bogus).unwrap_err();
assert!(matches!(
err,
crate::imp::store::StoreError::ChunkNotFound(_)
));
}
#[test]
fn log_lists_generations_in_order() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(10)).unwrap();
store.stage_file("a.txt", b"first").unwrap();
let _r0 = store.commit().unwrap();
store.stage_file("b.txt", b"second").unwrap();
let _r1 = store.commit().unwrap();
let log = store.log().unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].id, 0);
assert_eq!(log[1].id, 1);
}
#[test]
fn diff_between_two_generations_reports_added_key() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(10)).unwrap();
store
.stage_file("a.txt", b"alpha content here padded out")
.unwrap();
let r0 = store.commit().unwrap();
store
.stage_file("a.txt", b"alpha content here padded out")
.unwrap();
store
.stage_file("b.txt", b"a new resource entirely")
.unwrap();
let r1 = store.commit().unwrap();
let d = store.diff(r0, r1).unwrap();
assert_eq!(d.keys_added, vec!["b.txt".to_string()]);
assert!(d.keys_removed.is_empty());
}
#[test]
fn diff_unknown_root_errors() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(10)).unwrap();
store.stage_file("a.txt", b"x").unwrap();
let r0 = store.commit().unwrap();
let bogus = Bytes32([0xEEu8; 32]);
let err = store.diff(r0, bogus).unwrap_err();
assert!(matches!(
err,
crate::imp::store::StoreError::GenerationNotFound(_)
));
}
#[test]
fn current_root_and_history_accessors() {
let dir = tempdir().unwrap();
let mut store = Store::init(config(dir.path()), FixedClock::new(10)).unwrap();
assert!(store.current_root().unwrap().is_none());
store.stage_file("a.txt", b"one").unwrap();
let r0 = store.commit().unwrap();
store.stage_file("b.txt", b"two").unwrap();
let r1 = store.commit().unwrap();
assert_eq!(store.current_root().unwrap(), Some(r1));
assert_eq!(store.roothash_history().unwrap(), vec![r0, r1]);
let sid_hex = "44".repeat(32);
let expected = dir
.path()
.join("modules")
.join(format!("{sid_hex}-{}.dig", r1.to_hex()));
assert_eq!(store.module_path(r1), expected);
}