use std::path::{Path, PathBuf};
use crate::imp::chunker::{chunk_slice, Chunk};
use crate::imp::core::{
AuthenticationInfo, Bytes32, Bytes48, ChunkerConfig, MerkleTree, MetadataManifest, SecretSalt,
StoreConfig, TrustedHostKey, Visibility, MAX_STORE_BYTES,
};
use crate::imp::store::{ChunkRef, GenerationManifest, KeyTableRecord};
use dig_urn_protocol::{Bytes32 as UrnBytes32, DigUrn, CANONICAL_CHAIN};
#[derive(Debug, thiserror::Error)]
pub enum StageError {
#[error("nothing to stage; supply at least one file")]
EmptyStaging,
#[error("staged content is {got_mb:.1} MB, over the {cap_mb:.1} MB limit")]
OverCap { got_mb: f64, cap_mb: f64 },
#[error("compile failed: {0}")]
Compile(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub fn embedded_guest_wasm() -> &'static [u8] {
include_bytes!(concat!(env!("OUT_DIR"), "/dig_capsule_guest.wasm"))
}
pub fn chunker_config() -> ChunkerConfig {
ChunkerConfig {
min_size: 16 * 1024,
target_size: 64 * 1024,
max_size: 256 * 1024,
mask: (1u64 << 16) - 1,
}
}
pub fn canonical_resource_urn(store_id: Bytes32, resource_key: &str) -> DigUrn {
DigUrn {
chain: CANONICAL_CHAIN.to_string(),
store_id: UrnBytes32(store_id.0),
root_hash: None,
resource_key: Some(resource_key.to_string()),
}
}
fn salt_of(visibility: &Visibility) -> Option<SecretSalt> {
match visibility {
Visibility::Private(s) => Some(*s),
Visibility::Public => None,
}
}
fn cap_of(max_size: u64) -> u64 {
if max_size == 0 {
MAX_STORE_BYTES
} else {
max_size
}
}
pub struct PreparedCommit {
pub root: Bytes32,
pool_bodies: Vec<Vec<u8>>,
pool_hashes: Vec<Bytes32>,
key_records: Vec<(String, Vec<u32>, u64)>,
next_id: u64,
timestamp: u64,
store_id: Bytes32,
}
pub fn build_prepared(
files: &[(String, Vec<u8>)],
store_id: Bytes32,
visibility: &Visibility,
max_size: u64,
pre_encrypted: bool,
next_id: u64,
timestamp: u64,
) -> Result<PreparedCommit, StageError> {
let salt = salt_of(visibility);
if files.is_empty() {
return Err(StageError::EmptyStaging);
}
let cap = cap_of(max_size);
let staged_total: u64 = files.iter().map(|(_, c)| c.len() as u64).sum();
if staged_total > cap {
return Err(StageError::OverCap {
got_mb: staged_total as f64 / 1_000_000.0,
cap_mb: cap as f64 / 1_000_000.0,
});
}
let mut pool_bodies: Vec<Vec<u8>> = Vec::new(); let mut pool_hashes: Vec<Bytes32> = Vec::new(); let mut key_records: Vec<(String, Vec<u32>, u64)> = Vec::new();
let mut keyed_leaves: Vec<([u8; 32], Bytes32)> = Vec::new();
for (resource_key, content) in files {
let urn = canonical_resource_urn(store_id, resource_key);
let chunk_cts: Vec<Vec<u8>> = if pre_encrypted {
vec![content.clone()]
} else {
let aes_key =
crate::imp::crypto::derive_decryption_key(&urn.canonical(), salt.as_ref());
let chunks: Vec<Chunk> = chunk_slice(content, &chunker_config());
let chunks = if chunks.is_empty() {
vec![Chunk::new(0, Vec::new())]
} else {
chunks
};
chunks
.iter()
.map(|c| crate::imp::crypto::encrypt_chunk(&aes_key, &c.data))
.collect()
};
let mut indices = Vec::with_capacity(chunk_cts.len());
for ct in &chunk_cts {
let h = crate::imp::crypto::sha256(ct);
let idx = pool_bodies.len() as u32;
pool_bodies.push(ct.clone());
pool_hashes.push(h);
indices.push(idx);
}
let slices: Vec<&[u8]> = chunk_cts.iter().map(|c| c.as_slice()).collect();
let resource_blob = crate::imp::core::serving::concat_output(&slices);
keyed_leaves.push((
urn.retrieval_key().0,
crate::imp::crypto::sha256(&resource_blob),
));
let size = if pre_encrypted {
content.len().saturating_sub(16) as u64
} else {
content.len() as u64
};
key_records.push((resource_key.clone(), indices, size));
}
keyed_leaves.sort_by(|a, b| a.0.cmp(&b.0));
let resource_leaves: Vec<Bytes32> = keyed_leaves.into_iter().map(|(_, l)| l).collect();
let tree = MerkleTree::from_leaves(resource_leaves);
let root = tree.root();
Ok(PreparedCommit {
root,
pool_bodies,
pool_hashes,
key_records,
next_id,
timestamp,
store_id,
})
}
pub struct CompiledCapsule {
pub store_id: Bytes32,
pub root: Bytes32,
pub module_path: PathBuf,
pub size: u64,
pub manifest: GenerationManifest,
}
impl CompiledCapsule {
pub fn capsule(&self) -> String {
format!("{}:{}", self.store_id.to_hex(), self.root.to_hex())
}
pub fn files(&self) -> usize {
self.manifest.key_table.len()
}
}
pub struct FinalizeOptions {
pub data_dir: PathBuf,
pub trusted_keys: Vec<TrustedHostKey>,
pub store_pubkey: Bytes48,
pub metadata: MetadataManifest,
pub chain_state: Option<crate::imp::core::datasection::ChainState>,
pub auth: AuthenticationInfo,
pub include_public_manifest: bool,
}
pub fn no_auth() -> AuthenticationInfo {
AuthenticationInfo {
requires_session: false,
requires_jwt: false,
jwks_url: None,
accepted_algorithms: Vec::new(),
}
}
pub fn finalize(
prepared: PreparedCommit,
opts: &FinalizeOptions,
) -> Result<CompiledCapsule, StageError> {
let PreparedCommit {
root,
pool_bodies,
pool_hashes,
key_records,
next_id,
timestamp,
store_id,
} = prepared;
let root_hex = root.to_hex();
let generations_dir = opts.data_dir.join("generations");
let chunks_dir = generations_dir.join(&root_hex).join("chunks");
std::fs::create_dir_all(&chunks_dir)?;
let mut chunk_refs = Vec::with_capacity(pool_bodies.len());
for (i, (hash, body)) in pool_hashes.iter().zip(pool_bodies.iter()).enumerate() {
std::fs::write(chunks_dir.join(hash.to_hex()), body)?;
chunk_refs.push(ChunkRef {
index: i as u32,
hash: *hash,
size: body.len() as u64,
});
}
let key_table: Vec<KeyTableRecord> = key_records
.iter()
.map(|(rk, indices, total)| {
let urn = canonical_resource_urn(store_id, rk);
KeyTableRecord {
resource_key: rk.clone(),
static_key: Bytes32(urn.retrieval_key().0),
generation: root,
chunk_indices: indices.clone(),
total_size: *total,
}
})
.collect();
let manifest = GenerationManifest {
schema_version: 1,
generation_id: next_id,
root,
timestamp,
chunks: chunk_refs,
key_table,
};
manifest
.write_to(generations_dir.join(&root_hex).join("manifest.json"))
.map_err(|e| StageError::Compile(format!("write manifest: {e}")))?;
let public_manifest = if opts.include_public_manifest {
Some(
crate::imp::store::build_public_manifest(&generations_dir)
.map_err(|e| StageError::Compile(format!("build public manifest: {e}")))?,
)
} else {
None
};
let output_path = compile_module(
store_id,
&pool_bodies,
&manifest,
root,
opts,
public_manifest,
)?;
let output_size = std::fs::metadata(&output_path)
.map(|m| m.len())
.unwrap_or(0);
Ok(CompiledCapsule {
store_id,
root,
module_path: output_path,
size: output_size,
manifest,
})
}
#[allow(clippy::too_many_arguments)]
pub fn stage_and_compile(
files: &[(String, Vec<u8>)],
store_id: Bytes32,
visibility: &Visibility,
max_size: u64,
pre_encrypted: bool,
next_id: u64,
timestamp: u64,
opts: &FinalizeOptions,
) -> Result<CompiledCapsule, StageError> {
let prepared = build_prepared(
files,
store_id,
visibility,
max_size,
pre_encrypted,
next_id,
timestamp,
)?;
finalize(prepared, opts)
}
fn compile_module(
store_id: Bytes32,
pool_bodies: &[Vec<u8>],
manifest: &GenerationManifest,
root: Bytes32,
opts: &FinalizeOptions,
public_manifest: Option<crate::imp::core::PublicManifest>,
) -> Result<PathBuf, StageError> {
use crate::imp::compiler::{Compiler, CompilerConfig, GenerationView, ResourceView};
struct Res {
key: Bytes32,
chunks: Vec<(Bytes32, Vec<u8>)>,
}
impl ResourceView for Res {
fn resource_key(&self) -> Bytes32 {
self.key
}
fn chunks(&self) -> Vec<(Bytes32, Vec<u8>)> {
self.chunks.clone()
}
}
struct Gen {
root: Bytes32,
res: Vec<Res>,
}
impl GenerationView for Gen {
fn root(&self) -> Bytes32 {
self.root
}
fn resources(&self) -> Vec<Box<dyn ResourceView + '_>> {
self.res
.iter()
.map(|r| {
Box::new(Res {
key: r.key,
chunks: r.chunks.clone(),
}) as Box<dyn ResourceView + '_>
})
.collect()
}
}
let res: Vec<Res> = manifest
.key_table
.iter()
.map(|kt| Res {
key: kt.static_key,
chunks: kt
.chunk_indices
.iter()
.map(|&i| {
let body = pool_bodies[i as usize].clone();
(crate::imp::crypto::sha256(&body), body)
})
.collect(),
})
.collect();
let gen = Gen { root, res };
let output_dir = opts.data_dir.join("modules");
std::fs::create_dir_all(&output_dir)?;
let ccfg = CompilerConfig {
output_dir,
obfuscate: false,
optimize: false,
template_override: Some(embedded_guest_wasm().to_vec()),
..CompilerConfig::default()
};
let outcome = Compiler::compile(
&ccfg,
store_id,
opts.store_pubkey,
&[gen],
opts.metadata.clone(),
opts.auth.clone(),
&opts.trusted_keys,
opts.chain_state.clone(),
public_manifest,
)
.map_err(|e| StageError::Compile(format!("{e:?}")))?;
Ok(outcome.result.output_path)
}
pub fn ephemeral_config(store_id: Bytes32, visibility: Visibility, data_dir: &Path) -> StoreConfig {
StoreConfig {
store_id,
data_dir: data_dir.display().to_string(),
max_size: MAX_STORE_BYTES,
visibility,
label: None,
description: None,
}
}
pub fn empty_manifest() -> MetadataManifest {
MetadataManifest {
schema_version: 1,
name: String::new(),
version: None,
description: None,
authors: Vec::new(),
license: None,
homepage: None,
repository: None,
keywords: Vec::new(),
categories: Vec::new(),
icon: None,
content_type: None,
links: Default::default(),
custom: Default::default(),
}
}
pub fn manifest_from_json(v: &serde_json::Value) -> MetadataManifest {
use crate::imp::core::Author;
use std::collections::BTreeMap;
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|x| x.to_string());
let opt = |k: &str| s(k).filter(|t| !t.is_empty());
let arr_str = |k: &str| {
v.get(k)
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|e| e.as_str().map(|t| t.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
let authors = v
.get("authors")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|e| {
let name = e.get("name").and_then(|n| n.as_str())?.to_string();
Some(Author {
name,
handle: e
.get("handle")
.and_then(|h| h.as_str())
.map(|t| t.to_string()),
contact: e
.get("contact")
.and_then(|h| h.as_str())
.map(|t| t.to_string()),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let links = v
.get("links")
.and_then(|x| x.as_object())
.map(|o| {
o.iter()
.filter_map(|(k, val)| val.as_str().map(|t| (k.clone(), t.to_string())))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let custom = v
.get("custom")
.and_then(|x| x.as_object())
.map(|o| {
o.iter()
.map(|(k, val)| (k.clone(), val.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
MetadataManifest {
schema_version: v
.get("schema_version")
.and_then(|x| x.as_u64())
.unwrap_or(1) as u32,
name: s("name").unwrap_or_default(),
version: opt("version"),
description: opt("description"),
authors,
license: opt("license"),
homepage: opt("homepage"),
repository: opt("repository"),
keywords: arr_str("keywords"),
categories: arr_str("categories"),
icon: opt("icon"),
content_type: opt("content_type"),
links,
custom,
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use tempfile::tempdir;
fn trusted_pubkey() -> (Vec<TrustedHostKey>, Bytes48) {
let secret = crate::imp::crypto::bls::SecretKey::from_seed(&[7u8; 32]);
let pk = secret.public_key().to_bytes();
(
vec![TrustedHostKey {
public_key: pk.0,
label: format!("test:{}", pk.to_hex()),
}],
pk,
)
}
fn finalize_opts(data_dir: &Path) -> FinalizeOptions {
let (trusted, pk) = trusted_pubkey();
FinalizeOptions {
data_dir: data_dir.to_path_buf(),
trusted_keys: trusted,
store_pubkey: pk,
metadata: empty_manifest(),
chain_state: None,
auth: no_auth(),
include_public_manifest: true,
}
}
#[test]
fn stage_and_compile_produces_a_real_module() {
let td = tempdir().unwrap();
let store_id = Bytes32([1u8; 32]);
let files = vec![("index.html".to_string(), b"<h1>hi</h1>".to_vec())];
let cap = stage_and_compile(
&files,
store_id,
&Visibility::Public,
MAX_STORE_BYTES,
false,
0,
0,
&finalize_opts(td.path()),
)
.unwrap();
assert!(cap.module_path.exists(), "module must be written to disk");
assert!(cap.size > 0, "module must be non-empty");
assert_ne!(
cap.root,
Bytes32([0u8; 32]),
"root must be a real merkle root"
);
assert_eq!(cap.files(), 1);
assert_eq!(
cap.capsule(),
format!("{}:{}", store_id.to_hex(), cap.root.to_hex())
);
}
#[test]
fn public_store_embeds_public_manifest_section() {
let td = tempdir().unwrap();
let store_id = Bytes32([2u8; 32]);
let files = vec![
("index.html".to_string(), b"<h1>hi</h1>".to_vec()),
("assets/app.js".to_string(), b"console.log(1)".to_vec()),
];
let cap = stage_and_compile(
&files,
store_id,
&Visibility::Public,
MAX_STORE_BYTES,
false,
0,
0,
&finalize_opts(td.path()),
)
.unwrap();
let module = std::fs::read(&cap.module_path).unwrap();
let blob = crate::imp::compiler::extract_data_section_blob(&module).unwrap();
let pm = crate::imp::core::datasection::read_public_manifest(&blob)
.unwrap()
.expect("public store embeds a PublicManifest section");
let paths: Vec<&str> = pm.entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, vec!["assets/app.js", "index.html"]);
for e in &pm.entries {
assert_eq!(e.latest_root, cap.root);
assert_eq!(e.generation_index, 0);
assert_eq!(e.version_count, 1);
}
}
#[test]
fn private_store_omits_public_manifest_section() {
let td = tempdir().unwrap();
let mut opts = finalize_opts(td.path());
opts.include_public_manifest = false;
let cap = stage_and_compile(
&[("secret.txt".to_string(), b"top secret".to_vec())],
Bytes32([3u8; 32]),
&Visibility::Private(SecretSalt([9u8; 32])),
MAX_STORE_BYTES,
false,
0,
0,
&opts,
)
.unwrap();
let module = std::fs::read(&cap.module_path).unwrap();
let blob = crate::imp::compiler::extract_data_section_blob(&module).unwrap();
assert!(crate::imp::core::datasection::read_public_manifest(&blob)
.unwrap()
.is_none());
}
#[test]
fn empty_file_set_is_rejected() {
let td = tempdir().unwrap();
let err = stage_and_compile(
&[],
Bytes32([1u8; 32]),
&Visibility::Public,
MAX_STORE_BYTES,
false,
0,
0,
&finalize_opts(td.path()),
);
assert!(matches!(err, Err(StageError::EmptyStaging)));
}
#[test]
fn over_cap_content_is_rejected() {
let td = tempdir().unwrap();
let files = vec![("big".to_string(), vec![0u8; 100])];
let err = stage_and_compile(
&files,
Bytes32([1u8; 32]),
&Visibility::Public,
4, false,
0,
0,
&finalize_opts(td.path()),
);
assert!(matches!(err, Err(StageError::OverCap { .. })));
}
#[test]
fn same_inputs_produce_the_same_root() {
let td1 = tempdir().unwrap();
let td2 = tempdir().unwrap();
let store_id = Bytes32([9u8; 32]);
let files = vec![
("a.txt".to_string(), b"alpha".to_vec()),
("b.txt".to_string(), b"beta".to_vec()),
];
let r1 = stage_and_compile(
&files,
store_id,
&Visibility::Public,
MAX_STORE_BYTES,
false,
0,
0,
&finalize_opts(td1.path()),
)
.unwrap()
.root;
let r2 = stage_and_compile(
&files,
store_id,
&Visibility::Public,
MAX_STORE_BYTES,
false,
0,
0,
&finalize_opts(td2.path()),
)
.unwrap()
.root;
assert_eq!(r1, r2);
}
}