use crate::{
canister_build::compiler_cache::{CompilerCacheError, check_implicit_cache},
durable_io::{RegularFileLockError, lock_regular_file_with_parents},
};
use std::{
env,
ffi::OsStr,
fs, io,
path::{Path, PathBuf},
process::{Command, Output},
};
const DEFAULT_WASM_TARGET_RELATIVE: &str = "target/canic-wasm";
const CANISTER_BUILD_LOCK_RELATIVE: &str = ".canic/locks/canister-artifact-build.lock";
pub fn configure_canister_cargo_command(command: &mut Command, workspace_root: &Path) {
command.env("CARGO_INCREMENTAL", "0").env(
"CARGO_TARGET_DIR",
canister_build_target_root(workspace_root),
);
configure_implicit_sccache(
command,
env::var_os("RUSTC_WRAPPER").as_deref(),
env::var_os("PATH").as_deref(),
);
}
pub fn output_canister_cargo_command(command: &mut Command) -> Result<Output, CompilerCacheError> {
let explicit = env::var_os("RUSTC_WRAPPER");
let search_path = env::var_os("PATH");
let implicit = command
.get_envs()
.find(|(name, _)| *name == "RUSTC_WRAPPER")
.and_then(|(_, value)| value)
.map(PathBuf::from)
.filter(|selected| {
explicit.is_none()
&& search_path.as_deref().is_some_and(|paths| {
env::split_paths(paths)
.any(|directory| directory.join(sccache_executable_name()) == *selected)
})
});
output_with_implicit_cache(command, implicit.as_deref())
}
fn output_with_implicit_cache(
command: &mut Command,
implicit: Option<&Path>,
) -> Result<Output, CompilerCacheError> {
if let Some(wrapper) = implicit
&& command
.get_envs()
.any(|(name, value)| name == "RUSTC_WRAPPER" && value == Some(wrapper.as_os_str()))
{
check_implicit_cache(command, wrapper)?;
}
command.output().map_err(CompilerCacheError::CargoLaunch)
}
pub fn configure_declaration_command(
command: &mut Command,
context: &crate::canister_build::WorkspaceBuildContext,
) {
let profile = match context.profile {
crate::canister_build::CanisterBuildProfile::Debug => "DEV",
crate::canister_build::CanisterBuildProfile::Fast => "FAST",
crate::canister_build::CanisterBuildProfile::Release => "RELEASE",
};
command
.env(canic_core::role_contract::CANONICAL_CANDID_BUILD_ENV, "1")
.env_remove(canic_core::ids::RELEASE_BUILD_ID_ENV)
.env_remove(canic_core::role_contract::PROTOCOL_PROFILE_DIGEST_ENV)
.env_remove(canic_core::role_contract::build_context::PROTOCOL_BUILD_CONTEXT_ENV)
.env(format!("CARGO_PROFILE_{profile}_LTO"), "off")
.env(format!("CARGO_PROFILE_{profile}_OPT_LEVEL"), "0")
.env(format!("CARGO_PROFILE_{profile}_CODEGEN_UNITS"), "16")
.env(
"CARGO_TARGET_DIR",
declaration_target_root(&context.workspace_root),
);
}
pub fn declaration_target_root(workspace_root: &Path) -> PathBuf {
canister_build_target_root(workspace_root).join("declarations")
}
#[must_use]
pub fn canister_build_target_root(workspace_root: &Path) -> PathBuf {
resolve_canister_build_target_root(
workspace_root,
env::var_os("CARGO_TARGET_DIR").map(PathBuf::from),
)
}
pub fn lock_canister_build_target(workspace_root: &Path) -> io::Result<fs::File> {
let path = workspace_root.join(CANISTER_BUILD_LOCK_RELATIVE);
lock_regular_file_with_parents(&path).map_err(|error| match error {
RegularFileLockError::NotRegular => io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Canic artifact-build lock is not a regular file: {}",
path.display()
),
),
RegularFileLockError::Io(source) => io::Error::new(
source.kind(),
format!(
"failed to lock Canic artifact-build target {}: {source}",
path.display()
),
),
#[cfg(windows)]
RegularFileLockError::UnsupportedPlatform => io::Error::new(
io::ErrorKind::Unsupported,
"Canic artifact-build locking is unsupported on Windows",
),
})
}
fn resolve_canister_build_target_root(
workspace_root: &Path,
configured_target: Option<PathBuf>,
) -> PathBuf {
configured_target.map_or_else(
|| workspace_root.join(DEFAULT_WASM_TARGET_RELATIVE),
|path| {
if path.is_absolute() {
path
} else {
workspace_root.join(path)
}
},
)
}
fn resolve_implicit_sccache_wrapper(
explicit_wrapper: Option<&OsStr>,
search_path: Option<&OsStr>,
) -> Option<PathBuf> {
if explicit_wrapper.is_some() {
return None;
}
search_path.and_then(|search_path| {
env::split_paths(search_path)
.map(|directory| directory.join(sccache_executable_name()))
.find(|candidate| is_executable_file(candidate))
})
}
fn configure_implicit_sccache(
command: &mut Command,
explicit_wrapper: Option<&OsStr>,
search_path: Option<&OsStr>,
) {
if let Some(sccache) = resolve_implicit_sccache_wrapper(explicit_wrapper, search_path) {
command.env("RUSTC_WRAPPER", sccache);
}
}
#[cfg(windows)]
const fn sccache_executable_name() -> &'static str {
"sccache.exe"
}
#[cfg(not(windows))]
const fn sccache_executable_name() -> &'static str {
"sccache"
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::temp_dir;
use std::{ffi::OsString, sync::mpsc, thread, time::Duration};
#[test]
fn release_declarations_disable_lto_without_changing_runtime_profile() {
let context = crate::canister_build::WorkspaceBuildContext {
role: "app".into(),
profile: crate::canister_build::CanisterBuildProfile::Release,
environment: "local".into(),
build_network: canic_core::ids::BuildNetwork::Local,
workspace_root: "/workspace".into(),
icp_root: "/workspace".into(),
config_path: "/workspace/canic.toml".into(),
local_replica: None,
refresh_canonical_infrastructure_did: false,
release_build_id: None,
};
let mut command = Command::new("cargo");
configure_declaration_command(&mut command, &context);
let environment = command
.get_envs()
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
environment[OsStr::new("CARGO_PROFILE_RELEASE_LTO")],
Some(OsStr::new("off"))
);
assert_eq!(
environment[OsStr::new("CARGO_PROFILE_RELEASE_CODEGEN_UNITS")],
Some(OsStr::new("16"))
);
assert_eq!(
environment[OsStr::new("CARGO_TARGET_DIR")],
Some(declaration_target_root(&context.workspace_root).as_os_str())
);
assert_eq!(
environment[OsStr::new("CARGO_PROFILE_RELEASE_OPT_LEVEL")],
Some(OsStr::new("0"))
);
assert_eq!(
context.profile,
crate::canister_build::CanisterBuildProfile::Release
);
}
#[test]
fn declaration_and_runtime_preserve_cfg_with_distinct_final_outputs() {
let root = temp_dir("declaration-profile-cfg");
fs::create_dir_all(root.join("helper/src")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("Cargo.toml"), "[workspace]\n[package]\nname=\"cache_probe\"\nversion=\"0.1.0\"\nedition=\"2024\"\n[build-dependencies]\ncache_helper={path=\"helper\"}\n[profile.release]\nlto=true\ncodegen-units=1\n").unwrap();
fs::write(
root.join("helper/Cargo.toml"),
"[package]\nname=\"cache_helper\"\nversion=\"0.1.0\"\nedition=\"2024\"\n",
)
.unwrap();
fs::write(root.join("helper/src/lib.rs"), "pub fn mode() -> &'static str { if std::env::var(\"CANIC_INTERNAL_CANDID_BUILD\").as_deref() == Ok(\"1\") { \"declaration\" } else { \"runtime\" } }\n").unwrap();
fs::write(root.join("build.rs"), "fn main() { println!(\"cargo:rerun-if-env-changed=CANIC_INTERNAL_CANDID_BUILD\"); println!(\"cargo:rustc-env=MODE={}\", cache_helper::mode()); }\n").unwrap();
fs::write(
root.join("src/main.rs"),
"fn main() { println!(\"{} {}\", env!(\"MODE\"), cfg!(debug_assertions)); }\n",
)
.unwrap();
fs::write(root.join("Cargo.lock"), "version=4\n[[package]]\nname=\"cache_helper\"\nversion=\"0.1.0\"\n[[package]]\nname=\"cache_probe\"\nversion=\"0.1.0\"\ndependencies=[\"cache_helper\"]\n").unwrap();
let context = crate::canister_build::WorkspaceBuildContext {
role: "app".into(),
profile: crate::canister_build::CanisterBuildProfile::Release,
environment: "local".into(),
build_network: canic_core::ids::BuildNetwork::Local,
workspace_root: root.clone(),
icp_root: root.clone(),
config_path: root.join("Cargo.toml"),
local_replica: None,
refresh_canonical_infrastructure_did: false,
release_build_id: None,
};
let declaration = run_intermediate_probe(&context, true);
let runtime = run_intermediate_probe(&context, false);
assert_ne!(declaration, runtime);
assert_eq!(
Command::new(declaration).output().unwrap().stdout,
b"declaration false\n"
);
assert_eq!(
Command::new(runtime).output().unwrap().stdout,
b"runtime false\n"
);
fs::remove_dir_all(root).unwrap();
}
fn run_intermediate_probe(
context: &crate::canister_build::WorkspaceBuildContext,
declaration: bool,
) -> PathBuf {
let mut command = crate::cargo_command();
command.current_dir(&context.workspace_root).args([
"build",
"--locked",
"--offline",
"--release",
"--message-format=json",
]);
context.apply_to_command(&mut command);
configure_canister_cargo_command(&mut command, &context.workspace_root);
if declaration {
configure_declaration_command(&mut command, context);
}
let host = Command::new("rustc")
.args(["--print", "host-tuple"])
.output()
.unwrap();
assert!(host.status.success());
let host = String::from_utf8(host.stdout).unwrap();
command.arg("--target").arg(host.trim());
command.env_remove("CARGO_BUILD_BUILD_DIR");
command.env(
"CARGO_TARGET_DIR",
context.workspace_root.join(if declaration {
"declarations"
} else {
"runtime"
}),
);
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let records = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
let binary = records
.iter()
.find(|value| {
value["reason"] == "compiler-artifact" && value["target"]["name"] == "cache_probe"
})
.unwrap();
PathBuf::from(binary["executable"].as_str().unwrap())
}
#[test]
fn default_target_is_a_dedicated_reusable_workspace_cache() {
let root = Path::new("/workspace");
assert_eq!(
resolve_canister_build_target_root(root, None),
Path::new("/workspace/target/canic-wasm")
);
}
#[test]
fn configured_relative_target_remains_workspace_relative() {
let root = Path::new("/workspace");
assert_eq!(
resolve_canister_build_target_root(root, Some(PathBuf::from("custom-target"))),
Path::new("/workspace/custom-target")
);
}
#[test]
fn install_build_discovers_sccache_without_overriding_explicit_wrapper() {
let root = temp_dir("canister-build-sccache");
let bin = root.join("bin");
fs::create_dir_all(&bin).expect("create cache bin directory");
let sccache = bin.join(sccache_executable_name());
fs::write(&sccache, b"cache").expect("write cache executable");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&sccache, fs::Permissions::from_mode(0o755))
.expect("make cache executable");
}
let search_path = env::join_paths([&bin]).expect("join cache search path");
let mut discovered = Command::new("cargo");
configure_implicit_sccache(&mut discovered, None, Some(&search_path));
assert_eq!(
discovered
.get_envs()
.find(|(name, _)| *name == "RUSTC_WRAPPER")
.and_then(|(_, value)| value),
Some(sccache.as_os_str())
);
let explicit_wrapper = OsString::from("custom-wrapper");
let mut explicit = Command::new("cargo");
explicit.env("RUSTC_WRAPPER", &explicit_wrapper);
configure_implicit_sccache(
&mut explicit,
Some(explicit_wrapper.as_os_str()),
Some(&search_path),
);
assert_eq!(
explicit
.get_envs()
.find(|(name, _)| *name == "RUSTC_WRAPPER")
.and_then(|(_, value)| value),
Some(explicit_wrapper.as_os_str())
);
fs::remove_dir_all(root).expect("remove cache test root");
}
#[cfg(unix)]
fn cache_probe_fixture(cache_body: &str, compiler_body: &str) -> (PathBuf, PathBuf, Command) {
use std::os::unix::fs::PermissionsExt as _;
let root = temp_dir("compiler cache probe");
fs::create_dir_all(&root).unwrap();
let wrapper = root.join(sccache_executable_name());
let compiler = root.join("compiler");
for (path, body) in [(&wrapper, cache_body), (&compiler, compiler_body)] {
fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
let mut cargo = Command::new("/bin/sh");
cargo.current_dir(&root).args([
"-c",
"printf 'build started' > cargo-ran; printf 'compiler build error' >&2; exit 7",
]);
cargo
.env("RUSTC", compiler)
.env("RUSTC_WRAPPER", &wrapper)
.env_remove("RUSTC_WORKSPACE_WRAPPER")
.env("CANIC_CACHE_PROBE_FIXTURE", "selected");
(root, wrapper, cargo)
}
#[test]
#[cfg(unix)]
fn implicit_cache_failure_stops_before_cargo_with_actionable_original_evidence() {
let (root, wrapper, mut cargo) =
cache_probe_fixture("printf 'cache service unavailable' >&2; exit 47", "exit 0");
let error = output_with_implicit_cache(&mut cargo, Some(&wrapper)).unwrap_err();
assert!(matches!(
&error,
CompilerCacheError::ImplicitCache {
source: super::super::compiler_cache::ProbeError::Exit { status, stderr, .. },
..
} if status.code() == Some(47) && stderr == "cache service unavailable"
));
assert!(error.to_string().contains("RUSTC_WRAPPER="));
assert!(!root.join("cargo-ran").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
#[cfg(unix)]
fn compiler_startup_failure_is_not_attributed_to_the_implicit_cache() {
let (root, wrapper, mut cargo) = cache_probe_fixture(
"touch cache-ran; exit 0",
"printf 'compiler unavailable' >&2; exit 51",
);
let error = output_with_implicit_cache(&mut cargo, Some(&wrapper)).unwrap_err();
assert!(matches!(
error,
CompilerCacheError::Compiler {
source: super::super::compiler_cache::ProbeError::Exit { status, .. },
..
} if status.code() == Some(51)
));
assert!(!root.join("cache-ran").exists());
assert!(!root.join("cargo-ran").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
#[cfg(unix)]
fn working_discovery_preserves_cargo_failure_and_probe_environment() {
let (root, wrapper, mut cargo) = cache_probe_fixture(
"test \"$CANIC_CACHE_PROBE_FIXTURE\" = selected || exit 54; exec \"$@\"",
"test \"$1\" = -vV || exit 55; test -f compiler || exit 56; exit 0",
);
let path = env::join_paths([&root]).unwrap();
let selected = resolve_implicit_sccache_wrapper(None, Some(&path));
assert_eq!(selected.as_deref(), Some(wrapper.as_path()));
let output = output_with_implicit_cache(&mut cargo, selected.as_deref()).unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stderr, b"compiler build error");
assert!(root.join("cargo-ran").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
#[cfg(unix)]
fn explicit_empty_and_custom_wrappers_bypass_implicit_probes() {
for explicit in ["", "custom-wrapper"] {
let (root, wrapper, mut cargo) = cache_probe_fixture("exit 47", "exit 51");
cargo.env("RUSTC_WRAPPER", explicit);
let path = env::join_paths([&root]).unwrap();
assert!(
resolve_implicit_sccache_wrapper(Some(OsStr::new(explicit)), Some(&path)).is_none()
);
let output = output_with_implicit_cache(&mut cargo, Some(&wrapper)).unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(root.join("cargo-ran").exists());
fs::remove_dir_all(root).unwrap();
}
}
#[test]
#[cfg(unix)]
fn disappeared_implicit_cache_reports_launch_failure_before_cargo() {
let (root, wrapper, mut cargo) = cache_probe_fixture("exit 0", "exit 0");
fs::remove_file(&wrapper).unwrap();
let error = output_with_implicit_cache(&mut cargo, Some(&wrapper)).unwrap_err();
assert!(matches!(
error,
CompilerCacheError::ImplicitCache {
source: super::super::compiler_cache::ProbeError::Launch(_),
..
}
));
assert!(!root.join("cargo-ran").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn artifact_materialization_lock_excludes_a_second_builder() {
let root = temp_dir("canic-artifact-build-lock");
let _ = fs::remove_dir_all(&root);
let lock_path = root.join(CANISTER_BUILD_LOCK_RELATIVE);
assert!(lock_path.starts_with(root.join(".canic")));
assert!(!lock_path.starts_with(root.join("target")));
let first = lock_canister_build_target(&root).expect("acquire first build lock");
let contender_root = root.clone();
let (attempted_tx, attempted_rx) = mpsc::channel();
let (acquired_tx, acquired_rx) = mpsc::channel();
let contender = thread::spawn(move || {
attempted_tx.send(()).expect("report lock attempt");
let acquired = lock_canister_build_target(&contender_root).is_ok();
acquired_tx.send(acquired).expect("report lock result");
});
attempted_rx.recv().expect("observe contender attempt");
assert!(
acquired_rx
.recv_timeout(Duration::from_millis(100))
.is_err()
);
drop(first);
assert_eq!(acquired_rx.recv_timeout(Duration::from_secs(2)), Ok(true));
contender.join().expect("join lock contender");
fs::remove_dir_all(root).expect("clean build-lock fixture");
}
}