Skip to main content

canic_installer/
canister_build.rs

1use crate::{
2    bootstrap_store::{
3        BootstrapWasmStoreBuildOutput, BootstrapWasmStoreBuildProfile,
4        build_bootstrap_wasm_store_artifact,
5    },
6    cargo_command,
7    release_set::{
8        canister_manifest_path, dfx_root, emit_root_release_set_manifest_if_ready, workspace_root,
9    },
10};
11use flate2::{Compression, GzBuilder};
12use std::{
13    fs,
14    io::{Read, Write},
15    path::{Path, PathBuf},
16    process::Command,
17};
18use toml::Value as TomlValue;
19
20const ROOT_ROLE: &str = "root";
21const WASM_STORE_ROLE: &str = "wasm_store";
22const LOCAL_ARTIFACT_ROOT_RELATIVE: &str = ".dfx/local/canisters";
23const WASM_TARGET: &str = "wasm32-unknown-unknown";
24
25///
26/// CanisterBuildProfile
27///
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum CanisterBuildProfile {
31    Debug,
32    Fast,
33    Release,
34}
35
36impl CanisterBuildProfile {
37    // Resolve the current requested build profile from the explicit Canic wasm selector.
38    #[must_use]
39    pub fn current() -> Self {
40        match std::env::var("CANIC_WASM_PROFILE").ok().as_deref() {
41            Some("debug") => Self::Debug,
42            Some("fast") => Self::Fast,
43            _ => Self::Release,
44        }
45    }
46
47    // Return the cargo profile flags for one Canic canister build.
48    #[must_use]
49    pub const fn cargo_args(self) -> &'static [&'static str] {
50        match self {
51            Self::Debug => &[],
52            Self::Fast => &["--profile", "fast"],
53            Self::Release => &["--release"],
54        }
55    }
56
57    // Return the target-profile directory name for one Canic canister build.
58    #[must_use]
59    pub const fn target_dir_name(self) -> &'static str {
60        match self {
61            Self::Debug => "debug",
62            Self::Fast => "fast",
63            Self::Release => "release",
64        }
65    }
66}
67
68impl From<CanisterBuildProfile> for BootstrapWasmStoreBuildProfile {
69    // Reuse the same profile selection for the implicit bootstrap store build.
70    fn from(value: CanisterBuildProfile) -> Self {
71        match value {
72            CanisterBuildProfile::Debug => Self::Debug,
73            CanisterBuildProfile::Fast => Self::Fast,
74            CanisterBuildProfile::Release => Self::Release,
75        }
76    }
77}
78
79///
80/// CanisterArtifactBuildOutput
81///
82
83#[derive(Clone, Debug)]
84pub struct CanisterArtifactBuildOutput {
85    pub artifact_root: PathBuf,
86    pub wasm_path: PathBuf,
87    pub wasm_gz_path: PathBuf,
88    pub did_path: PathBuf,
89    pub manifest_path: Option<PathBuf>,
90}
91
92// Build one visible Canic canister artifact for the current workspace.
93pub fn build_current_workspace_canister_artifact(
94    canister_name: &str,
95    profile: CanisterBuildProfile,
96) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
97    let workspace_root = workspace_root()?;
98    let dfx_root = dfx_root()?;
99    build_canister_artifact(&workspace_root, &dfx_root, canister_name, profile)
100}
101
102// Build one visible Canic canister artifact and keep the thin-root special cases.
103pub fn build_canister_artifact(
104    workspace_root: &Path,
105    dfx_root: &Path,
106    canister_name: &str,
107    profile: CanisterBuildProfile,
108) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
109    if canister_name == WASM_STORE_ROLE {
110        return build_hidden_wasm_store_artifact(workspace_root, dfx_root, profile);
111    }
112
113    let canister_manifest_path = canister_manifest_path(workspace_root, canister_name);
114    let canister_package_name = load_canister_package_name(&canister_manifest_path)?;
115    let artifact_root = dfx_root
116        .join(LOCAL_ARTIFACT_ROOT_RELATIVE)
117        .join(canister_name);
118    let wasm_path = artifact_root.join(format!("{canister_name}.wasm"));
119    let wasm_gz_path = artifact_root.join(format!("{canister_name}.wasm.gz"));
120    let did_path = artifact_root.join(format!("{canister_name}.did"));
121    let require_embedded_release_artifacts = canister_name == ROOT_ROLE;
122
123    if require_embedded_release_artifacts {
124        build_bootstrap_wasm_store_artifact(workspace_root, dfx_root, profile.into())?;
125    }
126
127    fs::create_dir_all(&artifact_root)?;
128    remove_stale_dfx_candid_sidecars(&artifact_root)?;
129
130    let release_wasm_path = run_canister_build(
131        workspace_root,
132        dfx_root,
133        &canister_manifest_path,
134        &canister_package_name,
135        profile,
136        require_embedded_release_artifacts,
137    )?;
138    write_wasm_artifact(&release_wasm_path, &wasm_path)?;
139    maybe_shrink_wasm_artifact(&wasm_path)?;
140    write_gzip_artifact(&wasm_path, &wasm_gz_path)?;
141
142    let debug_wasm_path = run_canister_build(
143        workspace_root,
144        dfx_root,
145        &canister_manifest_path,
146        &canister_package_name,
147        CanisterBuildProfile::Debug,
148        require_embedded_release_artifacts,
149    )?;
150    extract_candid(&debug_wasm_path, &did_path)?;
151
152    let network = std::env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string());
153    let manifest_path =
154        emit_root_release_set_manifest_if_ready(workspace_root, dfx_root, &network)?;
155
156    Ok(CanisterArtifactBuildOutput {
157        artifact_root,
158        wasm_path,
159        wasm_gz_path,
160        did_path,
161        manifest_path,
162    })
163}
164
165// Read the real package name from one canister manifest so downstreams are not
166// forced to mirror the reference `canister_<role>` naming scheme.
167fn load_canister_package_name(manifest_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
168    let manifest_source = fs::read_to_string(manifest_path)?;
169    let manifest = toml::from_str::<TomlValue>(&manifest_source)?;
170    let package_name = manifest
171        .get("package")
172        .and_then(TomlValue::as_table)
173        .and_then(|package| package.get("name"))
174        .and_then(TomlValue::as_str)
175        .ok_or_else(|| format!("missing package.name in {}", manifest_path.display()))?;
176
177    Ok(package_name.to_string())
178}
179
180// Run one wasm-target cargo build for the requested canister manifest/profile.
181fn run_canister_build(
182    workspace_root: &Path,
183    dfx_root: &Path,
184    manifest_path: &Path,
185    package_name: &str,
186    profile: CanisterBuildProfile,
187    require_embedded_release_artifacts: bool,
188) -> Result<PathBuf, Box<dyn std::error::Error>> {
189    let target_root = std::env::var_os("CARGO_TARGET_DIR")
190        .map_or_else(|| workspace_root.join("target"), PathBuf::from);
191    let mut command = cargo_command();
192    command
193        .current_dir(workspace_root)
194        .env("CARGO_TARGET_DIR", &target_root)
195        .env("CANIC_DFX_ROOT", dfx_root)
196        .args([
197            "build",
198            "--manifest-path",
199            &manifest_path.display().to_string(),
200            "--target",
201            WASM_TARGET,
202        ])
203        .args(profile.cargo_args());
204
205    if require_embedded_release_artifacts {
206        command.env("CANIC_REQUIRE_EMBEDDED_RELEASE_ARTIFACTS", "1");
207    }
208
209    let output = command.output()?;
210    if !output.status.success() {
211        return Err(format!(
212            "cargo build failed for {}: {}",
213            manifest_path.display(),
214            String::from_utf8_lossy(&output.stderr)
215        )
216        .into());
217    }
218
219    Ok(target_root
220        .join(WASM_TARGET)
221        .join(profile.target_dir_name())
222        .join(format!("{}.wasm", package_name.replace('-', "_"))))
223}
224
225// Extract the service `.did` from one debug wasm so Candid stays deterministic.
226fn extract_candid(
227    debug_wasm_path: &Path,
228    did_path: &Path,
229) -> Result<(), Box<dyn std::error::Error>> {
230    let output = Command::new("candid-extractor")
231        .arg(debug_wasm_path)
232        .output()
233        .map_err(|err| {
234            format!(
235                "failed to run candid-extractor for {}: {err}",
236                debug_wasm_path.display()
237            )
238        })?;
239
240    if !output.status.success() {
241        return Err(format!(
242            "candid-extractor failed for {}: {}",
243            debug_wasm_path.display(),
244            String::from_utf8_lossy(&output.stderr)
245        )
246        .into());
247    }
248
249    write_bytes_atomically(did_path, &output.stdout)?;
250    Ok(())
251}
252
253// Remove stale DFX-generated Candid sidecars so local surface scans match the
254// extracted `<role>.did` artifact we actually ship and verify.
255fn remove_stale_dfx_candid_sidecars(artifact_root: &Path) -> std::io::Result<()> {
256    for relative in [
257        "constructor.did",
258        "service.did",
259        "service.did.d.ts",
260        "service.did.js",
261    ] {
262        let path = artifact_root.join(relative);
263        match fs::remove_file(path) {
264            Ok(()) => {}
265            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
266            Err(err) => return Err(err),
267        }
268    }
269
270    Ok(())
271}
272
273// Route the implicit bootstrap store through the published public builder.
274fn build_hidden_wasm_store_artifact(
275    workspace_root: &Path,
276    dfx_root: &Path,
277    profile: CanisterBuildProfile,
278) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
279    let output = build_bootstrap_wasm_store_artifact(workspace_root, dfx_root, profile.into())?;
280    Ok(map_bootstrap_output(output))
281}
282
283// Apply `ic-wasm shrink` when available so visible canister artifacts follow
284// the same size-reduction path as the implicit bootstrap store artifact.
285fn maybe_shrink_wasm_artifact(wasm_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
286    let shrunk_path = wasm_path.with_extension("wasm.shrunk");
287    match Command::new("ic-wasm")
288        .arg(wasm_path)
289        .arg("-o")
290        .arg(&shrunk_path)
291        .arg("shrink")
292        .status()
293    {
294        Ok(status) if status.success() => {
295            fs::rename(shrunk_path, wasm_path)?;
296        }
297        Ok(_) => {
298            let _ = fs::remove_file(shrunk_path);
299        }
300        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
301        Err(err) => {
302            return Err(format!("failed to run ic-wasm for {}: {err}", wasm_path.display()).into());
303        }
304    }
305
306    Ok(())
307}
308
309// Normalize the bootstrap store builder output to the public canister-artifact shape.
310fn map_bootstrap_output(output: BootstrapWasmStoreBuildOutput) -> CanisterArtifactBuildOutput {
311    CanisterArtifactBuildOutput {
312        artifact_root: output.artifact_root,
313        wasm_path: output.wasm_path,
314        wasm_gz_path: output.wasm_gz_path,
315        did_path: output.did_path,
316        manifest_path: None,
317    }
318}
319
320// Copy one `.wasm` artifact atomically into the DFX artifact tree.
321fn write_wasm_artifact(
322    source_path: &Path,
323    target_path: &Path,
324) -> Result<(), Box<dyn std::error::Error>> {
325    let bytes = fs::read(source_path)?;
326    write_bytes_atomically(target_path, &bytes)?;
327    Ok(())
328}
329
330// Write one deterministic `.wasm.gz` artifact with a zeroed gzip timestamp.
331fn write_gzip_artifact(
332    wasm_path: &Path,
333    wasm_gz_path: &Path,
334) -> Result<(), Box<dyn std::error::Error>> {
335    let mut wasm_bytes = Vec::new();
336    fs::File::open(wasm_path)?.read_to_end(&mut wasm_bytes)?;
337
338    let mut encoder = GzBuilder::new()
339        .mtime(0)
340        .write(Vec::new(), Compression::best());
341    encoder.write_all(&wasm_bytes)?;
342    let gz_bytes = encoder.finish()?;
343    write_bytes_atomically(wasm_gz_path, &gz_bytes)?;
344    Ok(())
345}
346
347// Persist one file through a sibling temp path so readers never observe a partial write.
348fn write_bytes_atomically(
349    target_path: &Path,
350    bytes: &[u8],
351) -> Result<(), Box<dyn std::error::Error>> {
352    let tmp_path = target_path.with_extension(format!(
353        "{}.tmp",
354        target_path
355            .extension()
356            .and_then(|extension| extension.to_str())
357            .unwrap_or_default()
358    ));
359    fs::write(&tmp_path, bytes)?;
360    fs::rename(tmp_path, target_path)?;
361    Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366    use super::remove_stale_dfx_candid_sidecars;
367    use std::fs;
368
369    #[test]
370    fn remove_stale_dfx_candid_sidecars_keeps_primary_role_did() {
371        let temp_root = std::env::temp_dir().join(format!(
372            "canic-canister-build-sidecars-{}",
373            std::process::id()
374        ));
375        let _ = fs::remove_dir_all(&temp_root);
376        fs::create_dir_all(&temp_root).unwrap();
377
378        for name in [
379            "constructor.did",
380            "service.did",
381            "service.did.d.ts",
382            "service.did.js",
383            "app.did",
384        ] {
385            fs::write(temp_root.join(name), "x").unwrap();
386        }
387
388        remove_stale_dfx_candid_sidecars(&temp_root).unwrap();
389
390        assert!(!temp_root.join("constructor.did").exists());
391        assert!(!temp_root.join("service.did").exists());
392        assert!(!temp_root.join("service.did.d.ts").exists());
393        assert!(!temp_root.join("service.did.js").exists());
394        assert!(temp_root.join("app.did").exists());
395
396        let _ = fs::remove_dir_all(temp_root);
397    }
398}