dig-capsule 0.5.0

The DIG Network .dig capsule data plane — one crate over the DIGS format, capsule read-crypto, compiler, staging, and the guest/host serve triad.
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
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
//! dig-capsule-stage — the context-free stage→compile engine.
//!
//! This crate is the SINGLE home of the "turn a file set into a capsule" pipeline:
//! AES-256-GCM-seal each resource's chunks under its per-URN key, build the
//! generation merkle tree over the **ciphertext** resource leaves (so client
//! merkle verification of ciphertext-to-root is genuine), persist the generation
//! manifest + ciphertext chunk bodies, and compile a real self-serving `.dig`
//! WASM module (BINDING contract D6 — compiled with the embedded `dig-capsule-guest`
//! wasm, so the module serves itself through `crate::imp::host::HostRuntime`).
//!
//! It is the EXACT engine the `digstore` CLI `commit`/`compile`/`deploy` use —
//! the CLI's `ops::store_ops` now delegates here (no fork), so a CLI commit and
//! an in-process [`stage_and_compile`] of the same files + store id + salt
//! produce byte-identical modules and roots.
//!
//! ## Why this crate exists (the in-process publishing job, #95 Pass C)
//!
//! The DIG Browser runs a native dig-node in-process (`dig_runtime.dll` →
//! `dig-node`). For the browser to publish — turn a folder into a capsule for a
//! local deploy — it needs this pipeline WITHOUT shelling out to the `digstore`
//! CLI binary. The pipeline used to live only in the CLI (a binary crate
//! `dig-node` cannot depend on) and the guest wasm was embedded only in the CLI.
//! Lifting both into this library crate lets BOTH the CLI and `dig-node` use one
//! copy, and embeds the guest wasm once (see `build.rs` / [`embedded_guest_wasm`]).
//!
//! The engine is build-only: it stages + compiles + returns the capsule and
//! module path. The on-chain root advance (Pass B `chia_advanceStore`) and the
//! §21 push are the wallet method + remote push respectively — Pass C is the
//! staging/compile half.

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};

/// Errors the stage→compile engine can return. Stable variants so callers
/// (the CLI, and dig-node's `dig.stage` RPC) can map them to catalogued error
/// codes without string-matching.
#[derive(Debug, thiserror::Error)]
pub enum StageError {
    /// No files were supplied to stage (an empty capsule is not meaningful).
    #[error("nothing to stage; supply at least one file")]
    EmptyStaging,
    /// Staged content exceeds the store's size cap.
    #[error("staged content is {got_mb:.1} MB, over the {cap_mb:.1} MB limit")]
    OverCap { got_mb: f64, cap_mb: f64 },
    /// The compiler failed to produce a module.
    #[error("compile failed: {0}")]
    Compile(String),
    /// A filesystem error while persisting the generation / chunk bodies.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

/// The REAL `dig-capsule-guest` wasm, embedded at build time (see `build.rs`).
/// The compiler uses this as the `template_override` so the produced module is
/// genuinely self-serving through `crate::imp::host::HostRuntime::serve_content`
/// (BINDING contract D6). This is the single embedded copy for the whole engine
/// — `digstore-cli` re-exports it rather than embedding its own.
pub fn embedded_guest_wasm() -> &'static [u8] {
    include_bytes!(concat!(env!("OUT_DIR"), "/dig_capsule_guest.wasm"))
}

/// Canonical chunker config (matches `dig-capsule-store`'s commit defaults). Public
/// so callers that only need a chunk-count PREVIEW (e.g. the CLI `add` summary)
/// use the SAME config the commit pipeline does.
pub fn chunker_config() -> ChunkerConfig {
    ChunkerConfig {
        min_size: 16 * 1024,
        target_size: 64 * 1024,
        max_size: 256 * 1024,
        mask: (1u64 << 16) - 1,
    }
}

/// The canonical root-INDEPENDENT URN for a resource (used for both the
/// retrieval key and the AES key, matching `dig-capsule-store`'s own convention).
/// The client must reconstruct this same URN (root dropped) when decrypting.
///
/// Returns the canonical [`DigUrn`] (from `dig-urn-protocol`); the `store_id` is
/// taken as a `crate::imp::core::Bytes32` for caller convenience and bridged to the
/// URN crate's byte type internally.
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,
    }
}

/// The effective per-store cap: the configured `max_size`, or the workspace
/// default ([`MAX_STORE_BYTES`]) when it is unset (`0`).
fn cap_of(max_size: u64) -> u64 {
    if max_size == 0 {
        MAX_STORE_BYTES
    } else {
        max_size
    }
}

