canic-installer 0.30.12

Published installer and release-set tooling for Canic downstream workspaces
Documentation
//! Release-set discovery, manifest emission, and staging helpers.

use std::time::{SystemTime, UNIX_EPOCH};

mod config;
mod manifest;
mod paths;
mod stage;

pub use config::{configured_install_targets, configured_release_roles};
pub use manifest::{
    ReleaseSetEntry, RootReleaseSetManifest, emit_root_release_set_manifest,
    emit_root_release_set_manifest_if_ready, load_root_release_set_manifest,
};
pub use paths::{
    canister_manifest_path, canisters_root, config_path, dfx_root, load_root_package_version,
    load_workspace_package_version, resolve_artifact_root, root_manifest_path,
    root_release_set_manifest_path, workspace_manifest_path, workspace_root,
};
use stage::build_release_set_entry;
pub use stage::{
    dfx_call, idl_blob, idl_text, json_u64, resume_root_bootstrap, stage_root_release_set,
    wasm_hash, wasm_hash_hex,
};

#[cfg(test)]
use stage::read_release_artifact;

#[cfg(test)]
use config::configured_release_roles_from_source;

pub(super) const CANISTERS_ROOT_RELATIVE: &str = "canisters";
pub(super) const ROOT_CONFIG_FILE: &str = "canic.toml";
pub(super) const WORKSPACE_MANIFEST_RELATIVE: &str = "Cargo.toml";
pub const ROOT_RELEASE_SET_MANIFEST_FILE: &str = "root.release-set.json";
pub(super) const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
pub(super) const WASM_MAGIC: [u8; 4] = [0x00, 0x61, 0x73, 0x6d];

// Read the current host wall clock so staged manifests use a stable whole-second
// timestamp without depending on an exported root time endpoint.
pub(super) fn root_time_secs(root_canister: &str) -> Result<u64, Box<dyn std::error::Error>> {
    let _ = root_canister;
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|err| format!("system clock before unix epoch: {err}"))?;
    Ok(now.as_secs())
}

#[cfg(test)]
mod tests {
    use super::{
        canister_manifest_path, canisters_root, config_path, configured_install_targets,
        configured_release_roles_from_source, read_release_artifact, root_manifest_path,
    };
    use flate2::{Compression, write::GzEncoder};
    use std::{
        env, fs,
        io::Write,
        path::{Path, PathBuf},
        sync::{Mutex, OnceLock},
        time::{SystemTime, UNIX_EPOCH},
    };

    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    const REAL_CONFIG: &str = r#"
controllers = []
app_index = ["user_hub", "scale_hub"]

[app]
init_mode = "enabled"
[app.whitelist]

[auth.delegated_tokens]
enabled = true
ecdsa_key_name = "test_key_1"

[standards]
icrc21 = true

[subnets.prime.canisters.root]
kind = "root"

[subnets.prime.canisters.user_hub]
kind = "singleton"

[subnets.prime.canisters.scale_hub]
kind = "singleton"
"#;

    const MULTI_ROOT_CONFIG: &str = r#"
controllers = []
app_index = []

[app]
init_mode = "enabled"
[app.whitelist]

[subnets.prime.canisters.root]
kind = "root"

[subnets.secondary.canisters.root]
kind = "root"
"#;

    const NO_ROOT_CONFIG: &str = r#"
controllers = []
app_index = []

[app]
init_mode = "enabled"
[app.whitelist]

[subnets.prime.canisters.user_hub]
kind = "singleton"
"#;

    fn with_guarded_env<T>(test: impl FnOnce() -> T) -> T {
        let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
        let _guard = lock.lock().unwrap();
        test()
    }

    struct TempWorkspace {
        path: PathBuf,
    }

