ic-build 0.2.4

Cargo and Wasm builds, exact inputs and retained artifact caches
Documentation
//! Named examples share Cargo identity/output selection while callers own execution.

mod support;

use ic_build::artifacts::{
    CapturedArtifact, InputEvidence, InputEvidenceError, InputEvidenceSpec,
    ResolvedCargoBuildInputs, WasmBuildError, WasmBuildSpec, build_wasm_canisters_cached,
    discover_cargo_depfile_inputs, resolve_cargo_build_inputs,
};
use ic_host_process::tool::{OutputLimit, OutputLimits, capture_command};
use std::{fs, path::Path, process::Command, time::Duration};

fn workspace() -> std::path::PathBuf {
    let root = support::unique_temp_directory("cargo-example");
    let workspace = root.join("workspace");
    fs::create_dir_all(workspace.join("src")).unwrap();
    fs::create_dir(workspace.join(".cargo")).unwrap();
    fs::write(
        workspace.join(".cargo/config.toml"),
        "[build]\ntarget-dir=\"configured\"\n",
    )
    .unwrap();
    fs::create_dir_all(workspace.join("examples")).unwrap();
    fs::write(workspace.join("Cargo.toml"), concat!(
        "[package]\nname=\"example-fixture\"\nversion=\"0.0.0\"\nedition=\"2024\"\n[workspace]\n",
        "[lib]\ncrate-type=[\"cdylib\"]\n[features]\nprobe=[]\n",
        "[[example]]\nname=\"probe\"\ncrate-type=[\"cdylib\"]\nrequired-features=[\"probe\"]\n",
        "[[example]]\nname=\"other-probe\"\ncrate-type=[\"cdylib\"]\n",
    )).unwrap();
    fs::write(
        workspace.join("src/lib.rs"),
        "#[unsafe(no_mangle)] pub extern \"C\" fn library() {}\n",
    )
    .unwrap();
    fs::write(
        workspace.join("examples/probe.rs"),
        "#[unsafe(no_mangle)] pub extern \"C\" fn probe() {}\n",
    )
    .unwrap();
    fs::write(
        workspace.join("examples/other-probe.rs"),
        "#[unsafe(no_mangle)] pub extern \"C\" fn other() {}\n",
    )
    .unwrap();
    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    assert!(
        Command::new(cargo)
            .current_dir(&workspace)
            .args(["generate-lockfile", "--offline"])
            .status()
            .unwrap()
            .success()
    );
    root
}

fn recipe(root: &Path) -> WasmBuildSpec {
    let workspace = root.join("workspace");
    WasmBuildSpec::new(
        &workspace,
        &workspace.join("target/cargo"),
        &["example-fixture"],
        "debug",
    )
    .with_example("probe")
    .with_cargo_profile_args(["--features", "probe", "--locked", "--offline"])
}

fn evidence(inputs: &ResolvedCargoBuildInputs) -> InputEvidenceSpec {
    let spec = inputs.inputs().iter().fold(
        InputEvidenceSpec::new(b"caller-owned-recipe".as_slice()),
        |spec, input| spec.with_input(input.path()),
    );
    inputs
        .exclusions()
        .iter()
        .fold(spec, InputEvidenceSpec::with_excluded_root)
}

fn execute(spec: &WasmBuildSpec) {
    let mut command = spec.cargo_build_command().unwrap();
    // An external caller keeps its deadline, output budgets and success admission.
    capture_command(
        &mut command,
        OutputLimits {
            stdout: OutputLimit::Terminate(1024 * 1024),
            stderr: OutputLimit::Terminate(1024 * 1024),
            timeout: Some(Duration::from_secs(600)),
        },
    )
    .unwrap()
    .require_success()
    .unwrap();
}