/// A staged generation computed from a file set, WITHOUT persistence. Holding
/// this between [`build_prepared`] and [`finalize`] lets the CLI anchor `root`
/// on-chain (and BLOCK until confirmed) BEFORE any local persistence — so local
/// history never advances past the chain. Persists nothing on its own.
pub struct PreparedCommit {
    /// Generation merkle root over the ciphertext resource leaves (D5).
    pub root: Bytes32,
    /// Chunk ciphertext bodies, global pool order.
    pool_bodies: Vec<Vec<u8>>,
    /// SHA-256(chunk ciphertext) per body, same order (manifest/diff).
    pool_hashes: Vec<Bytes32>,
    /// (resource_key, chunk indices into the pool, plaintext total size).
    key_records: Vec<(String, Vec<u32>, u64)>,
    /// The generation id this commit will become.
    next_id: u64,
    /// Commit timestamp.
    timestamp: u64,
    /// The store id these resources belong to (for the key table URNs).
    store_id: Bytes32,
}

/// Compute the staged generation's merkle `root` + the in-memory state
/// [`finalize`] needs, WITHOUT persisting anything.
///
/// Each resource's chunks are AES-256-GCM-sealed under its per-URN key. The
/// served resource ciphertext is the PLAIN ordered concat of its chunk
/// ciphertexts (BINDING contract D5/C9: exactly what the guest's `get_content`
/// returns via `concat_output`). The generation merkle tree has ONE leaf per
/// resource: `leaf = SHA-256(concat_output(ordered chunk ciphertexts))`, so a
/// single `ContentResponse.merkle_proof` fully verifies the served bytes to the
/// root. Leaves are ordered ascending by `static_key` to match the compiler's
/// `current_generation_leaves` (D5), so the store-reported root equals the
/// module's injected `CurrentRoot` and the client gate `proof.root ==
/// trusted_root` holds.
///
/// When `pre_encrypted` is true each file's bytes are treated as ALREADY-SEALED
/// ciphertext (the client sealed it under the per-URN key before upload — the
/// server never sees plaintext or the key); the resource is stored as a SINGLE
/// chunk, skipping the chunk + encrypt step. The produced module/merkle/wire
/// format is otherwise identical.
///
/// This is byte-for-byte the logic the CLI used in `store_ops::build_prepared`.
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);
    }

    // Defensive cap check (§3): refuse to compile content over the store's limit.
    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(); // chunk ciphertext bodies, global order
    let mut pool_hashes: Vec<Bytes32> = Vec::new(); // SHA-256(chunk ciphertext) (manifest/diff)
    let mut key_records: Vec<(String, Vec<u32>, u64)> = Vec::new();
    // (static_key, leaf) so we can sort leaves ascending by static_key (D5).
    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);
        // Ordered CHUNK CIPHERTEXTS for this resource.
        let chunk_cts: Vec<Vec<u8>> = if pre_encrypted {
            // PRE-ENCRYPTED: the bytes ARE the resource's already-sealed ciphertext (the client
            // sealed it under the per-URN key; the server never sees plaintext or the key). Stored
            // as ONE chunk — D5 leaf = SHA-256(these bytes). No chunking, no encryption here.
            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);
        }
        // D5: leaf = SHA-256(concat_output(chunks)) — the exact bytes get_content
        // returns for this resource (plain ordered concat, NO length framing).
        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),
        ));
        // Declared size: plaintext bytes. Pre-encrypted ciphertext carries a 16-byte GCM-SIV tag.
        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));
    }

    // Ascending by static_key (raw 32 bytes; Bytes32 has no Ord) — the exact
    // order the compiler injects and the guest ranks against (D5).
    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,
    })
}

/// The result of [`finalize`] / [`stage_and_compile`]: the produced capsule's
/// identity + the on-disk module artifact, plus the [`GenerationManifest`] (the
/// CLI uses it to write its local URN index — not part of the module bytes).
pub struct CompiledCapsule {
    /// The store id the capsule belongs to.
    pub store_id: Bytes32,
    /// The generation merkle root (the capsule's content version).
    pub root: Bytes32,
    /// The compiled `.dig` module on disk.
    pub module_path: PathBuf,
    /// The module's byte size.
    pub size: u64,
    /// The generation manifest (key table + chunk refs) for this root.
    pub manifest: GenerationManifest,
}

impl CompiledCapsule {
    /// The canonical capsule string identity `storeId:rootHash`
    /// (= `crate::imp::core::Capsule::canonical()`).
    pub fn capsule(&self) -> String {
        format!("{}:{}", self.store_id.to_hex(), self.root.to_hex())
    }

    /// The number of resources committed in this capsule.
    pub fn files(&self) -> usize {
        self.manifest.key_table.len()
    }
}

