Skip to main content

canic_installer/
bootstrap_store.rs

1use crate::{
2    cargo_command,
3    release_set::{config_path, dfx_root, workspace_root},
4};
5use flate2::{Compression, GzBuilder};
6use serde::Deserialize;
7use std::{
8    fmt::Write as _,
9    fs,
10    io::{Read, Write},
11    path::{Path, PathBuf},
12    process::Command,
13};
14
15const WASM_STORE_ROLE: &str = "wasm_store";
16const WASM_STORE_ARTIFACTS_RELATIVE: &str = ".dfx/local/canisters/wasm_store";
17const GENERATED_WRAPPER_RELATIVE: &str = ".dfx/local/generated/canic-wasm-store";
18const CANONICAL_WASM_STORE_MANIFEST_RELATIVE: &str = "crates/canic-wasm-store/Cargo.toml";
19const CANONICAL_WASM_STORE_DID_FILE: &str = "wasm_store.did";
20const CANONICAL_WASM_STORE_CRATE_NAME: &str = "canister_wasm_store";
21const GENERATED_WRAPPER_PACKAGE_NAME: &str = "canic-generated-wasm-store";
22const CANIC_FAMILY_CRATES: &[&str] = &[
23    "canic-cdk",
24    "canic-control-plane",
25    "canic-core",
26    "canic-macros",
27    "canic-memory",
28];
29
30///
31/// BootstrapWasmStoreBuildProfile
32///
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum BootstrapWasmStoreBuildProfile {
36    Debug,
37    Fast,
38    Release,
39}
40
41impl BootstrapWasmStoreBuildProfile {
42    #[must_use]
43    pub fn current() -> Self {
44        match std::env::var("CANIC_WASM_PROFILE").ok().as_deref() {
45            Some("debug") => Self::Debug,
46            Some("fast") => Self::Fast,
47            _ => Self::Release,
48        }
49    }
50
51    #[must_use]
52    pub const fn cargo_args(self) -> &'static [&'static str] {
53        match self {
54            Self::Debug => &[],
55            Self::Fast => &["--profile", "fast"],
56            Self::Release => &["--release"],
57        }
58    }
59
60    #[must_use]
61    pub const fn target_dir_name(self) -> &'static str {
62        match self {
63            Self::Debug => "debug",
64            Self::Fast => "fast",
65            Self::Release => "release",
66        }
67    }
68
69    #[must_use]
70    pub const fn profile_marker(self) -> &'static str {
71        self.target_dir_name()
72    }
73}
74
75///
76/// BootstrapWasmStoreBuildOutput
77///
78
79#[derive(Clone, Debug)]
80pub struct BootstrapWasmStoreBuildOutput {
81    pub artifact_root: PathBuf,
82    pub wasm_path: PathBuf,
83    pub wasm_gz_path: PathBuf,
84    pub did_path: PathBuf,
85}
86
87#[derive(Clone, Debug)]
88struct BootstrapWasmStoreSource {
89    manifest_path: PathBuf,
90    source_root: PathBuf,
91}
92
93#[derive(Clone, Debug, Deserialize)]
94struct CargoMetadata {
95    packages: Vec<CargoMetadataPackage>,
96}
97
98#[derive(Clone, Debug, Deserialize)]
99struct CargoMetadataPackage {
100    name: String,
101    version: String,
102    manifest_path: PathBuf,
103}
104
105// Build the implicit bootstrap `wasm_store` artifact and populate the canonical
106// local DFX artifact paths for downstream/root builds.
107pub fn build_bootstrap_wasm_store_artifact(
108    workspace_root: &Path,
109    dfx_root: &Path,
110    profile: BootstrapWasmStoreBuildProfile,
111) -> Result<BootstrapWasmStoreBuildOutput, Box<dyn std::error::Error>> {
112    let source = resolve_bootstrap_wasm_store_source(workspace_root, dfx_root)?;
113    let artifact_root = dfx_root.join(WASM_STORE_ARTIFACTS_RELATIVE);
114    fs::create_dir_all(&artifact_root)?;
115
116    run_wasm_store_cargo_build(
117        workspace_root,
118        &source.manifest_path,
119        &config_path(workspace_root),
120        profile,
121    )?;
122
123    let target_root = std::env::var_os("CARGO_TARGET_DIR")
124        .map_or_else(|| workspace_root.join("target"), PathBuf::from);
125    let built_wasm_path = target_root
126        .join("wasm32-unknown-unknown")
127        .join(profile.target_dir_name())
128        .join(format!("{CANONICAL_WASM_STORE_CRATE_NAME}.wasm"));
129
130    let wasm_path = artifact_root.join(format!("{WASM_STORE_ROLE}.wasm"));
131    let wasm_gz_path = artifact_root.join(format!("{WASM_STORE_ROLE}.wasm.gz"));
132    let did_path = artifact_root.join(format!("{WASM_STORE_ROLE}.did"));
133    let profile_path = artifact_root.join(".build-profile");
134
135    fs::copy(&built_wasm_path, &wasm_path)?;
136    maybe_shrink_wasm_artifact(&wasm_path)?;
137    write_gzip_artifact(&wasm_path, &wasm_gz_path)?;
138    fs::write(profile_path, profile.profile_marker())?;
139    ensure_wasm_store_did(workspace_root, &source, profile, &did_path)?;
140
141    Ok(BootstrapWasmStoreBuildOutput {
142        artifact_root,
143        wasm_path,
144        wasm_gz_path,
145        did_path,
146    })
147}
148
149// Resolve the current workspace root and build the bootstrap `wasm_store`
150// artifact from there.
151pub fn build_current_workspace_bootstrap_wasm_store_artifact(
152    profile: BootstrapWasmStoreBuildProfile,
153) -> Result<BootstrapWasmStoreBuildOutput, Box<dyn std::error::Error>> {
154    let workspace_root = workspace_root()?;
155    let dfx_root = dfx_root()?;
156    build_bootstrap_wasm_store_artifact(&workspace_root, &dfx_root, profile)
157}
158
159// Resolve the canonical published/workspace `canic-wasm-store` source or fall
160// back to a generated wrapper when downstreams only depend on `canic`.
161fn resolve_bootstrap_wasm_store_source(
162    workspace_root: &Path,
163    dfx_root: &Path,
164) -> Result<BootstrapWasmStoreSource, Box<dyn std::error::Error>> {
165    let metadata = cargo_metadata(workspace_root)?;
166    let canic_manifest_path = metadata
167        .packages
168        .iter()
169        .find(|package| package.name == "canic")
170        .map(|package| package.manifest_path.clone())
171        .ok_or_else(|| {
172            "unable to locate resolved 'canic' package in cargo metadata; downstreams that build the implicit wasm_store must depend on 'canic'."
173                .to_string()
174        })?;
175
176    if let Some(source) = resolve_canonical_bootstrap_wasm_store_source(
177        workspace_root,
178        &metadata,
179        &canic_manifest_path,
180    ) {
181        return Ok(source);
182    }
183
184    let wrapper_root =
185        ensure_generated_wasm_store_wrapper(dfx_root, workspace_root, &canic_manifest_path)?;
186    Ok(BootstrapWasmStoreSource {
187        manifest_path: wrapper_root.join("Cargo.toml"),
188        source_root: wrapper_root.clone(),
189    })
190}
191
192// Prefer the local workspace `canic-wasm-store` crate, then a direct metadata
193// hit, then a sibling registry checkout next to the resolved `canic` source.
194fn resolve_canonical_bootstrap_wasm_store_source(
195    workspace_root: &Path,
196    metadata: &CargoMetadata,
197    canic_manifest_path: &Path,
198) -> Option<BootstrapWasmStoreSource> {
199    let workspace_manifest = workspace_root.join(CANONICAL_WASM_STORE_MANIFEST_RELATIVE);
200    if workspace_manifest.is_file() {
201        let source_root = workspace_manifest
202            .parent()
203            .expect("manifest path must have parent")
204            .to_path_buf();
205        return Some(BootstrapWasmStoreSource {
206            manifest_path: workspace_manifest,
207            source_root,
208        });
209    }
210
211    if let Some(package) = metadata
212        .packages
213        .iter()
214        .find(|package| package.name == "canic-wasm-store")
215    {
216        let source_root = package
217            .manifest_path
218            .parent()
219            .expect("manifest path must have parent")
220            .to_path_buf();
221        return Some(BootstrapWasmStoreSource {
222            manifest_path: package.manifest_path.clone(),
223            source_root,
224        });
225    }
226
227    let canic_root = canic_manifest_path
228        .parent()
229        .expect("canic manifest path must have parent");
230    let sibling_root = canic_root.parent().expect("canic root must have parent");
231    let canic_version = metadata
232        .packages
233        .iter()
234        .find(|package| package.name == "canic")
235        .map(|package| package.version.clone())
236        .unwrap_or_default();
237
238    let local_sibling = sibling_root.join("canic-wasm-store").join("Cargo.toml");
239    if local_sibling.is_file() {
240        let source_root = local_sibling
241            .parent()
242            .expect("manifest path must have parent")
243            .to_path_buf();
244        return Some(BootstrapWasmStoreSource {
245            manifest_path: local_sibling,
246            source_root,
247        });
248    }
249
250    if !canic_version.is_empty() {
251        let registry_sibling = sibling_root
252            .join(format!("canic-wasm-store-{canic_version}"))
253            .join("Cargo.toml");
254        if registry_sibling.is_file() {
255            let source_root = registry_sibling
256                .parent()
257                .expect("manifest path must have parent")
258                .to_path_buf();
259            return Some(BootstrapWasmStoreSource {
260                manifest_path: registry_sibling,
261                source_root,
262            });
263        }
264    }
265
266    None
267}
268
269// Query cargo metadata for the current downstream workspace.
270fn cargo_metadata(workspace_root: &Path) -> Result<CargoMetadata, Box<dyn std::error::Error>> {
271    let output = cargo_command()
272        .current_dir(workspace_root)
273        .args([
274            "metadata",
275            "--format-version=1",
276            "--manifest-path",
277            &workspace_root.join("Cargo.toml").display().to_string(),
278        ])
279        .output()?;
280
281    if !output.status.success() {
282        return Err(format!(
283            "cargo metadata failed: {}",
284            String::from_utf8_lossy(&output.stderr)
285        )
286        .into());
287    }
288
289    Ok(serde_json::from_slice(&output.stdout)?)
290}
291
292// Render the generated wrapper under `.dfx/local/generated/canic-wasm-store`.
293fn ensure_generated_wasm_store_wrapper(
294    dfx_root: &Path,
295    workspace_root: &Path,
296    canic_manifest_path: &Path,
297) -> Result<PathBuf, Box<dyn std::error::Error>> {
298    let wrapper_root = dfx_root.join(GENERATED_WRAPPER_RELATIVE);
299    fs::create_dir_all(wrapper_root.join("src"))?;
300
301    let canic_root = canic_manifest_path
302        .parent()
303        .expect("canic manifest path must have parent");
304    let patch_table = generated_wasm_store_wrapper_patch_table(canic_manifest_path);
305    let mut cargo_toml = format!(
306        "[package]\n\
307name = \"{GENERATED_WRAPPER_PACKAGE_NAME}\"\n\
308version = \"0.0.0\"\n\
309edition = \"2024\"\n\
310publish = false\n\n\
311[workspace]\n\n\
312[lib]\n\
313name = \"{CANONICAL_WASM_STORE_CRATE_NAME}\"\n\
314crate-type = [\"cdylib\", \"rlib\"]\n\n\
315[dependencies]\n\
316canic = {{ path = \"{}\", features = [\"control-plane\"] }}\n\
317ic-cdk = \"0.20.0\"\n\
318candid = {{ version = \"0.10\", default-features = false }}\n\n\
319[build-dependencies]\n\
320canic = {{ path = \"{}\" }}\n",
321        canic_root.display(),
322        canic_root.display()
323    );
324
325    cargo_toml.push_str(
326        "\n[profile.release]\n\
327opt-level = \"z\"\n\
328lto = true\n\
329codegen-units = 1\n\
330strip = \"symbols\"\n\
331debug = false\n\
332panic = \"abort\"\n\
333overflow-checks = false\n\
334incremental = false\n\
335\n\
336[profile.fast]\n\
337inherits = \"release\"\n\
338lto = false\n\
339codegen-units = 16\n\
340incremental = true\n",
341    );
342
343    if !patch_table.is_empty() {
344        cargo_toml.push('\n');
345        cargo_toml.push_str(&patch_table);
346    }
347
348    fs::write(wrapper_root.join("Cargo.toml"), cargo_toml)?;
349    fs::write(
350        wrapper_root.join("build.rs"),
351        "fn main() {\n    let config_path = std::env::var(\"CANIC_CONFIG_PATH\")\n        .expect(\"CANIC_CONFIG_PATH must be set for generated wasm_store wrapper\");\n\n    canic::build!(config_path);\n}\n",
352    )?;
353    fs::write(
354        wrapper_root.join("src/lib.rs"),
355        "#![allow(clippy::unused_async)]\n\ncanic::start_wasm_store!();\ncanic::cdk::export_candid_debug!();\n",
356    )?;
357
358    let workspace_lock = workspace_root.join("Cargo.lock");
359    if workspace_lock.is_file() {
360        fs::copy(workspace_lock, wrapper_root.join("Cargo.lock"))?;
361    }
362
363    Ok(wrapper_root)
364}
365
366// Generate the `[patch.crates-io]` table for sibling packaged Canic crates.
367fn generated_wasm_store_wrapper_patch_table(canic_manifest_path: &Path) -> String {
368    let canic_root = canic_manifest_path
369        .parent()
370        .expect("canic manifest path must have parent");
371    let sibling_root = canic_root.parent().expect("canic root must have parent");
372    let registry_version = registry_package_version_suffix(canic_manifest_path, "canic");
373    let mut rendered = String::new();
374
375    for crate_name in CANIC_FAMILY_CRATES {
376        let mut manifest_path = sibling_root.join(crate_name).join("Cargo.toml");
377
378        if !manifest_path.is_file() {
379            manifest_path =
380                find_versioned_sibling_manifest(sibling_root, crate_name, registry_version)
381                    .unwrap_or_default();
382        }
383
384        if !manifest_path.is_file() {
385            continue;
386        }
387
388        let crate_root = manifest_path
389            .parent()
390            .expect("manifest path must have parent");
391        let _ = writeln!(
392            rendered,
393            "{crate_name} = {{ path = \"{}\" }}",
394            crate_root.display()
395        );
396    }
397
398    if rendered.is_empty() {
399        String::new()
400    } else {
401        format!("[patch.crates-io]\n{rendered}")
402    }
403}
404
405fn registry_package_version_suffix<'a>(
406    manifest_path: &'a Path,
407    crate_name: &str,
408) -> Option<&'a str> {
409    let parent_name = manifest_path.parent()?.file_name()?.to_str()?;
410    parent_name.strip_prefix(&format!("{crate_name}-"))
411}
412
413// Locate a versioned sibling packaged crate under the same registry source root.
414fn find_versioned_sibling_manifest(
415    sibling_root: &Path,
416    crate_name: &str,
417    version_hint: Option<&str>,
418) -> Option<PathBuf> {
419    if let Some(version) = version_hint {
420        let preferred = sibling_root
421            .join(format!("{crate_name}-{version}"))
422            .join("Cargo.toml");
423        if preferred.is_file() {
424            return Some(preferred);
425        }
426    }
427
428    let mut candidates = fs::read_dir(sibling_root).ok()?;
429    while let Some(Ok(entry)) = candidates.next() {
430        let file_name = entry.file_name();
431        let file_name = file_name.to_string_lossy();
432        if !file_name.starts_with(&format!("{crate_name}-")) {
433            continue;
434        }
435
436        let manifest_path = entry.path().join("Cargo.toml");
437        if manifest_path.is_file() {
438            return Some(manifest_path);
439        }
440    }
441
442    None
443}
444
445// Build the chosen `canic-wasm-store` source/wrapper for one target profile.
446fn run_wasm_store_cargo_build(
447    workspace_root: &Path,
448    manifest_path: &Path,
449    config_path: &Path,
450    profile: BootstrapWasmStoreBuildProfile,
451) -> Result<(), Box<dyn std::error::Error>> {
452    let mut command = cargo_command();
453    command
454        .current_dir(workspace_root)
455        .env("CANIC_CONFIG_PATH", config_path)
456        .env(
457            "CARGO_TARGET_DIR",
458            std::env::var_os("CARGO_TARGET_DIR")
459                .map_or_else(|| workspace_root.join("target"), PathBuf::from),
460        )
461        .args([
462            "build",
463            "--manifest-path",
464            &manifest_path.display().to_string(),
465            "--target",
466            "wasm32-unknown-unknown",
467        ])
468        .args(profile.cargo_args());
469
470    let output = command.output()?;
471    if output.status.success() {
472        return Ok(());
473    }
474
475    Err(format!(
476        "cargo build failed for bootstrap wasm_store: {}",
477        String::from_utf8_lossy(&output.stderr)
478    )
479    .into())
480}
481
482// Copy or regenerate the `.did` file that matches the built bootstrap artifact.
483fn ensure_wasm_store_did(
484    workspace_root: &Path,
485    source: &BootstrapWasmStoreSource,
486    profile: BootstrapWasmStoreBuildProfile,
487    artifact_did_path: &Path,
488) -> Result<(), Box<dyn std::error::Error>> {
489    let source_did_path = source.source_root.join(CANONICAL_WASM_STORE_DID_FILE);
490
491    // Ordinary artifact builds must treat the checked-in bootstrap `.did` as
492    // canonical source, not as a cache file that gets rewritten on unrelated
493    // workspace changes. Regeneration is explicit.
494    if source_did_path.is_file() && !refresh_canonical_wasm_store_did_enabled() {
495        fs::copy(source_did_path, artifact_did_path)?;
496        return Ok(());
497    }
498
499    run_wasm_store_cargo_build(
500        workspace_root,
501        &source.manifest_path,
502        &config_path(workspace_root),
503        BootstrapWasmStoreBuildProfile::Debug,
504    )?;
505
506    let target_root = std::env::var_os("CARGO_TARGET_DIR")
507        .map_or_else(|| workspace_root.join("target"), PathBuf::from);
508    let debug_wasm_path = target_root
509        .join("wasm32-unknown-unknown")
510        .join(BootstrapWasmStoreBuildProfile::Debug.target_dir_name())
511        .join(format!("{CANONICAL_WASM_STORE_CRATE_NAME}.wasm"));
512    let output = Command::new("candid-extractor")
513        .arg(&debug_wasm_path)
514        .output()?;
515
516    if !output.status.success() {
517        return Err(format!(
518            "candid-extractor failed for bootstrap wasm_store: {}",
519            String::from_utf8_lossy(&output.stderr)
520        )
521        .into());
522    }
523
524    if source_did_path
525        .parent()
526        .expect("bootstrap wasm_store did path must have parent")
527        .exists()
528    {
529        fs::write(&source_did_path, &output.stdout)?;
530    }
531    fs::copy(source_did_path, artifact_did_path)?;
532    if profile == BootstrapWasmStoreBuildProfile::Debug {
533        let artifact_root = artifact_did_path
534            .parent()
535            .expect("artifact did path must have parent");
536        let wasm_path = artifact_root.join(format!("{WASM_STORE_ROLE}.wasm"));
537        let wasm_gz_path = artifact_root.join(format!("{WASM_STORE_ROLE}.wasm.gz"));
538        if wasm_path.is_file() && wasm_gz_path.is_file() {
539            fs::write(artifact_root.join(".build-profile"), "debug")?;
540        }
541    }
542
543    Ok(())
544}
545
546// Regeneration of the canonical bootstrap-store `.did` is explicit so normal
547// artifact builds do not rewrite checked-in source files as a side effect.
548fn refresh_canonical_wasm_store_did_enabled() -> bool {
549    matches!(
550        std::env::var("CANIC_REFRESH_WASM_STORE_DID")
551            .ok()
552            .as_deref(),
553        Some("1" | "true" | "TRUE" | "yes" | "YES")
554    )
555}
556
557// Apply `ic-wasm shrink` when available so the bootstrap artifact matches the
558// normal custom-build path.
559fn maybe_shrink_wasm_artifact(wasm_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
560    let shrunk_path = wasm_path.with_extension("wasm.shrunk");
561    match Command::new("ic-wasm")
562        .arg(wasm_path)
563        .arg("-o")
564        .arg(&shrunk_path)
565        .arg("shrink")
566        .status()
567    {
568        Ok(status) if status.success() => {
569            fs::rename(shrunk_path, wasm_path)?;
570        }
571        Ok(_) => {
572            let _ = fs::remove_file(shrunk_path);
573        }
574        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
575        Err(err) => return Err(err.into()),
576    }
577
578    Ok(())
579}
580
581// Write one deterministic `.wasm.gz` artifact with zeroed gzip mtime.
582fn write_gzip_artifact(
583    wasm_path: &Path,
584    wasm_gz_path: &Path,
585) -> Result<(), Box<dyn std::error::Error>> {
586    let mut wasm_bytes = Vec::new();
587    fs::File::open(wasm_path)?.read_to_end(&mut wasm_bytes)?;
588
589    let mut encoder = GzBuilder::new()
590        .mtime(0)
591        .write(Vec::new(), Compression::best());
592    encoder.write_all(&wasm_bytes)?;
593    let gz_bytes = encoder.finish()?;
594    fs::write(wasm_gz_path, gz_bytes)?;
595    Ok(())
596}