#[test]
fn named_examples_build_and_reuse_without_aliasing_library_or_other_example() {
    for shared in [false, true] {
        let root = workspace();
        let mut spec = recipe(&root).with_isolated_builds();
        if shared {
            spec = spec.with_shared_incremental_target(root.join("workspace/target/shared"));
        }
        assert_eq!(spec.example(), Some("probe"));
        let inputs = resolve_cargo_build_inputs(&spec).unwrap();
        assert_eq!(
            inputs.artifact_paths().unwrap(),
            &[
                root.join(
                    "workspace/target/cargo/wasm32-unknown-unknown/debug/examples/probe.wasm"
                )
            ]
        );
        let first = build_wasm_canisters_cached(&spec).unwrap();
        let bytes = fs::read(&first.record().artifacts()[0]).unwrap();
        assert!(bytes.starts_with(b"\0asm"));
        assert!(first.record().artifacts()[0].ends_with("debug/examples/probe.wasm"));
        assert!(build_wasm_canisters_cached(&spec).unwrap().is_reused());
        let other = build_wasm_canisters_cached(&spec.clone().with_example("other-probe")).unwrap();
        assert_ne!(first.record().fingerprint(), other.record().fingerprint());
        assert!(other.record().artifacts()[0].ends_with("debug/examples/other_probe.wasm"));
        let library = WasmBuildSpec::new(
            &root.join("workspace"),
            &root.join("workspace/target/cargo"),
            &["example-fixture"],
            "debug",
        )
        .with_cargo_profile_args(["--features", "probe", "--locked", "--offline"])
        .with_isolated_builds();
        let library = build_wasm_canisters_cached(&library).unwrap();
        assert_ne!(first.record().fingerprint(), library.record().fingerprint());
        let manifest = root.join("workspace/Cargo.toml");
        let original = fs::read_to_string(&manifest).unwrap();
        fs::write(
            &manifest,
            original.replace(
                "crate-type=[\"cdylib\"]\nrequired-features",
                "crate-type=[\"rlib\"]\nrequired-features",
            ),
        )
        .unwrap();
        assert!(
            resolve_cargo_build_inputs(&spec)
                .unwrap()
                .artifact_paths()
                .is_err()
        );
        assert!(matches!(
            build_wasm_canisters_cached(&spec),
            Err(WasmBuildError::InvalidSpec { .. })
        ));
        assert_eq!(fs::read(&first.record().artifacts()[0]).unwrap(), bytes);
        drop((first, other, library));
        fs::remove_dir_all(root).unwrap();
    }
}

