Skip to main content

cratefield_cli/
build.rs

1//! `fz build <manifest>` — the headless compile engine's front door.
2//!
3//! Parse and validate a venture manifest, resolve its module set against
4//! the catalog (the built-in one, or a release-stamped one via
5//! `--catalog`), and generate a deterministic Cloudflare venture crate
6//! (issue #138). This is standalone: it needs no compiled-in harness,
7//! because it *produces* one. It stops at source — turning the generated
8//! crate into a deployable wasm is `worker-build` and `wrangler deploy`,
9//! which the printed plan names as the next (toolchain / needs-human)
10//! steps.
11//!
12//! ## Construct, never deploy (issue #142)
13//!
14//! Construction and deployment are separate operations with separate
15//! credentials, and this command only ever constructs. It writes sources
16//! plus a `provenance.json` ([`Provenance`]) binding the artifact to the
17//! exact reviewed release pins resolution chose; it never runs wrangler,
18//! never contacts a deployment target, and refuses to *attest* a
19//! sanctioned build when the environment shows a deploy credential or
20//! any other contract gap. Wrangler remains the only holder of the path
21//! to production credentials.
22//!
23//! `sandbox_attestation_from_env` is honest about what it is: a probe
24//! of this process's environment. On the build service — whose
25//! environment the platform, not the tenant, controls — it is the
26//! machine-checkable form of the
27//! [`BuildEnvironmentAttestation`] contract, and every shortfall is a
28//! hard refusal with itemized reasons. On any other machine it proves
29//! nothing about isolation, which is exactly why a local build without
30//! the markers records `attestation: null` rather than a pass: an
31//! explicit gap beats a fake green check.
32
33use std::path::Path;
34
35use cratefield_manifest::{
36    BuildEnvViolation, BuildEnvironmentAttestation, Catalog, HarnessSource, Provenance,
37    ResourceLimits, VentureManifest, builtin, generate,
38};
39
40/// Runs `fz build`.
41///
42/// `catalog_path` selects a release-stamped catalog (issue #142); `None`
43/// uses the built-in catalog whose seed pins carry the visible
44/// placeholder digest — fine for local development, refused by any
45/// attested build. `built_at` and `builder` are recorded verbatim in
46/// provenance; the CLI owns no clock and invents nothing.
47///
48/// # Errors
49///
50/// A human-readable message if the manifest or catalog cannot be read,
51/// parsed, resolved, or written; or if an environment that claims
52/// sanctioned-build status fails the contract.
53pub fn run(
54    manifest_path: &Path,
55    out: &Path,
56    harness_path: Option<&str>,
57    catalog_path: Option<&Path>,
58    built_at: Option<&str>,
59    builder: Option<&str>,
60) -> Result<(), String> {
61    let raw = std::fs::read_to_string(manifest_path)
62        .map_err(|err| format!("cannot read {}: {err}", manifest_path.display()))?;
63    let manifest = parse(manifest_path, &raw)?;
64
65    let catalog = load_catalog(catalog_path)?;
66    let module_set = manifest.resolve(&catalog).map_err(|err| err.to_string())?;
67
68    // An environment that claims sanctioned status must pass its own
69    // contract before a single byte is written (issue #142); an
70    // unattested local build proceeds, visibly labelled.
71    let attestation = sandbox_attestation_from_env();
72    if let Some(env) = &attestation {
73        let violations = env.check();
74        if !violations.is_empty() {
75            return Err(BuildEnvViolation(violations).to_string());
76        }
77    }
78
79    let source = match harness_path {
80        Some(path) => HarnessSource::Path(path.to_owned()),
81        None => HarnessSource::default(),
82    };
83    let venture = generate(&manifest, &module_set, &source).map_err(|err| err.to_string())?;
84
85    for file in &venture.files {
86        let dest = out.join(&file.path);
87        if let Some(parent) = dest.parent() {
88            std::fs::create_dir_all(parent)
89                .map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
90        }
91        std::fs::write(&dest, &file.contents)
92            .map_err(|err| format!("cannot write {}: {err}", dest.display()))?;
93    }
94
95    let builder_identity = builder
96        .map(str::to_owned)
97        .or_else(|| std::env::var("FZ_BUILDER").ok())
98        .unwrap_or_else(|| "local".to_owned());
99    let built_at_record = built_at
100        .map(str::to_owned)
101        .or_else(|| std::env::var("FZ_BUILD_TIMESTAMP").ok());
102    let provenance = Provenance::record(
103        &venture,
104        &module_set,
105        &builder_identity,
106        built_at_record.as_deref(),
107        attestation.clone(),
108    )
109    .map_err(|err| err.to_string())?;
110    std::fs::write(out.join("provenance.json"), provenance.to_json().as_bytes())
111        .map_err(|err| format!("cannot write provenance.json: {err}"))?;
112
113    println!("fz build: {}", venture.name);
114    println!("  module set:  {}", venture.content_key);
115    println!("  modules:     {}", venture.modules.join(" -> "));
116    println!("  composition: sha256:{}", venture.composition_hash);
117    println!("  pins:");
118    for release in module_set.releases() {
119        println!(
120            "    {} @ {} {}",
121            release.slug, release.version, release.digest
122        );
123    }
124    match &provenance.attestation {
125        Some(env) => println!(
126            "  provenance:  provenance.json — attested build (environment digest {})",
127            env.attestation_digest()
128        ),
129        None => println!(
130            "  provenance:  provenance.json — local build, unattested (no \
131             FZ_BUILD_SANDBOX_DIGEST). A sanctioned build must satisfy the \
132             BuildEnvironmentAttestation contract and pin stamped digests."
133        ),
134    }
135    println!(
136        "  wrote {} files to {}:",
137        venture.files.len(),
138        out.display()
139    );
140    for file in &venture.files {
141        println!("    {}", file.path);
142    }
143    println!("    provenance.json");
144    println!();
145    println!("next:");
146    println!(
147        "  1. cd {} && cargo run --bin fz -- migrations collect   # collect D1 migrations",
148        out.display()
149    );
150    println!("  2. worker-build --release   # compile the venture to wasm (needs the toolchain)");
151    println!(
152        "  3. wrangler deploy          # stand up the Worker + D1 (needs Cloudflare creds — needs-human)"
153    );
154    Ok(())
155}
156
157/// The catalog to resolve against. `--catalog` points at a JSON the
158/// release process stamped with real digests (issue #142); without it
159/// the built-in catalog serves development. A supplied catalog must be
160/// coherent — resolution would validate anyway, and failing here gives
161/// the operator the data bug before the compile.
162pub(crate) fn load_catalog(path: Option<&Path>) -> Result<Catalog, String> {
163    let Some(path) = path else {
164        return Ok(builtin());
165    };
166    let raw = std::fs::read_to_string(path)
167        .map_err(|err| format!("cannot read {}: {err}", path.display()))?;
168    let catalog: Catalog = serde_json::from_str(&raw)
169        .map_err(|err| format!("invalid catalog {}: {err}", path.display()))?;
170    catalog.validate().map_err(|err| err.to_string())?;
171    Ok(catalog)
172}
173
174/// The build-service environment probe (issue #142). Absent
175/// `FZ_BUILD_SANDBOX_DIGEST` this is an unattested local build and
176/// provenance records that honestly (`attestation: null`). Present
177/// means "this is the service": `FZ_BUILD_SANDBOX=disposable`,
178/// `FZ_BUILD_ISOLATION=workspace-scoped`, `FZ_BUILD_NETWORK` one of
179/// `deny-all`/`registry-allowlist`, `FZ_BUILD_SECRETS=production-absent`,
180/// `FZ_BUILD_LIMITS="600s,4Gi,10Gi"`, and no deploy credential visible
181/// to the process — each checked by
182/// [`BuildEnvironmentAttestation::check`], every gap an itemized
183/// refusal.
184fn sandbox_attestation_from_env() -> Option<BuildEnvironmentAttestation> {
185    let sandbox_digest = std::env::var("FZ_BUILD_SANDBOX_DIGEST").ok()?;
186    Some(BuildEnvironmentAttestation {
187        sandbox_digest,
188        disposable: env_is("FZ_BUILD_SANDBOX", "disposable"),
189        workspace_scoped: env_is("FZ_BUILD_ISOLATION", "workspace-scoped"),
190        network_egress: std::env::var("FZ_BUILD_NETWORK").unwrap_or_default(),
191        deploy_credentials_withheld: !deploy_credentials_present(),
192        production_key_absent: env_is("FZ_BUILD_SECRETS", "production-absent"),
193        limits: parse_limits(&std::env::var("FZ_BUILD_LIMITS").unwrap_or_default()),
194    })
195}
196
197fn env_is(key: &str, expected: &str) -> bool {
198    std::env::var(key).is_ok_and(|value| value == expected)
199}
200
201/// A build that can see a deploy credential must not attest one was
202/// withheld (issue #142). `FZ_BUILD_HAS_DEPLOY_CREDENTIALS=1` is the
203/// explicit override for substrates that inject credentials under names
204/// this list does not know.
205fn deploy_credentials_present() -> bool {
206    std::env::var_os("CLOUDFLARE_API_TOKEN").is_some()
207        || std::env::var_os("CF_API_TOKEN").is_some()
208        || std::env::var_os("WRANGLER_API_TOKEN").is_some()
209        || std::env::var("FZ_BUILD_HAS_DEPLOY_CREDENTIALS").is_ok_and(|v| v == "1")
210}
211
212/// `FZ_BUILD_LIMITS` is positional: `"wall-clock,memory,disk"`, in
213/// whatever units the enforcing substrate uses. Missing parts parse to
214/// empty and fail [`BuildEnvironmentAttestation::check`] as missing
215/// limits (issue #142).
216fn parse_limits(raw: &str) -> ResourceLimits {
217    let mut parts = raw.split(',').map(str::trim);
218    ResourceLimits {
219        wall_clock: parts.next().unwrap_or("").to_owned(),
220        memory: parts.next().unwrap_or("").to_owned(),
221        disk: parts.next().unwrap_or("").to_owned(),
222    }
223}
224
225/// Parse a manifest from JSON or TOML, chosen by file extension. The
226/// manifest crate itself is JSON/serde-only (so it compiles to wasm for the
227/// compose engine); TOML is decoded here into the same struct.
228pub(crate) fn parse(path: &Path, raw: &str) -> Result<VentureManifest, String> {
229    match path.extension().and_then(|ext| ext.to_str()) {
230        Some("toml") => toml::from_str(raw).map_err(|err| format!("invalid TOML manifest: {err}")),
231        _ => VentureManifest::from_json_str(raw).map_err(|err| err.to_string()),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn limits_are_positional_with_room_for_gaps() {
241        let limits = parse_limits("600s, 4Gi ,10Gi");
242        assert_eq!(limits.wall_clock, "600s");
243        assert_eq!(limits.memory, "4Gi");
244        assert_eq!(limits.disk, "10Gi");
245        let sparse = parse_limits("");
246        assert!(sparse.wall_clock.is_empty());
247        let partial = parse_limits("600s");
248        assert_eq!(partial.wall_clock, "600s");
249        assert!(partial.memory.is_empty());
250    }
251
252    #[test]
253    fn sparse_limits_fail_the_contract() {
254        let env = BuildEnvironmentAttestation {
255            sandbox_digest: format!("sha256:{}", "b".repeat(64)),
256            disposable: true,
257            workspace_scoped: true,
258            network_egress: "deny-all".to_owned(),
259            deploy_credentials_withheld: true,
260            production_key_absent: true,
261            limits: parse_limits("600s"),
262        };
263        let violations = env.check();
264        assert_eq!(violations.len(), 2, "{violations:?}");
265        assert!(violations.contains(&"no memory limit declared"));
266        assert!(violations.contains(&"no disk limit declared"));
267    }
268}