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        .map(|current| current == build_stamp_contents(network, profile, extra_env))
146        .unwrap_or(false)
147}
148
149fn write_build_stamp(
150    workspace_root: &Path,
151    network: &str,
152    profile: WasmBuildProfile,
153    extra_env: &[(&str, &str)],
154) -> io::Result<()> {
155    let stamp_path = workspace_root.join(DFX_BUILD_ENV_STAMP_RELATIVE);
156    if let Some(parent) = stamp_path.parent() {
157        fs::create_dir_all(parent)?;
158    }
159    fs::write(
160        stamp_path,
161        build_stamp_contents(network, profile, extra_env),
162    )
163}
164
165fn build_stamp_contents(
166    network: &str,
167    profile: WasmBuildProfile,
168    extra_env: &[(&str, &str)],
169) -> String {
170    let mut lines = vec![
171        format!("DFX_NETWORK={network}"),
172        format!("CANIC_WASM_PROFILE={}", profile.canic_wasm_profile_value()),
173    ];
174
175    let mut extra = extra_env.to_vec();
176    extra.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
177    lines.extend(
178        extra
179            .into_iter()
180            .map(|(key, value)| format!("{key}={value}")),
181    );
182    lines.push(String::new());
183    lines.join("\n")
184}
185
186// Invoke the shared local artifact build helper under one file lock when `flock` is available.
187fn run_local_artifact_build_with_lock(
188    workspace_root: &Path,
189    lock_relative_path: &str,
190    network: &str,
191    profile: WasmBuildProfile,
192    extra_env: &[(&str, &str)],
193) -> Output {
194    let lock_file = workspace_root.join(lock_relative_path);
195    let target_dir = workspace_root.join("target/dfx-build");
196    if let Some(parent) = lock_file.parent() {
197        let _ = fs::create_dir_all(parent);
198    }
199    let _ = fs::create_dir_all(&target_dir);
200
201    let mut flock = Command::new("flock");
202    flock
203        .current_dir(workspace_root)
204        .arg(lock_file.as_os_str())
205        .arg("bash")
206        .env("DFX_NETWORK", network)
207        .env("CANIC_WASM_PROFILE", profile.canic_wasm_profile_value())
208        .env("CARGO_TARGET_DIR", &target_dir)
209        .arg("scripts/ci/build-ci-wasm-artifacts.sh");
210    for (key, value) in extra_env {
211        flock.env(key, value);
212    }
213
214    match flock.output() {
215        Ok(output) => output,
216        Err(err) if err.kind() == io::ErrorKind::NotFound => {
217            run_local_artifact_build(workspace_root, network, profile, extra_env)
218        }
219        Err(err) => panic!("failed to run `flock` for local artifact build: {err}"),
220    }
221}
222
223// Invoke the shared local artifact build helper directly when `flock` is unavailable.
224fn run_local_artifact_build(
225    workspace_root: &Path,
226    network: &str,
227    profile: WasmBuildProfile,
228    extra_env: &[(&str, &str)],
229) -> Output {
230    let target_dir = workspace_root.join("target/dfx-build");
231    let _ = fs::create_dir_all(&target_dir);
232
233    let mut build = Command::new("bash");
234    build
235        .current_dir(workspace_root)
236        .env("DFX_NETWORK", network)
237        .env("CANIC_WASM_PROFILE", profile.canic_wasm_profile_value())
238        .env("CARGO_TARGET_DIR", &target_dir)
239        .arg("scripts/ci/build-ci-wasm-artifacts.sh");
240    for (key, value) in extra_env {
241        build.env(key, value);
242    }
243
244    build
245        .output()
246        .expect("failed to run local artifact build helper")
247}
248
249#[cfg(test)]
250mod tests {
251    use super::{build_stamp_contents, dfx_artifact_ready_for_build};
252    use crate::artifacts::WasmBuildProfile;
253    use std::{
254        fs,
255        path::PathBuf,
256        thread::sleep,
257        time::Duration,
258        time::{SystemTime, UNIX_EPOCH},
259    };
260
261    fn temp_workspace() -> PathBuf {
262        let unique = SystemTime::now()
263            .duration_since(UNIX_EPOCH)
264            .expect("system time before epoch")
265            .as_nanos();
266        let path = std::env::temp_dir().join(format!("canic-dfx-artifact-test-{unique}"));
267        fs::create_dir_all(path.join(".dfx/local/canisters/root")).expect("create temp workspace");
268        path
269    }
270
271    #[test]
272    fn dfx_artifact_ready_requires_matching_build_env_stamp() {
273        let workspace_root = temp_workspace();
274        let artifact_relative_path = ".dfx/local/canisters/root/root.wasm.gz";
275        let artifact_path = workspace_root.join(artifact_relative_path);
276        fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
277        sleep(Duration::from_millis(20));
278        fs::write(&artifact_path, b"wasm").expect("write artifact");
279        fs::write(
280            workspace_root.join(".dfx/canic-build-env.stamp"),
281            build_stamp_contents("local", WasmBuildProfile::Debug, &[]),
282        )
283        .expect("write build stamp");
284
285        assert!(dfx_artifact_ready_for_build(
286            &workspace_root,
287            artifact_relative_path,
288            &["Cargo.toml"],
289            "local",
290            WasmBuildProfile::Debug,
291            &[],
292        ));
293        assert!(!dfx_artifact_ready_for_build(
294            &workspace_root,
295            artifact_relative_path,
296            &["Cargo.toml"],
297            "local",
298            WasmBuildProfile::Debug,
299            &[("RUSTFLAGS", "--cfg canic_test_small_wasm_store")],
300        ));
301
302        let _ = fs::remove_dir_all(workspace_root);
303    }
304}