#[test]
fn external_example_execution_rechecks_inputs_before_capturing_exact_bytes() {
    let root = workspace();
    let spec = recipe(&root);
    let inputs = resolve_cargo_build_inputs(&spec).unwrap();
    let before = InputEvidence::capture(&evidence(&inputs)).unwrap();
    execute(&spec);
    assert!(inputs.is_current(&spec).unwrap());
    let after =
        InputEvidence::capture(&evidence(&resolve_cargo_build_inputs(&spec).unwrap())).unwrap();
    before.validate_after(&after).unwrap();
    let output = &inputs.artifact_paths().unwrap()[0];
    let bytes = fs::read(output).unwrap();
    fs::create_dir(root.join("retained")).unwrap();
    let capture = CapturedArtifact::capture(output, &root.join("retained"), bytes.len()).unwrap();
    fs::write(output, b"overwritten mutable Cargo output").unwrap();
    assert_eq!(fs::read(capture.path()).unwrap(), bytes);
    assert!(!root.join("workspace/target/cargo/.ic-testkit").exists());
    assert!(!root.join("workspace/configured").exists());
    fs::write(root.join("workspace/examples/probe.rs"), "changed source").unwrap();
    assert!(!inputs.is_content_current().unwrap());
    let after = InputEvidence::capture(&evidence(&inputs)).unwrap();
    assert!(matches!(
        before.validate_after(&after),
        Err(InputEvidenceError::ChangedInput { .. })
    ));
    drop(capture);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn late_example_depfile_inputs_require_prebuild_authority() {
    for predeclared in [false, true] {
        let root = workspace();
        let external = root.join("external.rs");
        fs::write(
            &external,
            "#[unsafe(no_mangle)] pub extern \"C\" fn external() {}\n",
        )
        .unwrap();
        fs::write(
            root.join("workspace/examples/probe.rs"),
            "include!(\"../../external.rs\");\n",
        )
        .unwrap();
        let mut spec = recipe(&root);
        if predeclared {
            spec = spec.with_additional_inputs([&external]);
        }
        let inputs = resolve_cargo_build_inputs(&spec).unwrap();
        let before = InputEvidence::capture(&evidence(&inputs)).unwrap();
        execute(&spec);
        let target = root.join("workspace/target/cargo");
        let paths = discover_cargo_depfile_inputs(
            &root.join("workspace"),
            &[target.join("wasm32-unknown-unknown/debug/examples")],
            std::slice::from_ref(&target),
            64 * 1024,
        )
        .unwrap();
        assert!(paths.contains(&external));
        let after_spec = paths
            .iter()
            .fold(evidence(&inputs), InputEvidenceSpec::with_input);
        let after = InputEvidence::capture(&after_spec).unwrap();
        if predeclared {
            before.validate_after(&after).unwrap();
            fs::remove_file(&external).unwrap();
            assert!(before.validate_after(&after).is_err());
        } else {
            assert!(
                matches!(before.validate_after(&after), Err(InputEvidenceError::UnobservedInput { path }) if path == external)
            );
        }
        fs::remove_dir_all(root).unwrap();
    }
}

#[test]
fn unsafe_or_conflicting_example_selectors_fail_before_dispatch() {
    let root = workspace();
    let spec = recipe(&root);
    for name in ["", ".", "..", "../probe", "/probe"] {
        assert!(
            spec.clone()
                .with_example(name)
                .cargo_build_command()
                .is_err()
        );
    }
    for arguments in [
        vec!["--lib"],
        vec!["--example", "other"],
        vec!["--bin=probe"],
    ] {
        assert!(
            spec.clone()
                .with_cargo_profile_args(arguments)
                .cargo_build_command()
                .is_err()
        );
    }
    let multi = WasmBuildSpec::new(
        &root.join("workspace"),
        &root.join("target"),
        &["one", "two"],
        "debug",
    )
    .with_example("probe");
    assert!(multi.cargo_build_command().is_err());
    assert!(
        resolve_cargo_build_inputs(&spec.with_example("missing"))
            .unwrap()
            .artifact_paths()
            .is_err()
    );
    fs::remove_dir_all(root).unwrap();
}

#[cfg(unix)]
#[test]
fn external_command_keeps_caller_failure_deadline_and_cancellation_policy() {
    use ic_host_process::{
        child::OwnedChild,
        tool::{ExecutionFailure, SuccessfulExit, ToolError, communicate_child},
    };
    use std::process::Stdio;
    let root = workspace();
    let program = root.join("cargo-script");
    support::executable::write_executable_script(
        &program,
        "#!/bin/sh\nprintf diagnostic >&2\nprintf evidence\nexit 37\n",
    );
    let spec = recipe(&root).with_cargo_program(&program);
    let limits = OutputLimits {
        stdout: OutputLimit::Terminate(1024),
        stderr: OutputLimit::Terminate(1024),
        timeout: Some(Duration::from_secs(10)),
    };
    let error = capture_command(&mut spec.cargo_build_command().unwrap(), limits)
        .unwrap()
        .require_success()
        .unwrap_err();
    let ToolError::Execution(error) = error else {
        panic!("expected process evidence")
    };
    assert!(matches!(error.failure, ExecutionFailure::ExitStatus));
    assert_eq!(error.evidence.status.unwrap().code(), Some(37));
    assert_eq!(error.evidence.stdout, b"evidence");
    assert_eq!(error.evidence.stderr, b"diagnostic");
    support::executable::write_executable_script(
        &program,
        "#!/bin/sh\nprintf diagnostic >&2\nexec sleep 30\n",
    );
    let error = capture_command(
        &mut spec.cargo_build_command().unwrap(),
        OutputLimits {
            timeout: Some(Duration::from_millis(100)),
            ..limits
        },
    )
    .unwrap_err();
    let ToolError::Execution(error) = error else {
        panic!("expected process evidence")
    };
    assert!(matches!(error.failure, ExecutionFailure::TimedOut));
    assert_eq!(error.evidence.stderr, b"diagnostic");
    let mut command = spec.cargo_build_command().unwrap();
    command
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = OwnedChild::spawn(&mut command).unwrap();
    let error =
        communicate_child(&mut child, None, limits, SuccessfulExit::Cleanup, || true).unwrap_err();
    let ToolError::Execution(error) = error else {
        panic!("expected cancellation evidence")
    };
    assert!(matches!(error.failure, ExecutionFailure::Cancelled));
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn prepared_metadata_sharing_preserves_distinct_example_selections() {
    use ic_build::artifacts::{LabeledWasmBuildSpec, WasmBuildBatchConfig, WasmBuildInputSnapshot};
    let root = workspace();
    let first = recipe(&root).with_isolated_builds();
    let second = first.clone().with_example("other-probe");
    let guard = std::sync::Mutex::new(());
    let guard = guard.lock().unwrap();
    let snapshot = WasmBuildInputSnapshot::prepare_assuming_sources_immutable(
        &guard,
        &[first.clone(), second.clone()],
    )
    .unwrap();
    assert_eq!(snapshot.metrics().input_resolution_runs(), 1);
    let recipes = [
        LabeledWasmBuildSpec::new("probe", first),
        LabeledWasmBuildSpec::new("other", second),
    ];
    let cold = snapshot
        .build_batch(&recipes, WasmBuildBatchConfig::new())
        .unwrap();
    assert!(cold.is_success());
    let identities: Vec<_> = cold
        .outcomes()
        .map(|entry| entry.outcome().record().fingerprint())
        .collect();
    assert_ne!(identities[0], identities[1]);
    let warm = snapshot
        .build_batch(&recipes, WasmBuildBatchConfig::new())
        .unwrap();
    assert!(warm.outcomes().all(|entry| entry.outcome().is_reused()));
    let unprepared = [LabeledWasmBuildSpec::new(
        "unprepared",
        recipes[0].spec().clone().with_example("missing"),
    )];
    assert!(
        snapshot
            .build_batch(&unprepared, WasmBuildBatchConfig::new())
            .is_err()
    );
    drop((cold, warm, snapshot));
    drop(guard);
    fs::remove_dir_all(root).unwrap();
}