1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
use crate::imp::chunker::chunk_slice;
use crate::imp::core::serving::concat_output;
use crate::imp::core::{
Bytes32, ChunkerConfig, GenerationState, MerkleTree, SecretSalt, StoreConfig, Visibility,
};
use crate::imp::store::chunkstore::ChunkStore;
use crate::imp::store::clock::Clock;
use crate::imp::store::config::{load_config, save_config};
use crate::imp::store::error::{Result, StoreError};
use crate::imp::store::generation::{ChunkRef, GenerationManifest, KeyTableRecord};
use crate::imp::store::history::RootHistory;
use crate::imp::store::paths::StorePaths;
use crate::imp::store::staging::StagingArea;
use dig_urn_protocol::{Bytes32 as UrnBytes32, DigUrn, CANONICAL_CHAIN};
use std::path::Path;
/// The host-side Store entity (§4). Owns the on-disk layout, staging, and
/// generations. Generic over a `Clock` so commit timestamps are injectable.
pub struct Store<C: Clock> {
config: StoreConfig,
paths: StorePaths,
clock: C,
}
impl<C: Clock> std::fmt::Debug for Store<C> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Store")
.field("config", &self.config)
.field("paths", &self.paths)
.finish_non_exhaustive()
}
}
impl<C: Clock> Store<C> {
/// Create a new store: write config + the §4.4 directory tree. Refuses to
/// overwrite an existing store (presence of `config.toml`).
pub fn init(config: StoreConfig, clock: C) -> Result<Self> {
let paths = StorePaths::new(&config.data_dir, config.store_id);
if paths.config_file().exists() {
return Err(StoreError::AlreadyExists(
paths.root().display().to_string(),
));
}
std::fs::create_dir_all(paths.root())?;
std::fs::create_dir_all(paths.generations_dir())?;
std::fs::create_dir_all(paths.modules_dir())?;
save_config(paths.config_file(), &config)?;
StagingArea::open(paths.staging_file())?;
RootHistory::open(paths.history_file())?;
Ok(Self {
config,
paths,
clock,
})
}
/// Open an existing store rooted at `data_dir`.
pub fn open(data_dir: impl AsRef<Path>, clock: C) -> Result<Self> {
let data_dir = data_dir.as_ref();
let config_file = data_dir.join("config.toml");
if !config_file.exists() {
return Err(StoreError::NotFound(data_dir.display().to_string()));
}
let config = load_config(&config_file)?;
let paths = StorePaths::new(data_dir, config.store_id);
Ok(Self {
config,
paths,
clock,
})
}
pub fn store_id(&self) -> Bytes32 {
self.config.store_id
}
pub fn config(&self) -> &StoreConfig {
&self.config
}
pub fn paths(&self) -> &StorePaths {
&self.paths
}
/// All generation states, oldest first (§4.3 root history).
pub fn root_history(&self) -> Result<Vec<GenerationState>> {
RootHistory::open(self.paths.history_file())?.entries()
}
/// True if a chunk with this hash is already stored under some generation
/// directory (global dedup index, §8.2).
fn chunk_exists_anywhere(&self, hash: Bytes32) -> Result<bool> {
let gens = self.paths.generations_dir();
if !gens.exists() {
return Ok(false);
}
let name = hash.to_hex();
for entry in std::fs::read_dir(&gens)? {
let chunks = entry?.path().join("chunks");
if chunks.join(&name).exists() {
return Ok(true);
}
}
Ok(false)
}
/// Stage raw bytes under an explicit resource key (§20.2).
pub fn stage_file(&mut self, resource_key: &str, bytes: &[u8]) -> Result<()> {
let mut staging = StagingArea::open(self.paths.staging_file())?;
staging.append(resource_key, bytes)?;
Ok(())
}
/// Stage a file from disk. The path relative to `base` becomes the resource
/// key (forward-slash normalized); the file bytes are staged verbatim.
pub fn add(&mut self, file: impl AsRef<Path>, base: impl AsRef<Path>) -> Result<()> {
let file = file.as_ref();
let base = base.as_ref();
let rel = file
.strip_prefix(base)
.map_err(|_| StoreError::PathEscape(file.to_path_buf()))?;
let resource_key = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
let bytes = std::fs::read(file)?;
self.stage_file(&resource_key, &bytes)
}
/// Finalize a generation (§20.3, §8.2): chunk staged content, AES-256-GCM
/// seal each chunk under its resource's per-URN key (chunks are stored as
/// CIPHERTEXT, content-addressed by `SHA-256(ciphertext)`), build the
/// PER-RESOURCE merkle tree (leaf = `SHA-256(concat_output(ordered chunk
/// ciphertexts))`, resources ascending by `static_key` — the exact leaves
/// the compiler injects, D5), append the root to history, write the
/// generation directory. Returns the new root hash. Does NOT compile the
/// module (that is `dig-capsule-compiler`'s job over this generation dir).
pub fn commit(&mut self) -> Result<Bytes32> {
let mut staging = StagingArea::open(self.paths.staging_file())?;
let records = staging.records()?;
if records.is_empty() {
return Err(StoreError::EmptyStaging);
}
// Catalog chunker defaults (min 16 KiB, target 64 KiB, max 256 KiB).
// `mask` selects the average chunk size; (1<<16)-1 targets ~64 KiB.
let chunker = ChunkerConfig {
min_size: 16 * 1024,
target_size: 64 * 1024,
max_size: 256 * 1024,
mask: (1u64 << 16) - 1,
};
// Per-store secret salt (private stores mix it into the per-URN key, §11.4).
let salt: Option<SecretSalt> = match &self.config.visibility {
Visibility::Private(s) => Some(*s),
Visibility::Public => None,
};
// Build the chunk pool in staged-record order (the §8.3 source consumed
// by the compiler) and the key table mapping each resource to its
// ordered pool indices. Chunks are stored as CIPHERTEXT: each resource's
// chunks are AES-256-GCM-sealed under its per-URN key, and the merkle
// leaf is committed over those SAME ciphertext bytes (D4/D5), so the
// committed root matches what the compiler injects.
let mut pool: Vec<(Bytes32, Vec<u8>)> = Vec::new();
let mut key_table: Vec<KeyTableRecord> = Vec::new();
// (static_key raw, resource ciphertext leaf) per resource, for the D5 tree.
let mut resource_leaves: Vec<([u8; 32], Bytes32)> = Vec::new();
for rec in &records {
// root_hash: None -> retrieval key is root-independent (documented).
let urn = DigUrn {
chain: CANONICAL_CHAIN.to_string(),
store_id: UrnBytes32(self.config.store_id.0),
root_hash: None,
resource_key: Some(rec.resource_key.clone()),
};
let static_key = Bytes32(urn.retrieval_key().0);
// Per-URN AES-256 key (§11.1): public store uses the fixed salt domain,
// private store mixes the secret salt.
let aes_key =
crate::imp::crypto::derive_decryption_key(&urn.canonical(), salt.as_ref());
let chunks = chunk_slice(&rec.content, &chunker);
let mut indices = Vec::with_capacity(chunks.len());
let mut total: u64 = 0;
// Ciphertext bodies of this resource's chunks, in resource order.
let mut ct_bodies: Vec<Vec<u8>> = Vec::with_capacity(chunks.len());
for chunk in &chunks {
// Encrypt the plaintext chunk; the pool/merkle commit over ciphertext.
let ct = crate::imp::crypto::encrypt_chunk(&aes_key, &chunk.data);
// Content-addressed by SHA-256(ciphertext) — dedup key is the
// ciphertext hash (still per-chunk, unchanged storage model).
let hash = crate::imp::crypto::sha256(&ct);
let index = pool.len() as u32;
total += chunk.data.len() as u64;
ct_bodies.push(ct.clone());
pool.push((hash, ct));
indices.push(index);
}
// D5 leaf = resource_leaf(concat_output(ordered chunk ciphertexts)) —
// SHA-256 over the exact bytes `get_content` returns for this resource.
// The SAME `crate::imp::core::resource_leaf` the browser verifier checks
// against (`dig-capsule-wasm`), so the content→leaf contract is shared.
let slices: Vec<&[u8]> = ct_bodies.iter().map(|b| b.as_slice()).collect();
let blob = concat_output(&slices);
let leaf = crate::imp::core::resource_leaf(&blob);
resource_leaves.push((static_key.0, leaf));
key_table.push(KeyTableRecord {
resource_key: rec.resource_key.clone(),
static_key,
generation: Bytes32([0u8; 32]), // placeholder set after root
chunk_indices: indices,
total_size: total,
});
}
// D5 merkle tree: ONE leaf per resource, ascending by static_key (the
// exact leaf order the compiler injects and the guest ranks against).
// §9.4 invariant `state.root == tree.root()` still holds.
resource_leaves.sort_by_key(|r| r.0);
let leaves: Vec<Bytes32> = resource_leaves.iter().map(|(_, leaf)| *leaf).collect();
let tree = MerkleTree::from_leaves(leaves);
let root = tree.root();
let root_hex = root.to_hex();
// Now that we know the root, stamp each key-table record's generation.
for rec in &mut key_table {
rec.generation = root;
}
// Write generation dir (per-directory dedup; global dedup added in
// Task 13 over `chunk_exists_anywhere`).
let chunks_dir = self.paths.generation_chunks_dir(&root_hex);
std::fs::create_dir_all(&chunks_dir)?;
let chunkstore = ChunkStore::new(&chunks_dir);
let mut chunk_refs = Vec::with_capacity(pool.len());
for (i, (hash, data)) in pool.iter().enumerate() {
// §8.2: only store the chunk if it is not already present in this or
// any prior generation. `chunk_refs` still records every chunk's
// index so reassembly is complete regardless of where the bytes live
// (resolved globally by `Store::resolve_chunk`, Task 14).
if !self.chunk_exists_anywhere(*hash)? {
chunkstore.put(*hash, data)?;
}
chunk_refs.push(ChunkRef {
index: i as u32,
hash: *hash,
size: data.len() as u64,
});
}
let next_id = RootHistory::open(self.paths.history_file())?.next_id()?;
let timestamp = self.clock.unix_seconds();
let manifest = GenerationManifest {
schema_version: 1,
generation_id: next_id,
root,
timestamp,
chunks: chunk_refs,
key_table,
};
manifest.write_to(self.paths.generation_manifest(&root_hex))?;
let mut history = RootHistory::open(self.paths.history_file())?;
history.append(&GenerationState {
id: next_id,
root,
timestamp,
})?;
staging.clear()?;
Ok(root)
}
/// Resolve a chunk's bytes by content hash across ALL generation chunk dirs.
/// Chunk bytes are content-addressed and stored once globally (§8.2), so a
/// chunk introduced by an earlier generation lives only under that
/// generation's `chunks/` dir; later generations referencing it have a
/// sparse `chunks/`. Returns `ChunkNotFound` if no generation holds it.
pub fn resolve_chunk(&self, hash: Bytes32) -> Result<Vec<u8>> {
let gens = self.paths.generations_dir();
if gens.exists() {
let name = hash.to_hex();
for entry in std::fs::read_dir(&gens)? {
let candidate = entry?.path().join("chunks").join(&name);
if candidate.exists() {
return Ok(std::fs::read(&candidate)?);
}
}
}
Err(StoreError::ChunkNotFound(hash.to_hex()))
}
/// Generations in chronological order (§20.4 `log`). Alias of root history.
pub fn log(&self) -> Result<Vec<GenerationState>> {
self.root_history()
}
/// Load a generation manifest by its root hash.
pub fn generation_manifest(&self, root: Bytes32) -> Result<GenerationManifest> {
let path = self.paths.generation_manifest(&root.to_hex());
if !path.exists() {
return Err(StoreError::GenerationNotFound(root.to_hex()));
}
GenerationManifest::read_from(path)
}
/// Diff two generations by root hash (§20.4 `diff`).
pub fn diff(&self, a: Bytes32, b: Bytes32) -> Result<crate::imp::store::diff::GenerationDiff> {
let ma = self.generation_manifest(a)?;
let mb = self.generation_manifest(b)?;
Ok(crate::imp::store::diff::GenerationDiff::between(&ma, &mb))
}
/// The current head root hash, or `None` if no generation has been committed.
pub fn current_root(&self) -> Result<Option<Bytes32>> {
Ok(RootHistory::open(self.paths.history_file())?
.head()?
.map(|g| g.root))
}
/// All root hashes in chronological order — the source for the guest's
/// `get_roothash_history` export (§4.3). Consumed by `dig-capsule-guest`.
pub fn roothash_history(&self) -> Result<Vec<Bytes32>> {
Ok(self.root_history()?.into_iter().map(|g| g.root).collect())
}
/// Deterministic path of the compiled module for a given root (§4.4):
/// `{store_id}-{root}.dig` under `modules/`. Consumed by `dig-capsule-compiler`.
pub fn module_path(&self, root: Bytes32) -> std::path::PathBuf {
self.paths.module_file(&root.to_hex())
}
}