Skip to main content

cratefield_cli/
build_key.rs

1//! `fz build-key`: the artifact's content address, computed and shown
2//! outside any build (issue #59).
3//!
4//! The artifact is a pure function of the module set and their exact
5//! versions, the harness API, the rustc that will compile it and the
6//! profile it compiles under — [`cratefield_manifest::build_key()`] is
7//! the identity, this command is its inspection surface. A cache (whose
8//! physical home is a control-plane decision) looks a compiled venture
9//! up by this key; `fz build-key` exists so CI, the control plane and
10//! an operator can compute and compare the address before any cargo
11//! invocation.
12//!
13//! What is deliberately *not* an input: the venture name, host, config,
14//! seed data and the sidecar mount table. Those are per-customer
15//! deployment configuration — two customers sharing a module set share
16//! the artifact and still get separate Workers, databases and secrets,
17//! and one wasm serves customers whose sidecar mounts differ, which is
18//! what makes the cache real (issue #59, as amended).
19
20use crate::build::{load_catalog, parse};
21use cratefield_manifest::{BUILD_PROFILE, BuildKeyInputs, build_key, canonical_inputs};
22use std::path::Path;
23
24/// A computed key together with the canonical form of the inputs that
25/// produced it, so a caller (or test) can assert on the inputs, not
26/// only the hash.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct BuildKeyReport {
29    /// `sha256:<64 hex>`.
30    pub key: String,
31    /// The pretty-printed canonical input JSON.
32    pub inputs_json: String,
33}
34
35/// Computes the build key for a manifest. `rustc_version` is injected
36/// rather than probed so the computation is testable without a
37/// toolchain; [`rustc_version`] is the real probe `run` uses.
38///
39/// # Errors
40///
41/// If the manifest or catalog cannot be read, parsed, validated or
42/// resolved, or if the resolved releases carry a duplicate slug.
43pub fn compute_with(
44    manifest_path: &Path,
45    catalog_path: Option<&Path>,
46    rustc_version: &str,
47) -> Result<BuildKeyReport, String> {
48    let raw = std::fs::read_to_string(manifest_path)
49        .map_err(|err| format!("cannot read {}: {err}", manifest_path.display()))?;
50    let manifest = parse(manifest_path, &raw)?;
51    let catalog = load_catalog(catalog_path)?;
52    let module_set = manifest.resolve(&catalog).map_err(|err| err.to_string())?;
53
54    let inputs = BuildKeyInputs {
55        releases: module_set.releases().to_vec(),
56        harness_api: cratefield_core::HARNESS_API,
57        rustc_version: rustc_version.to_owned(),
58        profile: BUILD_PROFILE.to_owned(),
59    };
60    let key = build_key(&inputs).map_err(|err| err.to_string())?;
61    Ok(BuildKeyReport {
62        key,
63        inputs_json: canonical_inputs(&inputs),
64    })
65}
66
67/// The rustc version the artifact would compile under: `$RUSTC` when
68/// set (the pinned-toolchain convention every build script here
69/// follows), `rustc` otherwise. The version *string* is the input, not
70/// a parsed triple — `rustc --version` is what a human compares.
71///
72/// # Errors
73///
74/// If rustc cannot be run: an artifact address computed without
75/// knowing the compiler would silently collide across toolchains.
76pub fn rustc_version() -> Result<String, String> {
77    let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
78    let output = std::process::Command::new(&rustc)
79        .arg("--version")
80        .output()
81        .map_err(|err| {
82            format!(
83                "cannot run {rustc} --version: {err} — the key names the \
84             compiler, so it cannot be computed without one"
85            )
86        })?;
87    if !output.status.success() {
88        return Err(format!(
89            "{rustc} --version failed: {}",
90            String::from_utf8_lossy(&output.stderr).trim()
91        ));
92    }
93    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
94}
95
96/// Runs `fz build-key`. Prints the key, then the inputs that produced
97/// it. Reads nothing but the manifest (and an optional catalog); runs
98/// no cargo, writes nothing.
99///
100/// # Errors
101///
102/// See [`compute_with`] and [`rustc_version`].
103pub fn run(manifest_path: &Path, catalog_path: Option<&Path>) -> Result<(), String> {
104    let report = compute_with(manifest_path, catalog_path, &rustc_version()?)?;
105    println!("fz build-key: {}", report.key);
106    println!("{}", report.inputs_json);
107    println!();
108    println!(
109        "The sidecar mount table is not an input: two compositions differing only in \
110         HARNESS_SIDECARS share this key and the same artifact (issue #59, amended)."
111    );
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::path::PathBuf;
119
120    const RUSTC_TEST_VERSION: &str = "rustc 1.98.1 (test-fixture 2026-01-01)";
121
122    /// Writes a manifest to a temp file and returns its path. `fz
123    /// build-key` reads from disk like `fz build` does, so the test
124    /// exercises the real read path.
125    fn write_manifest(name: &str, modules: &str) -> PathBuf {
126        let path =
127            std::env::temp_dir().join(format!("fz-build-key-{}-{}.json", name, std::process::id()));
128        std::fs::write(
129            &path,
130            format!(r#"{{ "name": "acme", "host": "acme.factory0.dev", "modules": {modules} }}"#),
131        )
132        .expect("temp manifest writes");
133        path
134    }
135
136    #[test]
137    fn reordering_the_manifest_does_not_change_the_key() {
138        let a = write_manifest("order-a", r#"["waitlist", "email-signup"]"#);
139        let b = write_manifest("order-b", r#"["email-signup", "waitlist"]"#);
140        let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
141        let kb = compute_with(&b, None, RUSTC_TEST_VERSION).unwrap();
142        assert_eq!(ka, kb);
143        assert!(ka.key.starts_with("sha256:"));
144        let _ = std::fs::remove_file(a);
145        let _ = std::fs::remove_file(b);
146    }
147
148    #[test]
149    fn the_sidecar_mount_table_is_not_an_input() {
150        // The amended acceptance criterion: two compositions differing
151        // only in their sidecar mounts produce the same key. This is
152        // structural, and the test pins the structure: `compute_with`
153        // takes no mount parameter, reads no environment, and the
154        // canonical inputs it hashes name only modules, harness API,
155        // rustc and profile. `HARNESS_SIDECARS` and `fz --sidecars`
156        // feed the *deployed* harness at runtime (`crate::sidecars`),
157        // never this computation — edition 2024 makes the env var
158        // unsettable from a `forbid(unsafe_code)` test, so the
159        // guarantee is asserted where it actually lives: the input
160        // surface.
161        let manifest = write_manifest("sidecars", r#"["waitlist"]"#);
162        let first = compute_with(&manifest, None, RUSTC_TEST_VERSION).unwrap();
163        let second = compute_with(&manifest, None, RUSTC_TEST_VERSION).unwrap();
164        assert_eq!(first.key, second.key);
165        assert!(!first.inputs_json.contains("sidecar"));
166        assert!(!first.inputs_json.contains("mount"));
167        let _ = std::fs::remove_file(manifest);
168    }
169
170    #[test]
171    fn per_customer_configuration_is_not_an_input_either() {
172        // The same property on the other axis: name, host, CORS and
173        // config belong to the deployment, so two customers on the same
174        // module set share the key and the artifact while keeping
175        // separate Workers, databases and secrets.
176        let a =
177            std::env::temp_dir().join(format!("fz-build-key-cfg-a-{}.json", std::process::id()));
178        let b =
179            std::env::temp_dir().join(format!("fz-build-key-cfg-b-{}.json", std::process::id()));
180        std::fs::write(
181            &a,
182            r#"{ "name": "acme", "host": "acme.dev", "cors_origins": ["https://a.dev"], "config": { "brand": "A" }, "modules": ["waitlist"] }"#,
183        )
184        .expect("temp manifest writes");
185        std::fs::write(
186            &b,
187            r#"{ "name": "other", "host": "other.dev", "config": { "brand": "B" }, "modules": ["waitlist"] }"#,
188        )
189        .expect("temp manifest writes");
190        let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
191        let kb = compute_with(&b, None, RUSTC_TEST_VERSION).unwrap();
192        assert_eq!(ka.key, kb.key);
193        let _ = std::fs::remove_file(a);
194        let _ = std::fs::remove_file(b);
195    }
196
197    #[test]
198    fn changing_a_pin_changes_the_key() {
199        let builtin = crate::build::load_catalog(None).unwrap();
200        let version = builtin
201            .modules
202            .iter()
203            .find(|m| m.slug == "cms")
204            .unwrap()
205            .releases[0]
206            .version
207            .clone();
208
209        let a = write_manifest("pin-a", r#"["cms"]"#);
210        let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
211
212        // Same set, a different rustc — the other axis the key must move on.
213        let kb = compute_with(&a, None, "rustc 1.99.0 (other 2026-06-01)").unwrap();
214        assert_ne!(ka.key, kb.key);
215        assert!(ka.inputs_json.contains(&version));
216        let _ = std::fs::remove_file(a);
217    }
218
219    #[test]
220    fn an_unresolvable_manifest_is_refused_before_any_hashing() {
221        let ghost = write_manifest("ghost", r#"["ghost"]"#);
222        let err = compute_with(&ghost, None, RUSTC_TEST_VERSION).unwrap_err();
223        assert!(err.contains("ghost"), "names the unknown module: {err}");
224        let _ = std::fs::remove_file(ghost);
225    }
226}