Skip to main content

canic_host/
canister_build.rs

1use crate::{
2    artifact_io::{
3        embed_candid_metadata, maybe_shrink_wasm_artifact, write_bytes_atomically,
4        write_gzip_artifact, write_wasm_artifact,
5    },
6    bootstrap_store::{BootstrapWasmStoreBuildOutput, build_bootstrap_wasm_store_artifact},
7    cargo_command, icp_environment_from_env,
8    release_set::{
9        canister_manifest_path, emit_root_release_set_manifest_if_ready, icp_root, workspace_root,
10    },
11    remove_optional_file, should_export_candid_artifacts,
12};
13use std::{
14    env, fs,
15    path::{Path, PathBuf},
16    process::Command,
17};
18use toml::Value as TomlValue;
19
20pub use crate::build_profile::CanisterBuildProfile;
21
22const ROOT_ROLE: &str = "root";
23const WASM_STORE_ROLE: &str = "wasm_store";
24const LOCAL_ARTIFACT_ROOT_RELATIVE: &str = ".icp/local/canisters";
25const WASM_TARGET: &str = "wasm32-unknown-unknown";
26
27///
28/// CanisterArtifactBuildOutput
29///
30
31#[derive(Clone, Debug)]
32pub struct CanisterArtifactBuildOutput {
33    pub artifact_root: PathBuf,
34    pub wasm_path: PathBuf,
35    pub wasm_gz_path: PathBuf,
36    pub did_path: PathBuf,
37    pub manifest_path: Option<PathBuf>,
38}
39
40///
41/// WorkspaceBuildContext
42///
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct WorkspaceBuildContext {
46    pub profile: String,
47    pub requested_profile: String,
48    pub network: String,
49    pub workspace_root: PathBuf,
50    pub icp_root: PathBuf,
51}
52
53impl WorkspaceBuildContext {
54    #[must_use]
55    pub fn lines(&self) -> Vec<String> {
56        let mut lines = vec![
57            "Canic build:".to_string(),
58            format!("profile: {}", self.profile),
59            format!("network: {}", self.network),
60            format!("workspace: {}", self.workspace_root.display()),
61        ];
62
63        if self.requested_profile != "unset" {
64            lines.push(format!("requested profile: {}", self.requested_profile));
65        }
66        if self.icp_root != self.workspace_root {
67            lines.push(format!("icp root: {}", self.icp_root.display()));
68        }
69
70        lines
71    }
72}
73
74// Print the current build context once per caller session so caller builds
75// stay readable without repeating root/profile diagnostics for every canister.
76pub fn print_current_workspace_build_context_once(
77    profile: CanisterBuildProfile,
78) -> Result<(), Box<dyn std::error::Error>> {
79    if let Some(context) = current_workspace_build_context_once(profile)? {
80        eprintln!("{}", context.lines().join("\n"));
81    }
82
83    Ok(())
84}
85
86// Return the current build context once per caller session.
87pub fn current_workspace_build_context_once(
88    profile: CanisterBuildProfile,
89) -> Result<Option<WorkspaceBuildContext>, Box<dyn std::error::Error>> {
90    let workspace_root = workspace_root()?;
91    let icp_root = icp_root()?;
92    let marker_dir = icp_root.join(".icp");
93    fs::create_dir_all(&marker_dir)?;
94
95    let requested_profile = env::var("CANIC_WASM_PROFILE").unwrap_or_else(|_| "unset".to_string());
96    let network = icp_environment_from_env();
97    let marker_key = icp_ancestor_process_id()
98        .or_else(parent_process_id)
99        .unwrap_or_else(std::process::id)
100        .to_string();
101    let marker_file = marker_dir.join(format!(".canic-build-context-{marker_key}"));
102
103    if marker_file.exists() {
104        return Ok(None);
105    }
106
107    fs::write(&marker_file, [])?;
108    Ok(Some(WorkspaceBuildContext {
109        profile: profile.target_dir_name().to_string(),
110        requested_profile,
111        network,
112        workspace_root,
113        icp_root,
114    }))
115}
116
117// Build one visible Canic canister artifact for the current workspace.
118pub fn build_current_workspace_canister_artifact(
119    canister_name: &str,
120    profile: CanisterBuildProfile,
121) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
122    let workspace_root = workspace_root()?;
123    let icp_root = icp_root()?;
124    build_canister_artifact(&workspace_root, &icp_root, canister_name, profile)
125}
126
127/// Copy the uncompressed artifact to the path requested by ICP custom builds.
128///
129/// ICP CLI sets `ICP_WASM_OUTPUT_PATH` for script-backed canister builds. Normal
130/// direct `canic build <role>` calls leave it unset and only write Canic's
131/// canonical `.icp/local/canisters/<role>/` artifacts.
132pub fn copy_icp_wasm_output(
133    canister_name: &str,
134    output: &CanisterArtifactBuildOutput,
135) -> Result<(), Box<dyn std::error::Error>> {
136    let Some(path) = env::var_os("ICP_WASM_OUTPUT_PATH").map(PathBuf::from) else {
137        return Ok(());
138    };
139
140    if !output.wasm_path.is_file() {
141        return Err(format!(
142            "missing ICP wasm output source for {canister_name}: {}",
143            output.wasm_path.display()
144        )
145        .into());
146    }
147
148    if let Some(parent) = path.parent() {
149        fs::create_dir_all(parent)?;
150    }
151    fs::copy(&output.wasm_path, Path::new(&path))?;
152    Ok(())
153}
154
155// Build one visible Canic canister artifact and keep the thin-root special cases.
156fn build_canister_artifact(
157    workspace_root: &Path,
158    icp_root: &Path,
159    canister_name: &str,
160    profile: CanisterBuildProfile,
161) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
162    if canister_name == WASM_STORE_ROLE {
163        return build_hidden_wasm_store_artifact(workspace_root, icp_root, profile);
164    }
165
166    let canister_manifest_path = canister_manifest_path(workspace_root, canister_name);
167    let canister_package_name = load_canister_package_name(&canister_manifest_path)?;
168    let artifact_root = icp_root
169        .join(LOCAL_ARTIFACT_ROOT_RELATIVE)
170        .join(canister_name);
171    let wasm_path = artifact_root.join(format!("{canister_name}.wasm"));
172    let wasm_gz_path = artifact_root.join(format!("{canister_name}.wasm.gz"));
173    let did_path = artifact_root.join(format!("{canister_name}.did"));
174    let require_embedded_release_artifacts = canister_name == ROOT_ROLE;
175
176    if require_embedded_release_artifacts {
177        build_bootstrap_wasm_store_artifact(workspace_root, icp_root, profile)?;
178    }
179
180    fs::create_dir_all(&artifact_root)?;
181    remove_stale_icp_candid_sidecars(&artifact_root)?;
182
183    let release_wasm_path = run_canister_build(
184        workspace_root,
185        icp_root,
186        &canister_manifest_path,
187        &canister_package_name,
188        profile,
189        require_embedded_release_artifacts,
190    )?;
191    write_wasm_artifact(&release_wasm_path, &wasm_path)?;
192    maybe_shrink_wasm_artifact(&wasm_path)?;
193
194    let network = icp_environment_from_env();
195    if should_export_candid_artifacts(&network) {
196        let debug_wasm_path = run_canister_build(
197            workspace_root,
198            icp_root,
199            &canister_manifest_path,
200            &canister_package_name,
201            CanisterBuildProfile::Debug,
202            require_embedded_release_artifacts,
203        )?;
204        extract_candid(&debug_wasm_path, &did_path)?;
205        embed_candid_metadata(&wasm_path, &did_path)?;
206    } else {
207        remove_optional_file(&did_path)?;
208    }
209    write_gzip_artifact(&wasm_path, &wasm_gz_path)?;
210
211    let manifest_path =
212        emit_root_release_set_manifest_if_ready(workspace_root, icp_root, &network)?;
213
214    Ok(CanisterArtifactBuildOutput {
215        artifact_root,
216        wasm_path,
217        wasm_gz_path,
218        did_path,
219        manifest_path,
220    })
221}
222
223// Read the real package name from one canister manifest so downstreams are not
224// forced to mirror the reference `canister_<role>` naming scheme.
225fn load_canister_package_name(manifest_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
226    let manifest_source = fs::read_to_string(manifest_path)?;
227    let manifest = toml::from_str::<TomlValue>(&manifest_source)?;
228    let package_name = manifest
229        .get("package")
230        .and_then(TomlValue::as_table)
231        .and_then(|package| package.get("name"))
232        .and_then(TomlValue::as_str)
233        .ok_or_else(|| format!("missing package.name in {}", manifest_path.display()))?;
234
235    Ok(package_name.to_string())
236}
237
238// Run one wasm-target cargo build for the requested canister manifest/profile.
239fn run_canister_build(
240    workspace_root: &Path,
241    icp_root: &Path,
242    manifest_path: &Path,
243    package_name: &str,
244    profile: CanisterBuildProfile,
245    require_embedded_release_artifacts: bool,
246) -> Result<PathBuf, Box<dyn std::error::Error>> {
247    let target_root = std::env::var_os("CARGO_TARGET_DIR")
248        .map_or_else(|| workspace_root.join("target"), PathBuf::from);
249    let mut command = cargo_command();
250    command
251        .current_dir(workspace_root)
252        .env("CARGO_TARGET_DIR", &target_root)
253        .env("CANIC_ICP_ROOT", icp_root)
254        .args([
255            "build",
256            "--manifest-path",
257            &manifest_path.display().to_string(),
258            "--target",
259            WASM_TARGET,
260        ])
261        .args(profile.cargo_args());
262
263    if require_embedded_release_artifacts {
264        command.env("CANIC_REQUIRE_EMBEDDED_RELEASE_ARTIFACTS", "1");
265    }
266
267    let output = command.output()?;
268    if !output.status.success() {
269        return Err(format!(
270            "cargo build failed for {}: {}",
271            manifest_path.display(),
272            String::from_utf8_lossy(&output.stderr)
273        )
274        .into());
275    }
276
277    Ok(target_root
278        .join(WASM_TARGET)
279        .join(profile.target_dir_name())
280        .join(format!("{}.wasm", package_name.replace('-', "_"))))
281}
282
283// Extract the service `.did` from one debug wasm so Candid stays deterministic.
284fn extract_candid(
285    debug_wasm_path: &Path,
286    did_path: &Path,
287) -> Result<(), Box<dyn std::error::Error>> {
288    let output = Command::new("candid-extractor")
289        .arg(debug_wasm_path)
290        .output()
291        .map_err(|err| {
292            format!(
293                "failed to run candid-extractor for {}: {err}",
294                debug_wasm_path.display()
295            )
296        })?;
297
298    if !output.status.success() {
299        return Err(format!(
300            "candid-extractor failed for {}: {}",
301            debug_wasm_path.display(),
302            String::from_utf8_lossy(&output.stderr)
303        )
304        .into());
305    }
306
307    write_bytes_atomically(did_path, &output.stdout)?;
308    Ok(())
309}
310
311// Remove stale ICP-generated Candid sidecars so local surface scans match the
312// extracted `<role>.did` artifact we actually ship and verify.
313fn remove_stale_icp_candid_sidecars(artifact_root: &Path) -> std::io::Result<()> {
314    for relative in [
315        "constructor.did",
316        "service.did",
317        "service.did.d.ts",
318        "service.did.js",
319    ] {
320        let path = artifact_root.join(relative);
321        match fs::remove_file(path) {
322            Ok(()) => {}
323            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
324            Err(err) => return Err(err),
325        }
326    }
327
328    Ok(())
329}
330
331// Route the implicit bootstrap store through the published public builder.
332fn build_hidden_wasm_store_artifact(
333    workspace_root: &Path,
334    icp_root: &Path,
335    profile: CanisterBuildProfile,
336) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
337    let output = build_bootstrap_wasm_store_artifact(workspace_root, icp_root, profile)?;
338    Ok(map_bootstrap_output(output))
339}
340
341// Normalize the bootstrap store builder output to the public canister-artifact shape.
342fn map_bootstrap_output(output: BootstrapWasmStoreBuildOutput) -> CanisterArtifactBuildOutput {
343    CanisterArtifactBuildOutput {
344        artifact_root: output.artifact_root,
345        wasm_path: output.wasm_path,
346        wasm_gz_path: output.wasm_gz_path,
347        did_path: output.did_path,
348        manifest_path: None,
349    }
350}
351
352// Read the current parent process id from Linux procfs when available.
353fn parent_process_id() -> Option<u32> {
354    let stat = fs::read_to_string("/proc/self/stat").ok()?;
355    parse_parent_process_id(&stat)
356}
357
358// Walk ancestor processes until the wrapping `icp` process is found.
359fn icp_ancestor_process_id() -> Option<u32> {
360    let mut pid = parent_process_id()?;
361    loop {
362        if process_comm(pid).as_deref() == Some("icp") {
363            return Some(pid);
364        }
365
366        let parent = process_parent_id(pid)?;
367        if parent == 0 || parent == pid {
368            return None;
369        }
370        pid = parent;
371    }
372}
373
374// Read one ancestor's parent process id from procfs.
375fn process_parent_id(pid: u32) -> Option<u32> {
376    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
377    parse_parent_process_id(&stat)
378}
379
380// Read one process command name from procfs.
381fn process_comm(pid: u32) -> Option<String> {
382    fs::read_to_string(format!("/proc/{pid}/comm"))
383        .ok()
384        .map(|comm| comm.trim().to_string())
385}
386
387// Parse Linux `/proc/<pid>/stat` enough to extract the parent process id.
388fn parse_parent_process_id(stat: &str) -> Option<u32> {
389    let (_, suffix) = stat.rsplit_once(") ")?;
390    let mut parts = suffix.split_whitespace();
391    let _state = parts.next()?;
392    parts.next()?.parse::<u32>().ok()
393}
394
395#[cfg(test)]
396mod tests {
397    use super::{parse_parent_process_id, remove_stale_icp_candid_sidecars};
398    use crate::test_support::temp_dir;
399    use std::fs;
400
401    #[test]
402    fn parse_parent_process_id_accepts_proc_stat_shape() {
403        let stat = "12345 (build_canister_ar) S 67890 0 0 0";
404        assert_eq!(parse_parent_process_id(stat), Some(67890));
405    }
406
407    #[test]
408    fn remove_stale_icp_candid_sidecars_keeps_primary_role_did() {
409        let temp_root = temp_dir("canic-canister-build-sidecars");
410        let _ = fs::remove_dir_all(&temp_root);
411        fs::create_dir_all(&temp_root).unwrap();
412
413        for name in [
414            "constructor.did",
415            "service.did",
416            "service.did.d.ts",
417            "service.did.js",
418            "app.did",
419        ] {
420            fs::write(temp_root.join(name), "x").unwrap();
421        }
422
423        remove_stale_icp_candid_sidecars(&temp_root).unwrap();
424
425        assert!(!temp_root.join("constructor.did").exists());
426        assert!(!temp_root.join("service.did").exists());
427        assert!(!temp_root.join("service.did.d.ts").exists());
428        assert!(!temp_root.join("service.did.js").exists());
429        assert!(temp_root.join("app.did").exists());
430
431        let _ = fs::remove_dir_all(temp_root);
432    }
433}