    impl TempWorkspace {
        fn new() -> Self {
            let unique = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_or(0, |duration| duration.as_nanos());
            let path = env::temp_dir().join(format!(
                "canic-installer-release-set-tests-{}-{unique}",
                std::process::id()
            ));
            fs::create_dir_all(&path).expect("create temp workspace");
            Self { path }
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TempWorkspace {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    #[test]
    fn configured_release_roles_filters_root_and_wasm_store() {
        let config = r#"
controllers = []
app_index = []

[app]
init_mode = "enabled"
[app.whitelist]

[subnets.prime.canisters.root]
kind = "root"

[subnets.prime.canisters.user_hub]
kind = "singleton"

[subnets.prime.canisters.scale_hub]
kind = "singleton"
"#;

        let roles = configured_release_roles_from_source(config).expect("release roles");

        assert_eq!(roles, vec!["scale_hub".to_string(), "user_hub".to_string()]);
    }

    #[test]
    fn configured_release_roles_rejects_multiple_root_subnets() {
        let err = configured_release_roles_from_source(MULTI_ROOT_CONFIG).unwrap_err();
        assert!(
            err.to_string()
                .contains("root kind must be unique globally"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn configured_release_roles_rejects_missing_root() {
        let err = configured_release_roles_from_source(NO_ROOT_CONFIG).unwrap_err();
        assert!(
            err.to_string().contains("root canister not defined"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn configured_install_targets_prefixes_root_canister() {
        let temp = TempWorkspace::new();
        let config_path = temp.path().join("canic.toml");
        fs::write(&config_path, REAL_CONFIG).expect("write config");

        let targets = configured_install_targets(&config_path, "root").expect("install targets");

        assert_eq!(
            targets,
            vec![
                "root".to_string(),
                "scale_hub".to_string(),
                "user_hub".to_string()
            ]
        );
    }

    #[test]
    fn canisters_root_follows_config_parent_when_manifest_metadata_is_unavailable() {
        with_guarded_env(|| {
            let temp = TempWorkspace::new();
            let workspace_root = temp.path();
            let config_dir = workspace_root.join("custom");
            fs::create_dir_all(&config_dir).expect("create config dir");
            let config_file = config_dir.join("override.toml");
            fs::write(&config_file, "").expect("write config");

            let previous = std::env::var_os("CANIC_CONFIG_PATH");
            unsafe {
                std::env::set_var("CANIC_CONFIG_PATH", &config_file);
            }
            let result = canisters_root(workspace_root);
            unsafe {
                if let Some(value) = previous {
                    std::env::set_var("CANIC_CONFIG_PATH", value);
                } else {
                    std::env::remove_var("CANIC_CONFIG_PATH");
                }
            }

            assert_eq!(result, config_dir);
        });
    }

    #[test]
    fn config_path_defaults_under_canisters_root() {
        with_guarded_env(|| {
            let temp = TempWorkspace::new();
            let workspace_root = temp.path();
            let canisters_dir = workspace_root.join("canisters");
            fs::create_dir_all(&canisters_dir).expect("create canisters dir");
            let expected = canisters_dir.join("canic.toml");

            let previous = std::env::var_os("CANIC_CONFIG_PATH");
            unsafe {
                std::env::remove_var("CANIC_CONFIG_PATH");
            }
            let result = config_path(workspace_root);
            unsafe {
                if let Some(value) = previous {
                    std::env::set_var("CANIC_CONFIG_PATH", value);
                }
            }

            assert_eq!(result, expected);
        });
    }

    #[test]
    fn root_manifest_path_prefers_canister_manifest_metadata() {
        let temp = TempWorkspace::new();
        let workspace_root = temp.path();
        fs::create_dir_all(workspace_root.join("canisters/root")).expect("create root dir");
        fs::write(
            workspace_root.join("Cargo.toml"),
            "[workspace]\nmembers = []\n",
        )
        .expect("write workspace manifest");
        fs::write(
            workspace_root.join("canisters/root/Cargo.toml"),
            "[package]\nname = \"canister_root\"\nversion = \"0.1.0\"\n",
        )
        .expect("write root manifest");

        assert_eq!(
            root_manifest_path(workspace_root),
            workspace_root.join("canisters/root/Cargo.toml")
        );
    }

    #[test]
    fn canister_manifest_path_prefers_canister_manifest_metadata() {
        let temp = TempWorkspace::new();
        let workspace_root = temp.path();
        fs::create_dir_all(workspace_root.join("canisters/user_hub")).expect("create user hub dir");
        fs::write(
            workspace_root.join("Cargo.toml"),
            "[workspace]\nmembers = []\n",
        )
        .expect("write workspace manifest");
        fs::write(
            workspace_root.join("canisters/user_hub/Cargo.toml"),
            "[package]\nname = \"canister_user_hub\"\nversion = \"0.1.0\"\n",
        )
        .expect("write user hub manifest");

        assert_eq!(
            canister_manifest_path(workspace_root, "user_hub"),
            workspace_root.join("canisters/user_hub/Cargo.toml")
        );
    }

    #[test]
    fn read_release_artifact_accepts_gzip_wasm() {
        let temp = TempWorkspace::new();
        let path = temp.path().join("artifact.wasm.gz");
        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
        encoder
            .write_all(&[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
            .expect("write wasm bytes");
        fs::write(&path, encoder.finish().expect("finish encoder")).expect("write artifact");

        let artifact = read_release_artifact(&path).expect("read artifact");

        assert!(!artifact.is_empty());
    }

    #[test]
    fn read_release_artifact_rejects_plain_wasm() {
        let temp = TempWorkspace::new();
        let path = temp.path().join("artifact.wasm");
        fs::write(&path, [0x00, 0x61, 0x73, 0x6d]).expect("write plain wasm");

        let err = read_release_artifact(&path).unwrap_err();

        assert!(
            err.to_string().contains("not gzip-compressed"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn read_release_artifact_rejects_non_wasm_payload() {
        let temp = TempWorkspace::new();
        let path = temp.path().join("artifact.bin.gz");
        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
        encoder.write_all(b"not wasm").expect("write payload");
        fs::write(&path, encoder.finish().expect("finish encoder")).expect("write artifact");

        let err = read_release_artifact(&path).unwrap_err();

        assert!(
            err.to_string()
                .contains("does not decompress to a wasm module"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn canister_manifest_path_falls_back_to_canisters_root() {
        let temp = TempWorkspace::new();
        let workspace_root = temp.path();
        fs::create_dir_all(workspace_root.join("canisters")).expect("create canisters dir");

        assert_eq!(
            canister_manifest_path(workspace_root, "user_hub"),
            workspace_root.join("canisters/user_hub/Cargo.toml")
        );
    }

    #[test]
    fn canisters_root_defaults_to_workspace_canisters_dir() {
        let temp = TempWorkspace::new();
        let workspace_root = temp.path();

        assert_eq!(
            canisters_root(workspace_root),
            workspace_root.join("canisters")
        );
    }

    #[test]
    fn config_path_override_is_normalized_against_workspace_root() {
        with_guarded_env(|| {
            let temp = TempWorkspace::new();
            let workspace_root = temp.path();
            let relative = Path::new("configs/canic.toml");
            let previous = std::env::var_os("CANIC_CONFIG_PATH");
            unsafe {
                std::env::set_var("CANIC_CONFIG_PATH", relative);
            }
            let result = config_path(workspace_root);
            unsafe {
                if let Some(value) = previous {
                    std::env::set_var("CANIC_CONFIG_PATH", value);
                } else {
                    std::env::remove_var("CANIC_CONFIG_PATH");
                }
            }

            assert_eq!(result, workspace_root.join(relative));
        });
    }
}