1use std::path::Path;
34
35use cratefield_manifest::{
36 BuildEnvViolation, BuildEnvironmentAttestation, Catalog, HarnessSource, Provenance,
37 ResourceLimits, VentureManifest, builtin, generate,
38};
39
40pub 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 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
157pub(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
174fn 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
201fn 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
212fn 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
225pub(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}