/// Where [`finalize`] writes the generation manifest + ciphertext chunk bodies
/// and the compiled module, and the serving identity baked into the module.
///
/// This mirrors the CLI's `.dig` layout: `<data_dir>/generations/<root>/…` and
/// `<data_dir>/modules/`. dig-node points these at a scratch dir under its cache.
pub struct FinalizeOptions {
    /// The store's data directory (the `.dig` dir). `generations/` and `modules/`
    /// live directly under it.
    pub data_dir: PathBuf,
    /// The TRUSTED serving host key set compiled into the module (§12.2).
    pub trusted_keys: Vec<TrustedHostKey>,
    /// The store's content-signing public key (compiled into the module).
    pub store_pubkey: Bytes48,
    /// The store-level metadata manifest embedded in the module's data section
    /// (Digstore §8.4, served ungated via the guest `get_metadata` export).
    pub metadata: MetadataManifest,
    /// Optional on-chain pointer to embed (the chainless path passes `None`).
    pub chain_state: Option<crate::imp::core::datasection::ChainState>,
    /// The per-store auth policy compiled into the module (§4.1/§5.2). Most
    /// stores want [`no_auth`]; a JWT/session-required store supplies its own.
    pub auth: AuthenticationInfo,
    /// Embed the normalized public manifest section (the store's complete public
    /// file set, latest version per path) in the compiled `.dig`. Set `true` for
    /// PUBLIC stores; leave `false` for PRIVATE stores so their file paths stay
    /// opaque (a private store carries no `PublicManifest` section).
    pub include_public_manifest: bool,
}

/// The explicit no-auth policy: a store requiring neither a session nor a JWT.
pub fn no_auth() -> AuthenticationInfo {
    AuthenticationInfo {
        requires_session: false,
        requires_jwt: false,
        jwks_url: None,
        accepted_algorithms: Vec::new(),
    }
}

/// Persist a [`PreparedCommit`] and compile its serving module.
///
/// Writes `<data_dir>/generations/<root>/{manifest.json,chunks/*}` and compiles
/// `<data_dir>/modules/<store>-<root>.dig`. Persists NOTHING else (no history,
/// no URN index, no staging clear — those are caller-owned presentation state).
/// The crypto/merkle/manifest bytes + compiled module are byte-for-byte what the
/// CLI produced before this extraction.
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");

    // Persist the generation manifest + ciphertext chunk bodies.
    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}")))?;

    // Normalized public manifest (PUBLIC stores only): walk every generation now
    // on disk (including the one just written) → latest version per path. Built
    // here, after the current generation manifest is persisted, so it reflects the
    // complete public file surface up to and including this commit.
    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
    };

    // Compile a real module (so a real .wasm exists for host/push/clone).
    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,
    })
}

/// One-shot: [`build_prepared`] then [`finalize`]. The straight-through path the
/// in-process node uses to turn a file set into a capsule.
#[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)
}

/// Compile the generation into a real serving module via `dig-capsule-compiler`,
/// using the embedded guest wasm as the compiler template (D6).
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 };

    // The compiler writes the module into `output_dir`; in the CLI this dir is
    // created by `Store::init`, but the context-free engine must ensure it exists.
    let output_dir = opts.data_dir.join("modules");
    std::fs::create_dir_all(&output_dir)?;
    let ccfg = CompilerConfig {
        output_dir,
        obfuscate: false,
        optimize: false,
        // D6: compile with the REAL guest wasm so the module serves itself via
        // `HostRuntime::serve_content` (NOT the stub template).
        template_override: Some(embedded_guest_wasm().to_vec()),
        // §8.3 uniform-size filler budget: production pads to the 128 MiB default
        // (or the DIGSTORE_UNIFORM_BLOB_LEN override) so every store is one size.
        ..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)
}

/// Build a [`StoreConfig`] for an ephemeral/in-process stage (no on-disk store
/// scaffolding required by the engine — [`finalize`] writes only generations +
/// modules). Provided for callers that want to keep the config alongside.
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,
    }
}

/// An empty metadata manifest (the compiler requires one). The default for a
/// stage/compile with no `--metadata` / `metadata` param.
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(),
    }
}

/// Build a [`MetadataManifest`] from the dighub `Manifest` JSON shape (the 14
/// publisher fields). Tolerant: missing/empty fields collapse to `None`/empty;
/// unknown keys are ignored except `custom`, which is preserved verbatim. This is
/// the inverse of the retrieval Lambda's `manifest_to_json`, so a round-trip
/// (.dig → RPC JSON → recompile) is stable. Shared by the CLI `compile` command
/// and the in-process `dig.stage` RPC (one parser, no fork).
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) {
        // A deterministic BLS identity so tests are hermetic.
        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);
        // The capsule string is the canonical storeId:rootHash.
        assert_eq!(
            cap.capsule(),
            format!("{}:{}", store_id.to_hex(), cap.root.to_hex())
        );
    }

    #[test]
    fn public_store_embeds_public_manifest_section() {
        // A PUBLIC store's compiled `.dig` carries the normalized public manifest
        // section; a consumer reads the whole public file surface from the module.
        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"]);
        // Each entry's latest is this single generation (root) with version_count 1.
        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() {
        // A PRIVATE store must NOT leak its file paths: no PublicManifest 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, // 4-byte cap
            false,
            0,
            0,
            &finalize_opts(td.path()),
        );
        assert!(matches!(err, Err(StageError::OverCap { .. })));
    }

    #[test]
    fn same_inputs_produce_the_same_root() {
        // Determinism guard: the root is a content merkle root, so identical
        // files + store id + (public) visibility must reproduce 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);
    }
}