Skip to main content

cratefield_manifest/
lib.rs

1//! The Factory Zero venture manifest and deterministic composition
2//! generator (issue #138).
3//!
4//! A [`VentureManifest`] is the small declarative document that says what a
5//! backend *is* — which modules, on which host, with what config and seed
6//! data. It is the SHARED contract of "Build Anywhere": the native compile
7//! engine (`fz build`) generates a venture crate from it and compiles it;
8//! the wasm compose engine (later) mounts the same module set in the
9//! browser. The same manifest promotes to Cloudflare unchanged.
10//!
11//! Three concerns, three modules:
12//! - [`catalog`] — the module catalog and dependency resolution (the
13//!   wasm-clean canonical home of control-plane's `cratefield_catalog`
14//!   logic; see that module's docs).
15//! - [`manifest`] — the [`VentureManifest`] format and parsing.
16//! - [`generate`](mod@generate) — the deterministic Rust composition generator.
17
18#![forbid(unsafe_code)]
19
20pub mod access;
21pub mod build_key;
22pub mod catalog;
23pub mod diff;
24pub mod generate;
25mod generate_tables;
26pub mod manifest;
27pub mod privacy;
28pub mod provenance;
29
30pub use build_key::{BUILD_PROFILE, BuildKeyError, BuildKeyInputs, build_key, canonical_inputs};
31pub use catalog::{
32    Catalog, CatalogError, CatalogModule, ModuleRelease, ModuleSet, PinnedRelease, ReleaseReview,
33    ResolveError, Tier, builtin, is_exact_version, is_sha256_digest,
34};
35pub use generate::{GeneratedFile, GeneratedVenture, HarnessSource, generate};
36pub use manifest::{ManifestError, ModuleRef, VentureManifest};
37// Re-exported so a caller building a `VentureManifest` can name the type
38// of its `tables` field without adding a dependency of its own.
39pub use access::{Access, AccessMap, LEVELS};
40pub use cratefield_tables::Schema;
41pub use diff::{Change as DeclarationChange, Move, diff as declaration_diff};
42pub use privacy::{Disposition, KINDS, TablePrivacy, TablePrivacyMap};
43pub use provenance::{
44    BuildEnvViolation, BuildEnvironmentAttestation, PROVENANCE_SCHEMA, Provenance, ProvenanceError,
45    ProvenanceFile, ResourceLimits, file_digest, is_placeholder_digest,
46};
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    fn sample_json() -> &'static str {
53        r#"{
54            "name": "acme-signups",
55            "host": "acme.factory0.dev",
56            "cors_origins": ["https://acme.example"],
57            "modules": ["waitlist", { "slug": "email-signup" }],
58            "config": { "brand": "Acme" },
59            "seed_sql": "INSERT INTO waitlist_product (slug) VALUES ('launch');"
60        }"#
61    }
62
63    /// A manifest declaring tables of its own (issue #153).
64    fn with_tables() -> &'static str {
65        r#"{
66            "name": "acme-signups",
67            "host": "acme.factory0.dev",
68            "cors_origins": ["https://acme.example"],
69            "modules": ["waitlist"],
70            "tables": {
71                "note": {
72                    "primary_key": "id",
73                    "fields": [
74                        { "name": "id", "kind": "uuid", "required": true },
75                        { "name": "author", "kind": "text" },
76                        { "name": "body", "kind": "text", "max_len": 400 }
77                    ]
78                }
79            },
80            "table_privacy": {
81                "note": {
82                    "holds": "personal",
83                    "subject": "author",
84                    "kind": "content",
85                    "disposition": "erase",
86                    "description": "The notes you wrote, and when."
87                }
88            },
89            "table_access": { "note": "owner" }
90        }"#
91    }
92
93    #[test]
94    fn a_manifest_without_tables_still_reads_and_writes_unchanged() {
95        // The common case, and the one a new field breaks first: nothing
96        // in the document mentions tables, and nothing in the output does
97        // either.
98        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
99        assert!(manifest.tables.is_empty());
100        manifest.validate().expect("valid");
101        let written = serde_json::to_string(&manifest).expect("writes");
102        assert!(!written.contains("tables"), "{written}");
103    }
104
105    #[test]
106    fn a_declared_table_is_carried_and_checked() {
107        let manifest = VentureManifest::from_json_str(with_tables()).expect("parses");
108        manifest.validate().expect("valid");
109        assert_eq!(manifest.tables.tables.len(), 1);
110        assert_eq!(manifest.tables.tables[0].name, "note");
111        // And back out again: a manifest a control plane writes has to be
112        // one it can read.
113        let written = serde_json::to_string(&manifest).expect("writes");
114        let again = VentureManifest::from_json_str(&written).expect("reads what it wrote");
115        assert_eq!(manifest, again);
116    }
117
118    #[test]
119    fn a_declaration_that_is_not_legal_fails_the_manifest_not_something_later() {
120        // The primary key names no declared field. Reported here, beside
121        // the manifest's own problems, rather than by whatever layer
122        // would have met it next.
123        let broken = with_tables().replace(r#""primary_key": "id""#, r#""primary_key": "nope""#);
124        let manifest = VentureManifest::from_json_str(&broken).expect("parses");
125        let error = manifest.validate().expect_err("not legal");
126        let text = error.to_string();
127        assert!(text.contains("[tables]"), "{text}");
128        assert!(text.contains("nope"), "{text}");
129    }
130
131    #[test]
132    fn a_declared_table_becomes_a_module_the_venture_composes() {
133        // A declaration that produced no module would be a section that
134        // does nothing: the tables would not be created, would not be in
135        // `fz data export`, and would not be reachable by erasure.
136        let manifest = VentureManifest::from_json_str(with_tables()).expect("parses");
137        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
138        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
139            .expect("generates");
140
141        let file = |name: &str| {
142            venture
143                .files
144                .iter()
145                .find(|f| f.path == name)
146                .map(|f| f.contents.as_str())
147                .unwrap_or_default()
148        };
149
150        let tables = file("src/tables.rs");
151        assert!(!tables.is_empty(), "no src/tables.rs was emitted");
152        assert!(
153            tables.contains("CREATE TABLE IF NOT EXISTS note"),
154            "{tables}"
155        );
156        // `tables()` and `personal_data()` both name it, which is the
157        // pair `unlisted_tables` and `undeclared_tables` compare.
158        assert!(tables.contains("&[\"note\"]"), "{tables}");
159        assert!(tables.contains("table: \"note\""), "{tables}");
160        assert!(tables.contains("subject: \"author\""), "{tables}");
161        assert!(tables.contains("DataKind::Content"), "{tables}");
162        assert!(tables.contains("Disposition::Erase"), "{tables}");
163
164        // A table whose access is not `public-read` is a question about
165        // who is asking, so the module declares the port that answers it.
166        assert!(
167            tables.contains("&[Port::Db, Port::Auth]"),
168            "an owner table generated a module that needs no verifier: {tables}"
169        );
170
171        // The module serves the tables rather than only creating them.
172        // `crates/tables-api/tests/routes.rs` hand-writes this same shape
173        // and compiles and drives it, so what these assert is that the
174        // generator emits that shape — not that the shape works.
175        assert!(
176            tables.contains("cratefield::tables_api::router(Arc::new("),
177            "the generated module creates the tables and serves nothing: {tables}"
178        );
179        assert!(
180            tables.contains("cratefield::tables_api::Tables {"),
181            "{tables}"
182        );
183        assert!(tables.contains("const DECLARED: &str = r#"), "{tables}");
184        // And reads it while composing, so a declaration it cannot read
185        // refuses the deployment rather than serving no tables.
186        //
187        // The body of `validate_config` alone, not everything after it:
188        // the first version of this sliced to the end of the file, which
189        // contains `router`'s own `parse` call, so it passed with
190        // `validate_config` returning `Ok(())`.
191        let validate = {
192            let from = tables
193                .find("fn validate_config")
194                .expect("the module validates its config");
195            let rest = &tables[from..];
196            let to = rest.find("fn router").expect("router follows it");
197            &rest[..to]
198        };
199        assert!(
200            validate.contains("cratefield::tables_api::parse(DECLARED)"),
201            "the declaration is never read while composing: {validate}"
202        );
203
204        // And publishes what it serves, or `/__surface` describes a
205        // venture smaller than the one running.
206        assert!(
207            tables.contains("fn surface(&self) -> Surface"),
208            "the declared tables are served and unpublished: {tables}"
209        );
210        assert!(
211            tables.contains("cratefield::tables_api::surface(&tables)"),
212            "{tables}"
213        );
214
215        // And the venture composes it, or it is a file nobody builds.
216        // The facade feature that carries the routes, or the generated
217        // crate does not compile: `cratefield::tables_api` is behind it.
218        let cargo = file("Cargo.toml");
219        assert!(cargo.contains("\"tables-api\","), "{cargo}");
220
221        let lib = file("src/lib.rs");
222        assert!(lib.contains("pub mod tables;"), "{lib}");
223        assert!(
224            lib.contains(".module(crate::tables::DeclaredTables::new())"),
225            "{lib}"
226        );
227    }
228
229    #[test]
230    fn a_generated_venture_can_be_tested() {
231        // Without this an author's first `#[test]` needs a hand edit to a
232        // file whose first line says not to edit it by hand — and the
233        // facade's own `testing` feature is documented as belonging under
234        // a venture's `[dev-dependencies]`, which nothing put there.
235        let manifest = VentureManifest::from_json_str(with_tables()).expect("parses");
236        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
237        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
238            .expect("generates");
239        let cargo = venture
240            .files
241            .iter()
242            .find(|f| f.path == "Cargo.toml")
243            .map(|f| f.contents.as_str())
244            .unwrap_or_default();
245
246        let dev = cargo
247            .split_once("[dev-dependencies]")
248            .unwrap_or_else(|| {
249                panic!("a venture with no dev-dependencies cannot be tested: {cargo}")
250            })
251            .1;
252        assert!(
253            dev.contains("\"testing\""),
254            "the conformance kit is what a venture tests through: {dev}"
255        );
256        // And somewhere to run an async test: every handler is async, and
257        // so is every kit helper that drives one.
258        assert!(dev.contains("pollster"), "{dev}");
259    }
260
261    #[test]
262    fn a_venture_with_a_non_public_table_wires_a_verifier() {
263        // The module requires `Port::Auth`, so the runtime has to provide
264        // one or the composition is refused at boot — correct, and
265        // useless, because nothing would ever wire it.
266        let manifest = VentureManifest::from_json_str(with_tables()).expect("parses");
267        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
268        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
269            .expect("generates");
270        let lib = venture
271            .files
272            .iter()
273            .find(|f| f.path == "src/lib.rs")
274            .map(|f| f.contents.as_str())
275            .unwrap_or_default();
276        assert!(
277            lib.contains(".auth_from_env()"),
278            "an owner table generated a venture that cannot boot: {lib}"
279        );
280    }
281
282    #[test]
283    fn a_venture_whose_tables_are_all_public_does_not_wire_a_verifier() {
284        // Nothing asks who is calling, so nothing needs an auth service.
285        let manifest = VentureManifest::from_json_str(
286            &with_tables().replace(r#""note": "owner""#, r#""note": "public-read""#),
287        )
288        .expect("parses");
289        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
290        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
291            .expect("generates");
292        let lib = venture
293            .files
294            .iter()
295            .find(|f| f.path == "src/lib.rs")
296            .map(|f| f.contents.as_str())
297            .unwrap_or_default();
298        assert!(!lib.contains("auth_from_env"), "{lib}");
299    }
300
301    #[test]
302    fn a_venture_whose_tables_are_all_public_does_not_demand_a_verifier() {
303        // The other half, and the one that makes the rule a rule rather
304        // than a constant: requiring `Port::Auth` from every venture
305        // would make a deployment that serves only public reference data
306        // refuse to start until somebody wired an auth service it has no
307        // use for.
308        let tables = generated_tables_rs(
309            &with_tables().replace(r#""note": "owner""#, r#""note": "public-read""#),
310        );
311        assert!(tables.contains("&[Port::Db]"), "{tables}");
312        assert!(!tables.contains("Port::Auth"), "{tables}");
313    }
314
315    /// `src/tables.rs` as generated from a manifest's JSON.
316    fn generated_tables_rs(json: &str) -> String {
317        let manifest = VentureManifest::from_json_str(json).expect("the fixture parses");
318        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
319        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
320            .expect("generates");
321        venture
322            .files
323            .iter()
324            .find(|f| f.path == "src/tables.rs")
325            .map(|f| f.contents.clone())
326            .unwrap_or_default()
327    }
328
329    #[test]
330    fn one_table_that_is_not_public_is_enough_to_need_a_verifier() {
331        // Any, not all: a venture serving nine public tables and one
332        // `owner` table still has to be able to say who is asking.
333        let two_tables = with_tables()
334            .replace(
335                r#""note": {
336                    "primary_key": "id","#,
337                r#""tier": {
338                    "primary_key": "id",
339                    "fields": [ { "name": "id", "kind": "uuid", "required": true } ]
340                },
341                "note": {
342                    "primary_key": "id","#,
343            )
344            .replace(
345                r#""table_privacy": {"#,
346                r#""table_privacy": {
347                "tier": { "holds": "nothing", "reason": "Plan tiers; nobody is in them." },"#,
348            )
349            .replace(
350                r#""table_access": { "note": "owner" }"#,
351                r#""table_access": { "note": "owner", "tier": "public-read" }"#,
352            );
353        let tables = generated_tables_rs(&two_tables);
354        assert!(
355            tables.contains("\"tier\""),
356            "the fixture has both: {tables}"
357        );
358        assert!(tables.contains("Port::Auth"), "{tables}");
359    }
360
361    #[test]
362    fn a_venture_that_lists_no_cors_origins_still_generates_valid_rust() {
363        // `.cors_origins([])` has no inferable element type, so this
364        // generated a crate that did not compile. Found by generating a
365        // venture and running `cargo check` over it rather than by any
366        // test here: every fixture in this file lists an origin, so the
367        // empty case had never been rendered.
368        //
369        // `validate()` now refuses an empty list, so `fz build` cannot
370        // reach this — but `generate()` is a library function and this
371        // calls it directly, which is the only way the renderer's empty
372        // case stays covered. The test's name is about the Rust it
373        // renders; whether that venture would *boot* is a different
374        // question, and the answer was no.
375        // The module set comes from a manifest that is legal; the
376        // manifest handed to `generate` is then emptied of its origins,
377        // which `validate` would refuse and `generate` does not check.
378        let legal = VentureManifest::from_json_str(sample_json()).expect("parses");
379        let set = legal.resolve(&catalog::builtin()).expect("resolves");
380        let mut without = legal.clone();
381        without.cors_origins.clear();
382        assert!(
383            without.validate().is_err(),
384            "an empty origin list has to be a manifest error, or `fz build` \
385             emits a venture that panics on its first request"
386        );
387
388        let venture = generate::generate(&without, &set, &generate::HarnessSource::default())
389            .expect("generates");
390        let lib = venture
391            .files
392            .iter()
393            .find(|f| f.path == "src/lib.rs")
394            .map(|f| f.contents.as_str())
395            .unwrap_or_default();
396        assert!(!lib.contains("cors_origins"), "{lib}");
397
398        // And one that does list them still gets the call.
399        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
400        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
401        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
402            .expect("generates");
403        let lib = venture
404            .files
405            .iter()
406            .find(|f| f.path == "src/lib.rs")
407            .map(|f| f.contents.as_str())
408            .unwrap_or_default();
409        assert!(
410            lib.contains(".cors_origins([\"https://acme.example\"])"),
411            "{lib}"
412        );
413    }
414
415    #[test]
416    fn a_declared_table_must_say_who_may_reach_it() {
417        // Same argument as the privacy block: `public-read` by default
418        // publishes a venture's tables the day the CRUD layer lands, and
419        // `admin` by default makes them useless until somebody notices.
420        let base: serde_json::Value =
421            serde_json::from_str(with_tables()).expect("the fixture is JSON");
422        let mut object = base.as_object().expect("an object").clone();
423        object.remove("table_access");
424        let error = VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
425            .expect("parses")
426            .validate()
427            .expect_err("must say");
428        let text = error.to_string();
429        assert!(text.contains("does not say who may reach it"), "{text}");
430        assert!(text.contains("public-read"), "the four are listed: {text}");
431    }
432
433    #[test]
434    fn owner_needs_the_table_to_say_whose_each_row_is() {
435        // `owner` matches a caller against the column the privacy block
436        // names. On a table that holds nothing personal there is no such
437        // column, and a route falling back to "everyone" or "nobody"
438        // would be deciding that silently.
439        let base: serde_json::Value =
440            serde_json::from_str(with_tables()).expect("the fixture is JSON");
441        let mut object = base.as_object().expect("an object").clone();
442        object.insert(
443            "table_privacy".to_owned(),
444            serde_json::json!({ "note": { "holds": "nothing", "reason": "Reference data." } }),
445        );
446        let error = VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
447            .expect("parses")
448            .validate()
449            .expect_err("nothing to match a caller against");
450        let text = error.to_string();
451        assert!(
452            text.contains("no column to match a caller against"),
453            "{text}"
454        );
455
456        // The other three do not need one.
457        for level in ["public-read", "tenant-members", "admin"] {
458            let mut object = base.as_object().expect("an object").clone();
459            object.insert(
460                "table_privacy".to_owned(),
461                serde_json::json!({ "note": { "holds": "nothing", "reason": "Reference data." } }),
462            );
463            object.insert(
464                "table_access".to_owned(),
465                serde_json::json!({ "note": level }),
466            );
467            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
468                .expect("parses")
469                .validate()
470                .unwrap_or_else(|err| panic!("{level} should be legal here: {err}"));
471        }
472    }
473
474    #[test]
475    fn access_for_a_table_that_is_not_declared_is_refused() {
476        let base: serde_json::Value =
477            serde_json::from_str(with_tables()).expect("the fixture is JSON");
478        let mut object = base.as_object().expect("an object").clone();
479        object.insert(
480            "table_access".to_owned(),
481            serde_json::json!({ "note": "owner", "ghost": "admin" }),
482        );
483        let error = VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
484            .expect("parses")
485            .validate()
486            .expect_err("not a declared table");
487        assert!(
488            error.to_string().contains("not a declared table"),
489            "{error}"
490        );
491    }
492
493    #[test]
494    fn a_venture_with_no_declared_tables_gets_no_tables_module() {
495        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
496        let set = manifest.resolve(&catalog::builtin()).expect("resolves");
497        let venture = generate::generate(&manifest, &set, &generate::HarnessSource::default())
498            .expect("generates");
499        let paths: Vec<&str> = venture.files.iter().map(|f| f.path.as_str()).collect();
500        assert!(!paths.contains(&"src/tables.rs"), "{paths:?}");
501        let lib = venture
502            .files
503            .iter()
504            .find(|f| f.path == "src/lib.rs")
505            .map(|f| f.contents.as_str())
506            .unwrap_or_default();
507        assert!(!lib.contains("mod tables"), "{lib}");
508    }
509
510    /// The same manifest with the privacy block removed.
511    fn without_privacy() -> String {
512        let full: serde_json::Value =
513            serde_json::from_str(with_tables()).expect("the fixture is JSON");
514        let mut object = full.as_object().expect("an object").clone();
515        object.remove("table_privacy");
516        serde_json::Value::Object(object).to_string()
517    }
518
519    #[test]
520    fn a_declared_table_must_say_what_it_holds() {
521        // Both available defaults are wrong: "nothing personal unless you
522        // say" puts a venture's tables outside export and erasure
523        // silently, and "personal unless you say" deletes reference data
524        // the first time somebody asks. So there is no default.
525        let manifest = VentureManifest::from_json_str(&without_privacy()).expect("parses");
526        let error = manifest.validate().expect_err("must say");
527        let text = error.to_string();
528        assert!(text.contains("does not say what it holds"), "{text}");
529        assert!(text.contains("tables.note.privacy"), "{text}");
530    }
531
532    #[test]
533    fn saying_a_table_holds_nothing_is_one_line_and_needs_the_reason() {
534        let base: serde_json::Value =
535            serde_json::from_str(&without_privacy()).expect("the fixture is JSON");
536        let with = |privacy: serde_json::Value| {
537            let mut object = base.as_object().expect("an object").clone();
538            object.insert(
539                "table_privacy".to_owned(),
540                serde_json::json!({ "note": privacy }),
541            );
542            // This test is about the privacy block, so the access level
543            // is one that asks nothing of it — `owner` would need a
544            // subject column, which is a different rule's business.
545            object.insert(
546                "table_access".to_owned(),
547                serde_json::json!({ "note": "public-read" }),
548            );
549            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
550                .expect("parses")
551        };
552
553        with(serde_json::json!({
554            "holds": "nothing",
555            "reason": "One row per plan tier; nobody is named in it."
556        }))
557        .validate()
558        .expect("a reason is all it takes");
559
560        // Without the reason it is a silence wearing a decision's
561        // clothes, which is the thing this refuses.
562        let error = with(serde_json::json!({ "holds": "nothing", "reason": "  " }))
563            .validate()
564            .expect_err("an empty reason is not a reason");
565        assert!(error.to_string().contains("needs a reason"), "{error}");
566    }
567
568    #[test]
569    fn a_declaration_must_describe_columns_the_table_actually_has() {
570        // Each rule is a promise about a column, and a promise about a
571        // column that does not exist is not checkable by anything later.
572        let base: serde_json::Value =
573            serde_json::from_str(&without_privacy()).expect("the fixture is JSON");
574        let check = |privacy: serde_json::Value| -> String {
575            let mut object = base.as_object().expect("an object").clone();
576            object.insert(
577                "table_privacy".to_owned(),
578                serde_json::json!({ "note": privacy }),
579            );
580            object.insert(
581                "table_access".to_owned(),
582                serde_json::json!({ "note": "public-read" }),
583            );
584            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
585                .expect("parses")
586                .validate()
587                .expect_err("not valid")
588                .to_string()
589        };
590
591        let text = check(serde_json::json!({
592            "holds": "personal", "subject": "nobody", "kind": "content",
593            "disposition": "erase", "description": "x"
594        }));
595        assert!(text.contains("`nobody` is not a field"), "{text}");
596
597        let text = check(serde_json::json!({
598            "holds": "personal", "subject": "author", "kind": "invented",
599            "disposition": "erase", "description": "x"
600        }));
601        assert!(text.contains("is not a data kind"), "{text}");
602
603        // An empty description is published to the person asking.
604        let text = check(serde_json::json!({
605            "holds": "personal", "subject": "author", "kind": "content",
606            "disposition": "erase", "description": " "
607        }));
608        assert!(text.contains("must not be empty"), "{text}");
609    }
610
611    #[test]
612    fn a_field_that_becomes_an_identifier_cannot_carry_a_quote() {
613        // `name` is written into the generated `Cargo.toml`, into the
614        // generated `wrangler.toml` twice, and into `Venture::new` — all
615        // by interpolation, none of it escaped. A quote in it ended the
616        // literal and the rest became source:
617        //
618        //   cratefield::Venture::new("acme", evil(); Venture::new("x", …)
619        //
620        // A venture author breaking their own build is the small version.
621        // #141 and #159 are a hosted pipeline running this generator over
622        // a manifest somebody else wrote.
623        let base: serde_json::Value =
624            serde_json::from_str(sample_json()).expect("the fixture is JSON");
625        let with = |field: &str, value: serde_json::Value| {
626            let mut object = base.as_object().expect("an object").clone();
627            object.insert(field.to_owned(), value);
628            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
629                .expect("parses")
630                .validate()
631        };
632
633        for (field, value) in [
634            (
635                "name",
636                serde_json::json!("acme\", evil(); cratefield::Venture::new(\"x"),
637            ),
638            ("host", serde_json::json!("acme.test\", evil(), \"x")),
639            (
640                "public_url",
641                serde_json::json!("https://acme.test\", evil(), \"x"),
642            ),
643            (
644                "cors_origins",
645                serde_json::json!(["https://acme.test\", evil(), \"x"]),
646            ),
647        ] {
648            let error = with(field, value).expect_err(field).to_string();
649            assert!(error.contains(field), "{error}");
650            // The value is not echoed back: it is the wrong shape, which
651            // is what should not be pasted into a log or an annotation.
652            assert!(!error.contains("evil()"), "{error}");
653        }
654
655        // The other half. A check that refused everything would satisfy
656        // every assertion above and stop any venture from building.
657        with("name", serde_json::json!("acme-signups_2")).expect("an ordinary name");
658        with("host", serde_json::json!("acme.factory0.dev:8787")).expect("a host with a port");
659        with("public_url", serde_json::json!("https://acme.example")).expect("an ordinary url");
660        with(
661            "cors_origins",
662            serde_json::json!(["https://acme.example", "http://localhost:5173"]),
663        )
664        .expect("ordinary origins");
665    }
666
667    #[test]
668    fn an_origin_with_a_path_is_not_an_origin() {
669        // The rule `cratefield_core` enforces at boot. A manifest that
670        // passes here and is refused there is the failure `cors_origins`
671        // already taught once.
672        let base: serde_json::Value =
673            serde_json::from_str(sample_json()).expect("the fixture is JSON");
674        let mut object = base.as_object().expect("an object").clone();
675        object.insert(
676            "cors_origins".to_owned(),
677            serde_json::json!(["https://acme.example/app"]),
678        );
679        let error = VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
680            .expect("parses")
681            .validate()
682            .expect_err("an origin has no path")
683            .to_string();
684        assert!(error.contains("cors_origins"), "{error}");
685    }
686
687    #[test]
688    fn browser_extension_origins_are_origins() {
689        // The same three shapes `cratefield_core` accepts at boot
690        // (issue #579), so a manifest listing an extension's origin builds
691        // instead of being refused here first.
692        let with_origins = |origins: serde_json::Value| {
693            let base: serde_json::Value =
694                serde_json::from_str(sample_json()).expect("the fixture is JSON");
695            let mut object = base.as_object().expect("an object").clone();
696            object.insert("cors_origins".to_owned(), origins);
697            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
698                .expect("parses")
699                .validate()
700        };
701        with_origins(serde_json::json!([
702            "chrome-extension://abcdefghijklmnopabcdefghijklmnop",
703            "moz-extension://5de6e0f6-2b1a-4f6e-9c3d-0a1b2c3d4e5f",
704            "safari-web-extension://5DE6E0F6-2B1A-4F6E-9C3D-0A1B2C3D4E5F",
705        ]))
706        .expect("extension origins are origins");
707
708        // Rejected: a bad id, a bare scheme, a trailing slash and an
709        // unknown hyphenated scheme.
710        for bad in [
711            "chrome-extension://abcdefghijklmnopabcdefghijklmno",
712            "chrome-extension://qbcdefghijklmnopabcdefghijklmnop",
713            "moz-extension://5DE6E0F6-2B1A-4F6E-9C3D-0A1B2C3D4E5F",
714            "chrome-extension://",
715            "chrome-extension://abcdefghijklmnopabcdefghijklmnop/",
716            "foo-bar://x",
717            "null",
718        ] {
719            let error = with_origins(serde_json::json!([bad]))
720                .expect_err(bad)
721                .to_string();
722            assert!(error.contains("cors_origins"), "{bad}: {error}");
723        }
724    }
725
726    #[test]
727    fn a_subject_column_must_be_able_to_hold_a_caller_id() {
728        // A subject is a caller's id: a string out of a verified
729        // credential. Declared on a column that cannot hold one, every
730        // scoped read failed while its statement was built, and answered
731        // `400 bad-filter` with the detail "`author` the subject this
732        // read is scoped to is not integer" — blaming the caller for a
733        // filter they never sent, and naming the table's subject column,
734        // which the refusal for exactly this case says must never reach
735        // one.
736        let declare = |kind: &str| -> Result<(), ManifestError> {
737            let json = format!(
738                r#"{{
739                    "name": "acme", "host": "acme.factory0.dev",
740                    "cors_origins": ["https://acme.example"], "modules": [],
741                    "tables": {{ "note": {{ "primary_key": "id", "fields": [
742                        {{ "name": "id", "kind": "uuid", "required": true }},
743                        {{ "name": "author", "kind": "{kind}" }}
744                    ] }} }},
745                    "table_privacy": {{ "note": {{
746                        "holds": "personal", "subject": "author", "kind": "content",
747                        "disposition": "erase", "description": "Notes."
748                    }} }},
749                    "table_access": {{ "note": "owner" }}
750                }}"#
751            );
752            VentureManifest::from_json_str(&json)
753                .expect("parses")
754                .validate()
755        };
756
757        let text = declare("integer")
758            .expect_err("cannot hold an id")
759            .to_string();
760        assert!(text.contains("cannot hold a caller's id"), "{text}");
761        assert!(text.contains("`text` or `uuid`"), "{text}");
762
763        // The other half: the rule refuses a kind, not every declaration.
764        // A check that only ever refused would satisfy the assertion
765        // above and take the feature away.
766        declare("uuid").expect("a uuid holds an id");
767        declare("text").expect("so does text");
768    }
769
770    #[test]
771    fn published_prose_is_plain_text() {
772        // A description goes two places: verbatim to the person asking
773        // what a venture holds about them, and into generated Rust as a
774        // raw string literal. A lone CR makes that literal `error: bare
775        // CR not allowed in raw string`; a direction override makes it
776        // `error: unicode codepoint changing visible direction of text
777        // present in literal`, which rustc denies by default. Either way
778        // the author meets a compiler error inside a file whose first
779        // line tells them not to edit it.
780        //
781        // The direction override is worse after the build than during
782        // it: published text that reads as something other than what it
783        // says, in a privacy notice.
784        let base: serde_json::Value =
785            serde_json::from_str(&without_privacy()).expect("the fixture is JSON");
786        let check = |privacy: serde_json::Value| -> Result<(), ManifestError> {
787            let mut object = base.as_object().expect("an object").clone();
788            object.insert(
789                "table_privacy".to_owned(),
790                serde_json::json!({ "note": privacy }),
791            );
792            object.insert(
793                "table_access".to_owned(),
794                serde_json::json!({ "note": "public-read" }),
795            );
796            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
797                .expect("parses")
798                .validate()
799        };
800        let personal = |description: &str| {
801            serde_json::json!({
802                "holds": "personal", "subject": "author", "kind": "content",
803                "disposition": "erase", "description": description
804            })
805        };
806
807        let text = check(personal("Line one\rLine two."))
808            .expect_err("a bare CR")
809            .to_string();
810        assert!(text.contains("U+000D"), "{text}");
811
812        let text = check(personal("Your notes\u{202e}.txt"))
813            .expect_err("a direction override")
814            .to_string();
815        assert!(text.contains("U+202E"), "{text}");
816        assert!(text.contains("direction"), "{text}");
817
818        // A reason is prose too, and so is a retain reason.
819        let text = check(serde_json::json!({ "holds": "nothing", "reason": "Tiers\u{0}." }))
820            .expect_err("a NUL")
821            .to_string();
822        assert!(text.contains("U+0000"), "{text}");
823
824        // The other half, and the one a too-eager rule breaks: ordinary
825        // prose keeps working, punctuation and accents and line breaks
826        // included.
827        check(personal(
828            "The notes you wrote — “first” and ‘second’.\n\tWith a tab.",
829        ))
830        .expect("prose is allowed to have shape");
831    }
832
833    #[test]
834    fn anonymise_names_columns_a_database_can_actually_overwrite() {
835        // The same check `cratefield-module-privacy` makes against the
836        // applied schema rather than trusting: a `NOT NULL` column with
837        // no default has nothing to be overwritten with, and a primary
838        // key cannot move.
839        let base: serde_json::Value =
840            serde_json::from_str(&without_privacy()).expect("the fixture is JSON");
841        let check = |columns: serde_json::Value| -> String {
842            let mut object = base.as_object().expect("an object").clone();
843            object.insert(
844                "table_privacy".to_owned(),
845                serde_json::json!({ "note": {
846                    "holds": "personal", "subject": "author", "kind": "content",
847                    "disposition": { "anonymise": columns },
848                    "description": "x"
849                }}),
850            );
851            VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
852                .expect("parses")
853                .validate()
854                .map_or_else(|err| err.to_string(), |()| String::new())
855        };
856
857        assert!(
858            check(serde_json::json!(["body"])).is_empty(),
859            "an optional column is fine"
860        );
861        assert!(
862            check(serde_json::json!(["id"])).contains("required and has no default"),
863            "a required column with no default was accepted"
864        );
865        assert!(
866            check(serde_json::json!([])).contains("erases nothing"),
867            "an empty anonymise list was accepted"
868        );
869        assert!(
870            check(serde_json::json!(["ghost"])).contains("not a field"),
871            "a column that does not exist was accepted"
872        );
873    }
874
875    #[test]
876    fn privacy_for_a_table_that_is_not_declared_is_refused() {
877        let base: serde_json::Value =
878            serde_json::from_str(with_tables()).expect("the fixture is JSON");
879        let mut object = base.as_object().expect("an object").clone();
880        let mut privacy = object["table_privacy"].as_object().expect("map").clone();
881        privacy.insert(
882            "ghost".to_owned(),
883            serde_json::json!({ "holds": "nothing", "reason": "x" }),
884        );
885        object.insert(
886            "table_privacy".to_owned(),
887            serde_json::Value::Object(privacy),
888        );
889        let error = VentureManifest::from_json_str(&serde_json::Value::Object(object).to_string())
890            .expect("parses")
891            .validate()
892            .expect_err("not a declared table");
893        assert!(
894            error.to_string().contains("not a declared table"),
895            "{error}"
896        );
897    }
898
899    #[test]
900    fn a_manifest_parses_both_module_ref_forms() {
901        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
902        assert_eq!(manifest.name, "acme-signups");
903        assert_eq!(manifest.module_slugs(), vec!["waitlist", "email-signup"]);
904    }
905
906    #[test]
907    fn a_manifest_round_trips_through_json() {
908        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
909        let json = manifest.to_json_string().expect("serializes");
910        let back = VentureManifest::from_json_str(&json).expect("re-parses");
911        assert_eq!(manifest, back);
912    }
913
914    #[test]
915    fn resolution_pulls_the_closure_and_keys_order_independently() {
916        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
917        let set = manifest.resolve(&builtin()).expect("resolves");
918        assert!(set.contains("waitlist"));
919        assert!(set.contains("email-signup"));
920        // built-in modules have no deps today, so the key is the two slugs
921        // sorted — and independent of listed order.
922        assert_eq!(set.content_key(), "email-signup+waitlist");
923    }
924
925    #[test]
926    fn an_unknown_module_is_refused_with_a_clear_error() {
927        let json = r#"{ "name": "x", "host": "x.dev", "cors_origins": ["https://x.dev"], "modules": ["ghost"] }"#;
928        let manifest = VentureManifest::from_json_str(json).expect("parses");
929        let err = manifest.resolve(&builtin()).expect_err("unknown module");
930        assert!(matches!(
931            err,
932            ManifestError::Resolve(ResolveError::UnknownModule(ref s)) if s == "ghost"
933        ));
934    }
935
936    #[test]
937    fn an_empty_name_is_refused() {
938        let json = r#"{ "name": "", "host": "x.dev", "modules": [] }"#;
939        let manifest = VentureManifest::from_json_str(json).expect("parses");
940        assert_eq!(
941            manifest.validate(),
942            Err(ManifestError::MissingField("name"))
943        );
944    }
945
946    #[test]
947    fn a_duplicate_module_is_refused() {
948        let json = r#"{ "name": "x", "host": "x.dev", "cors_origins": ["https://x.dev"], "modules": ["cms", "cms"] }"#;
949        let manifest = VentureManifest::from_json_str(json).expect("parses");
950        assert_eq!(
951            manifest.validate(),
952            Err(ManifestError::DuplicateModule("cms".to_owned()))
953        );
954    }
955
956    #[test]
957    fn generation_is_deterministic() {
958        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
959        let set = manifest.resolve(&builtin()).expect("resolves");
960        let a = generate(&manifest, &set, &HarnessSource::default()).expect("generates");
961        let b = generate(&manifest, &set, &HarnessSource::default()).expect("generates");
962        assert_eq!(a, b, "the same manifest must generate byte-identical files");
963        assert_eq!(a.composition_hash, b.composition_hash);
964    }
965
966    #[test]
967    fn generation_emits_the_expected_files_and_wiring() {
968        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
969        let set = manifest.resolve(&builtin()).expect("resolves");
970        let venture = generate(&manifest, &set, &HarnessSource::default()).expect("generates");
971
972        let paths: Vec<&str> = venture.files.iter().map(|f| f.path.as_str()).collect();
973        assert!(paths.contains(&"Cargo.toml"));
974        assert!(paths.contains(&"src/lib.rs"));
975        assert!(paths.contains(&"src/fz_main.rs"));
976        assert!(paths.contains(&"wrangler.toml"));
977        assert!(paths.contains(&"seed.sql"), "seed_sql present -> seed.sql");
978
979        let file = |name: &str| {
980            venture
981                .files
982                .iter()
983                .find(|f| f.path == name)
984                .map(|f| f.contents.as_str())
985                .unwrap_or_default()
986        };
987
988        let cargo = file("Cargo.toml");
989        // both modules need a mailer -> resend feature is wired
990        assert!(cargo.contains("\"resend\""), "{cargo}");
991        assert!(cargo.contains("\"email-signup\""));
992        assert!(cargo.contains("\"waitlist\""));
993        assert!(cargo.contains("crate-type = [\"cdylib\", \"rlib\"]"));
994
995        let lib = file("src/lib.rs");
996        assert!(lib.contains("use cratefield::waitlist::Waitlist;"));
997        assert!(lib.contains("use cratefield::email_signup::EmailSignup;"));
998        assert!(lib.contains(".module(Waitlist::new())"));
999        assert!(lib.contains(".module(EmailSignup::new())"));
1000        assert!(lib.contains("default_templates()"));
1001        // The mailer wiring must move with `Resend::new`'s signature; a
1002        // generated venture that cannot compile is found here, not at deploy.
1003        assert!(lib.contains("cratefield::resend::Resend::new("));
1004        assert!(lib.contains("cratefield::cloudflare::WorkersClock"));
1005        assert!(lib.contains("cratefield::Venture::new(\"acme-signups\", \"acme.factory0.dev\")"));
1006
1007        let fz = file("src/fz_main.rs");
1008        assert!(fz.contains("cratefield_cli::main_for(acme_signups::harness);"));
1009
1010        let wrangler = file("wrangler.toml");
1011        assert!(wrangler.contains("binding = \"DB\""));
1012        assert!(wrangler.contains("[vars]"));
1013        assert!(wrangler.contains("brand = \"Acme\""));
1014    }
1015
1016    #[test]
1017    fn a_cms_only_venture_needs_no_mailer() {
1018        let json = r#"{ "name": "docs-site", "host": "docs.dev", "cors_origins": ["https://docs.dev"], "modules": ["cms"] }"#;
1019        let manifest = VentureManifest::from_json_str(json).expect("parses");
1020        let set = manifest.resolve(&builtin()).expect("resolves");
1021        let venture = generate(&manifest, &set, &HarnessSource::default()).expect("generates");
1022        let cargo = &venture.files[0].contents;
1023        assert!(
1024            !cargo.contains("\"resend\""),
1025            "cms needs no mailer: {cargo}"
1026        );
1027    }
1028
1029    /// The generated venture for `modules`, as (`Cargo.toml`, `src/lib.rs`).
1030    fn generated_for(modules: &[&str]) -> (String, String) {
1031        let json = serde_json::json!({
1032            "name": "every-module",
1033            "host": "every.dev",
1034            "cors_origins": ["https://every.dev"],
1035            "modules": modules,
1036        });
1037        let manifest = VentureManifest::from_json_str(&json.to_string()).expect("parses");
1038        let set = manifest.resolve(&builtin()).expect("resolves");
1039        let venture = generate(&manifest, &set, &HarnessSource::default()).expect("generates");
1040        let file = |name: &str| {
1041            venture
1042                .files
1043                .iter()
1044                .find(|f| f.path == name)
1045                .map(|f| f.contents.clone())
1046                .unwrap_or_default()
1047        };
1048        (file("Cargo.toml"), file("src/lib.rs"))
1049    }
1050
1051    #[test]
1052    fn every_builtin_module_generates() {
1053        // Issues #428 and #451: a module the catalog offers that the
1054        // generator cannot compose is `fz add` accepting what `fz build`
1055        // then refuses.
1056        let catalog = builtin();
1057        let slugs: Vec<&str> = catalog.modules.iter().map(|m| m.slug.as_str()).collect();
1058        for slug in &slugs {
1059            let (_, lib) = generated_for(&[*slug]);
1060            let codegen = generate::REGISTRY
1061                .iter()
1062                .find(|m| m.slug == *slug)
1063                .expect("in the registry");
1064            let module = format!(".module({}::new())", codegen.type_name);
1065            assert!(lib.contains(&module), "{slug}: {lib}");
1066        }
1067        let (_, lib) = generated_for(&slugs);
1068        for codegen in generate::REGISTRY {
1069            let module = format!(".module({}::new())", codegen.type_name);
1070            assert!(lib.contains(&module), "{lib}");
1071        }
1072    }
1073
1074    #[test]
1075    fn a_module_that_needs_push_gets_it_from_the_environment() {
1076        let (cargo, lib) = generated_for(&["notifications"]);
1077        assert!(cargo.contains("\"push-wiring\""), "{cargo}");
1078        assert!(lib.contains(".push_from_env()"), "{lib}");
1079
1080        let (cargo, lib) = generated_for(&["cms"]);
1081        assert!(!cargo.contains("push-wiring"), "{cargo}");
1082        assert!(!lib.contains("push_from_env"), "{lib}");
1083    }
1084
1085    #[test]
1086    fn a_manifest_with_privacy_resolves_against_the_builtin_catalog() {
1087        // The reported repro: `fz build` with `privacy` answered
1088        // `UnknownModule`, though the control plane's wizard offered it.
1089        let json = r#"{ "name": "x", "host": "x.dev", "cors_origins": ["https://x.dev"], "modules": ["waitlist", "privacy"] }"#;
1090        let manifest = VentureManifest::from_json_str(json).expect("parses");
1091        let set = manifest.resolve(&builtin()).expect("resolves");
1092        assert_eq!(set.content_key(), "privacy+waitlist");
1093    }
1094
1095    #[test]
1096    fn a_path_source_emits_path_deps_for_offline_builds() {
1097        let manifest = VentureManifest::from_json_str(sample_json()).expect("parses");
1098        let set = manifest.resolve(&builtin()).expect("resolves");
1099        let venture = generate(
1100            &manifest,
1101            &set,
1102            &HarnessSource::Path("/opt/harness".to_owned()),
1103        )
1104        .expect("generates");
1105        let cargo = &venture.files[0].contents;
1106        assert!(
1107            cargo.contains("path = \"/opt/harness/crates/facade\""),
1108            "{cargo}"
1109        );
1110    }
1111
1112    #[test]
1113    fn the_builtin_catalog_is_coherent() {
1114        builtin()
1115            .validate()
1116            .expect("the built-in catalog must be valid");
1117    }
1118
1119    // -- release gating (issue #142) ------------------------------------
1120
1121    fn single_entry(slug: &str, releases: Vec<ModuleRelease>) -> Catalog {
1122        Catalog {
1123            modules: vec![CatalogModule {
1124                slug: slug.to_owned(),
1125                name: slug.to_owned(),
1126                summary: String::new(),
1127                tier: Tier::Optional,
1128                depends_on: vec![],
1129                releases,
1130            }],
1131        }
1132    }
1133
1134    fn approved_pin(version: &str) -> ModuleRelease {
1135        ModuleRelease {
1136            version: version.to_owned(),
1137            digest: format!("sha256:{}", "a".repeat(64)),
1138            review: ReleaseReview::Approved {
1139                reviewer: "test-reviewer".to_owned(),
1140                reviewed_at: "2026-01-01T00:00:00Z".to_owned(),
1141            },
1142        }
1143    }
1144
1145    #[test]
1146    fn the_builtin_catalog_resolves_with_reviewed_pins() {
1147        let set = builtin()
1148            .resolve(&["waitlist"])
1149            .expect("the shipped catalog resolves");
1150        assert_eq!(set.releases().len(), set.slugs().len());
1151        let pin = set.release("waitlist").expect("waitlist is pinned");
1152        assert_eq!(pin.version, "0.1.1");
1153        // The honesty marker: seed digests are the visible placeholder
1154        // until CI stamps releases; a sanctioned build refuses them.
1155        assert!(is_placeholder_digest(&pin.digest));
1156    }
1157
1158    #[test]
1159    fn an_entry_with_no_pinned_release_is_refused() {
1160        let catalog = single_entry("blog", vec![]);
1161        assert_eq!(
1162            catalog.resolve(&["blog"]),
1163            Err(ResolveError::Unpinned("blog".to_owned()))
1164        );
1165    }
1166
1167    #[test]
1168    fn revocation_refuses_a_selection_that_resolved_minutes_earlier() {
1169        let mut catalog = single_entry("blog", vec![approved_pin("1.0.0")]);
1170        catalog
1171            .resolve(&["blog"])
1172            .expect("approved before the advisory");
1173        catalog.modules[0].releases = vec![ModuleRelease {
1174            version: "1.0.0".to_owned(),
1175            digest: format!("sha256:{}", "a".repeat(64)),
1176            review: ReleaseReview::Revoked {
1177                by: "sec-oncall".to_owned(),
1178                revoked_at: "2026-09-08T00:00:00Z".to_owned(),
1179                reason: "dependency poisoned".to_owned(),
1180            },
1181        }];
1182        let err = catalog
1183            .resolve(&["blog"])
1184            .expect_err("revoked after the advisory");
1185        assert!(matches!(
1186            &err,
1187            ResolveError::Revoked { slug, version, .. }
1188                if slug == "blog" && version == "1.0.0"
1189        ));
1190        assert!(err.to_string().contains("dependency poisoned"));
1191    }
1192
1193    #[test]
1194    fn the_manifest_propagates_a_release_refusal() {
1195        let json = r#"{ "name": "x", "host": "x.dev", "cors_origins": ["https://x.dev"], "modules": ["blog"] }"#;
1196        let manifest = VentureManifest::from_json_str(json).expect("parses");
1197        let catalog = single_entry(
1198            "blog",
1199            vec![ModuleRelease {
1200                version: "2.0.0".to_owned(),
1201                digest: format!("sha256:{}", "a".repeat(64)),
1202                review: ReleaseReview::Pending,
1203            }],
1204        );
1205        let err = manifest
1206            .resolve(&catalog)
1207            .expect_err("pending is not approved");
1208        assert!(matches!(
1209            err,
1210            ManifestError::Resolve(ResolveError::Unreviewed { ref slug, ref version })
1211                if slug == "blog" && version == "2.0.0"
1212        ));
1213    }
1214
1215    // -- provenance (issue #142) ----------------------------------------
1216
1217    fn artifact() -> GeneratedVenture {
1218        let files = vec![
1219            GeneratedFile {
1220                path: "Cargo.toml".to_owned(),
1221                contents: "[package]\nname = \"acme\"\n".to_owned(),
1222            },
1223            GeneratedFile {
1224                path: "src/lib.rs".to_owned(),
1225                contents: "// venture\n".to_owned(),
1226            },
1227        ];
1228        let composition_hash = generate::composition_hash_of(&files);
1229        GeneratedVenture {
1230            name: "acme-signups".to_owned(),
1231            modules: vec!["waitlist".to_owned()],
1232            content_key: "waitlist".to_owned(),
1233            composition_hash,
1234            files,
1235        }
1236    }
1237
1238    fn reviewed_set() -> ModuleSet {
1239        single_entry("waitlist", vec![approved_pin("1.0.0")])
1240            .resolve(&["waitlist"])
1241            .expect("resolves")
1242    }
1243
1244    fn good_env() -> BuildEnvironmentAttestation {
1245        BuildEnvironmentAttestation {
1246            sandbox_digest: format!("sha256:{}", "b".repeat(64)),
1247            disposable: true,
1248            workspace_scoped: true,
1249            network_egress: "registry-allowlist".to_owned(),
1250            deploy_credentials_withheld: true,
1251            production_key_absent: true,
1252            limits: ResourceLimits {
1253                wall_clock: "600s".to_owned(),
1254                memory: "4Gi".to_owned(),
1255                disk: "10Gi".to_owned(),
1256            },
1257        }
1258    }
1259
1260    #[test]
1261    fn provenance_round_trips_and_verifies_its_artifact() {
1262        let artifact = artifact();
1263        let record = Provenance::record(
1264            &artifact,
1265            &reviewed_set(),
1266            "test-builder",
1267            Some("2026-09-09T00:00:00Z"),
1268            None,
1269        )
1270        .expect("a local record always writes");
1271        assert_eq!(record.attestation, None);
1272        assert_eq!(record.releases.len(), 1);
1273        let back = Provenance::from_json(&record.to_json()).expect("round-trips");
1274        assert_eq!(back, record);
1275        back.verify_against(&artifact)
1276            .expect("the artifact verifies");
1277    }
1278
1279    #[test]
1280    fn a_tampered_file_fails_verification() {
1281        let artifact = artifact();
1282        let record =
1283            Provenance::record(&artifact, &reviewed_set(), "b", None, None).expect("records");
1284        let mut tampered = artifact.clone();
1285        tampered.files[1].contents = "// evil\n".to_owned();
1286        let err = record
1287            .verify_against(&tampered)
1288            .expect_err("digest must catch the edit");
1289        assert!(matches!(
1290            err,
1291            ProvenanceError::FileDigestMismatch { ref path, .. } if path == "src/lib.rs"
1292        ));
1293    }
1294
1295    #[test]
1296    fn files_swapped_in_or_out_fail_verification() {
1297        let artifact = artifact();
1298        let record =
1299            Provenance::record(&artifact, &reviewed_set(), "b", None, None).expect("records");
1300        let mut extra = artifact.clone();
1301        extra.files.push(GeneratedFile {
1302            path: "src/backdoor.rs".to_owned(),
1303            contents: String::new(),
1304        });
1305        assert!(matches!(
1306            record.verify_against(&extra),
1307            Err(ProvenanceError::FileListMismatch { .. })
1308        ));
1309    }
1310
1311    #[test]
1312    fn a_fabricated_composition_hash_fails_verification() {
1313        let artifact = artifact();
1314        let record =
1315            Provenance::record(&artifact, &reviewed_set(), "b", None, None).expect("records");
1316        let mut drifted = artifact.clone();
1317        drifted.composition_hash = "deadbeef".to_owned();
1318        assert!(matches!(
1319            record.verify_against(&drifted),
1320            Err(ProvenanceError::CompositionMismatch { .. })
1321        ));
1322    }
1323
1324    #[test]
1325    fn provenance_never_claims_a_different_venture_or_set() {
1326        let artifact = artifact();
1327        let record =
1328            Provenance::record(&artifact, &reviewed_set(), "b", None, None).expect("records");
1329        let other = GeneratedVenture {
1330            name: "someone-else".to_owned(),
1331            ..artifact.clone()
1332        };
1333        assert!(matches!(
1334            record.verify_against(&other),
1335            Err(ProvenanceError::VentureMismatch { .. })
1336        ));
1337        let other = GeneratedVenture {
1338            content_key: "cms+waitlist".to_owned(),
1339            ..artifact
1340        };
1341        assert!(matches!(
1342            record.verify_against(&other),
1343            Err(ProvenanceError::ModuleSetMismatch { .. })
1344        ));
1345    }
1346
1347    #[test]
1348    fn a_record_with_an_unknown_schema_is_rejected() {
1349        let artifact = artifact();
1350        let json = Provenance::record(&artifact, &reviewed_set(), "b", None, None)
1351            .expect("records")
1352            .to_json()
1353            .replace(PROVENANCE_SCHEMA, "fz-provenance/99");
1354        assert!(matches!(
1355            Provenance::from_json(&json),
1356            Err(ProvenanceError::BadRecord(_))
1357        ));
1358    }
1359
1360    #[test]
1361    fn an_attestation_that_fails_the_contract_is_refused_at_record_time() {
1362        let mut env = good_env();
1363        env.network_egress = "open".to_owned();
1364        env.deploy_credentials_withheld = false;
1365        let violations = env.check();
1366        assert_eq!(violations.len(), 2, "{violations:?}");
1367        assert!(matches!(
1368            Provenance::record(&artifact(), &reviewed_set(), "b", None, Some(env)),
1369            Err(ProvenanceError::Environment(BuildEnvViolation(ref v))) if v.len() == 2
1370        ));
1371        assert!(good_env().check().is_empty(), "the fixture is sound");
1372    }
1373
1374    #[test]
1375    fn a_sanctioned_build_refuses_placeholder_pins() {
1376        // The shipped pins are stamped placeholders; resolution accepts
1377        // their *shape* (local dev builds must keep working) but an
1378        // attested, sanctioned record refuses them (issue #142).
1379        let set = builtin().resolve(&["waitlist"]).expect("resolves");
1380        assert!(matches!(
1381            Provenance::record(&artifact(), &set, "fleet-a", None, Some(good_env())),
1382            Err(ProvenanceError::UnstampedPin(ref slug)) if slug == "waitlist"
1383        ));
1384    }
1385
1386    #[test]
1387    fn a_sanctioned_record_carries_a_stable_attestation_digest() {
1388        let artifact = artifact();
1389        let record = Provenance::record(
1390            &artifact,
1391            &reviewed_set(),
1392            "fleet-a",
1393            None,
1394            Some(good_env()),
1395        )
1396        .expect("a clean attestation records");
1397        record.verify_against(&artifact).expect("verifies");
1398        let env = record.attestation.as_ref().expect("attested");
1399        assert_eq!(env.attestation_digest(), good_env().attestation_digest());
1400        let back = BuildEnvironmentAttestation::from_json(&env.to_json()).expect("round-trips");
1401        assert_eq!(back.attestation_digest(), env.attestation_digest());
1402        let json = record.to_json();
1403        assert!(json.contains("\"built-at\": null"), "no clock, no lie");
1404        assert_eq!(Provenance::from_json(&json).expect("parses"), record);
1405    }
1406
1407    #[test]
1408    fn file_digests_are_content_bound_sha256_addresses() {
1409        let a = file_digest("hello");
1410        assert!(is_sha256_digest(&a));
1411        assert_ne!(a, file_digest("hellp"));
1412        assert!(!is_placeholder_digest(&a));
1413        assert!(is_placeholder_digest(&format!("sha256:{}", "0".repeat(64))));
1414    }
1415}