Skip to main content

canic_testkit/artifacts/
dfx.rs

1use super::wasm::WasmBuildProfile;
2use std::{
3    fs, io,
4    path::Path,
5    process::{Command, Output},
6    time::SystemTime,
7};
8
9const DFX_BUILD_ENV_STAMP_RELATIVE: &str = ".dfx/canic-build-env.stamp";
10
11///
12/// WatchedInputSnapshot
13///
14
15#[derive(Clone, Copy, Debug)]
16pub struct WatchedInputSnapshot {
17    newest_input_mtime: SystemTime,
18}
19
20impl WatchedInputSnapshot {
21    /// Capture the newest modification time across all watched inputs once.
22    pub fn capture(workspace_root: &Path, watched_relative_paths: &[&str]) -> io::Result<Self> {
23        Ok(Self {
24            newest_input_mtime: newest_watched_input_mtime(workspace_root, watched_relative_paths)?,
25        })
26    }
27
28    /// Check whether one artifact is newer than the captured watched inputs.
29    pub fn artifact_is_fresh(self, artifact_path: &Path) -> io::Result<bool> {
30        let artifact_mtime = fs::metadata(artifact_path)?.modified()?;
31        Ok(self.newest_input_mtime <= artifact_mtime)
32    }
33}
34
35/// Check whether a `dfx` artifact exists, is fresh, and matches the expected build env.
36#[must_use]
37pub fn dfx_artifact_ready_for_build(
38    workspace_root: &Path,
39    artifact_relative_path: &str,
40    watched_relative_paths: &[&str],
41    network: &str,
42    profile: WasmBuildProfile,
43    extra_env: &[(&str, &str)],
44) -> bool {
45    let Ok(watched_inputs) = WatchedInputSnapshot::capture(workspace_root, watched_relative_paths)
46    else {
47        return false;
48    };
49
50    dfx_artifact_ready_with_snapshot(
51        workspace_root,
52        artifact_relative_path,
53        watched_inputs,
54        network,
55        profile,
56        extra_env,
57    )
58}
59
60/// Check one `dfx` artifact against one already-captured watched-input snapshot.
61#[must_use]
62pub fn dfx_artifact_ready_with_snapshot(
63    workspace_root: &Path,
64    artifact_relative_path: &str,
65    watched_inputs: WatchedInputSnapshot,
66    network: &str,
67    profile: WasmBuildProfile,
68    extra_env: &[(&str, &str)],
69) -> bool {
70    let artifact_path = workspace_root.join(artifact_relative_path);
71
72    match fs::metadata(&artifact_path) {
73        Ok(meta) if meta.is_file() && meta.len() > 0 => {
74            watched_inputs
75                .artifact_is_fresh(&artifact_path)
76                .unwrap_or(false)
77                && build_stamp_matches(workspace_root, network, profile, extra_env)
78        }
79        _ => false,
80    }
81}
82
83/// Build all local `.dfx` canister artifacts while holding a file lock around the build and
84/// applying additional environment overrides.
85pub fn build_dfx_all_with_env(
86    workspace_root: &Path,
87    lock_relative_path: &str,
88    network: &str,
89    profile: WasmBuildProfile,
90    extra_env: &[(&str, &str)],
91) {
92    let output = run_local_artifact_build_with_lock(
93        workspace_root,
94        lock_relative_path,
95        network,
96        profile,
97        extra_env,
98    );
99    assert!(
100        output.status.success(),
101        "local artifact build failed: {}",
102        String::from_utf8_lossy(&output.stderr)
103    );
104    write_build_stamp(workspace_root, network, profile, extra_env)
105        .expect("write local artifact build env stamp");
106}
107
108// Walk watched files and directories and return the newest modification time.
109fn newest_watched_input_mtime(
110    workspace_root: &Path,
111    watched_relative_paths: &[&str],
112) -> io::Result<SystemTime> {
113    let mut newest = SystemTime::UNIX_EPOCH;
114
115    for relative in watched_relative_paths {
116        let path = workspace_root.join(relative);
117        newest = newest.max(newest_path_mtime(&path)?);
118    }
119
120    Ok(newest)
121}
122
123// Recursively compute the newest modification time under one watched path.
124fn newest_path_mtime(path: &Path) -> io::Result<SystemTime> {
125    let metadata = fs::metadata(path)?;
126    let mut newest = metadata.modified()?;
127
128    if metadata.is_dir() {
129        for entry in fs::read_dir(path)? {
130            let entry = entry?;
131            newest = newest.max(newest_path_mtime(&entry.path())?);
132        }
133    }
134
135    Ok(newest)
136}
137
138fn build_stamp_matches(
139    workspace_root: &Path,
140    network: &str,
141    profile: WasmBuildProfile,
142    extra_env: &[(&str, &str)],
143) -> bool {
144    fs::read_to_string(workspace_root.join(DFX_BUILD_ENV_STAMP_RELATIVE))
145        .is_ok_and(|current| current == build_stamp_contents(network, profile, extra_env))
146}
147
148fn write_build_stamp(
149    workspace_root: &Path,
150    network: &str,
151    profile: WasmBuildProfile,
152    extra_env: &[(&str, &str)],
153) -> io::Result<()> {
154    let stamp_path = workspace_root.join(DFX_BUILD_ENV_STAMP_RELATIVE);
155    if let Some(parent) = stamp_path.parent() {
156        fs::create_dir_all(parent)?;
157    }
158    fs::write(
159        stamp_path,
160        build_stamp_contents(network, profile, extra_env),
161    )
162}
163
164fn build_stamp_contents(
165    network: &str,
166    profile: WasmBuildProfile,
167    extra_env: &[(&str, &str)],
168) -> String {
169    let mut lines = vec![
170        format!("DFX_NETWORK={network}"),
171        format!("CANIC_WASM_PROFILE={}", profile.canic_wasm_profile_value()),
172    ];
173
174    let mut extra = extra_env.to_vec();
175    extra.sort_unstable_by_key(|(left, _)| *left);
176    lines.extend(
177        extra
178            .into_iter()
179            .map(|(key, value)| format!("{key}={value}")),
180    );
181    lines.push(String::new());
182    lines.join("\n")
183}
184
185// Invoke the shared local artifact build helper under one file lock when `flock` is available.
186fn run_local_artifact_build_with_lock(
187    workspace_root: &Path,
188    lock_relative_path: &str,
189    network: &str,
190    profile: WasmBuildProfile,
191    extra_env: &[(&str, &str)],
192) -> Output {
193    let lock_file = workspace_root.join(lock_relative_path);
194    let target_dir = workspace_root.join("target/dfx-build");
195    if let Some(parent) = lock_file.parent() {
196        let _ = fs::create_dir_all(parent);
197    }
198    let _ = fs::create_dir_all(&target_dir);
199
200    let mut flock = Command::new("flock");
201    flock
202        .current_dir(workspace_root)
203        .arg(lock_file.as_os_str())
204        .arg("bash")
205        .env("DFX_NETWORK", network)
206        .env("CANIC_WASM_PROFILE", profile.canic_wasm_profile_value())
207        .env("CARGO_TARGET_DIR", &target_dir)
208        .arg("scripts/ci/build-ci-wasm-artifacts.sh");
209    for (key, value) in extra_env {
210        flock.env(key, value);
211    }
212
213    match flock.output() {
214        Ok(output) => output,
215        Err(err) if err.kind() == io::ErrorKind::NotFound => {
216            run_local_artifact_build(workspace_root, network, profile, extra_env)
217        }
218        Err(err) => panic!("failed to run `flock` for local artifact build: {err}"),
219    }
220}
221
222// Invoke the shared local artifact build helper directly when `flock` is unavailable.
223fn run_local_artifact_build(
224    workspace_root: &Path,
225    network: &str,
226    profile: WasmBuildProfile,
227    extra_env: &[(&str, &str)],
228) -> Output {
229    let target_dir = workspace_root.join("target/dfx-build");
230    let _ = fs::create_dir_all(&target_dir);
231
232    let mut build = Command::new("bash");
233    build
234        .current_dir(workspace_root)
235        .env("DFX_NETWORK", network)
236        .env("CANIC_WASM_PROFILE", profile.canic_wasm_profile_value())
237        .env("CARGO_TARGET_DIR", &target_dir)
238        .arg("scripts/ci/build-ci-wasm-artifacts.sh");
239    for (key, value) in extra_env {
240        build.env(key, value);
241    }
242
243    build
244        .output()
245        .expect("failed to run local artifact build helper")
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{build_stamp_contents, dfx_artifact_ready_for_build};
251    use crate::artifacts::WasmBuildProfile;
252    use std::{
253        fs,
254        path::PathBuf,
255        thread::sleep,
256        time::Duration,
257        time::{SystemTime, UNIX_EPOCH},
258    };
259
260    fn temp_workspace() -> PathBuf {
261        let unique = SystemTime::now()
262            .duration_since(UNIX_EPOCH)
263            .expect("system time before epoch")
264            .as_nanos();
265        let path = std::env::temp_dir().join(format!("canic-dfx-artifact-test-{unique}"));
266        fs::create_dir_all(path.join(".dfx/local/canisters/root")).expect("create temp workspace");
267        path
268    }
269
270    #[test]
271    fn dfx_artifact_ready_requires_matching_build_env_stamp() {
272        let workspace_root = temp_workspace();
273        let artifact_relative_path = ".dfx/local/canisters/root/root.wasm.gz";
274        let artifact_path = workspace_root.join(artifact_relative_path);
275        fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
276        sleep(Duration::from_millis(20));
277        fs::write(&artifact_path, b"wasm").expect("write artifact");
278        fs::write(
279            workspace_root.join(".dfx/canic-build-env.stamp"),
280            build_stamp_contents("local", WasmBuildProfile::Debug, &[]),
281        )
282        .expect("write build stamp");
283
284        assert!(dfx_artifact_ready_for_build(
285            &workspace_root,
286            artifact_relative_path,
287            &["Cargo.toml"],
288            "local",
289            WasmBuildProfile::Debug,
290            &[],
291        ));
292        assert!(!dfx_artifact_ready_for_build(
293            &workspace_root,
294            artifact_relative_path,
295            &["Cargo.toml"],
296            "local",
297            WasmBuildProfile::Debug,
298            &[("RUSTFLAGS", "--cfg canic_test_small_wasm_store")],
299        ));
300
301        let _ = fs::remove_dir_all(workspace_root);
302    }
303}