Skip to main content

ic_testkit/artifacts/
icp.rs

1use std::{ffi::OsString, fs, io, path::Path};
2
3use super::digest::{InputDigest, digest_labeled_paths, write_atomic};
4
5const WATCHED_INPUT_STAMP_VERSION: &str = "ic-testkit-watched-input-v1";
6
7/// Exact content digest captured across a set of watched input trees.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub struct WatchedInputSnapshot {
10    digest: InputDigest,
11}
12
13impl WatchedInputSnapshot {
14    /// Recursively hash the paths and contents of all watched inputs.
15    ///
16    /// File timestamps are deliberately excluded, so the same content produces
17    /// the same digest after a Git checkout or CI cache restore.
18    pub fn capture(workspace_root: &Path, watched_relative_paths: &[&str]) -> io::Result<Self> {
19        let paths = watched_relative_paths
20            .iter()
21            .map(|relative| ((*relative).into(), workspace_root.join(relative)))
22            .collect::<Vec<_>>();
23        Ok(Self {
24            digest: digest_labeled_paths("watched-inputs-v1", &paths, &[])?,
25        })
26    }
27
28    /// Return the exact content digest of the watched inputs.
29    #[must_use]
30    pub const fn digest(self) -> InputDigest {
31        self.digest
32    }
33
34    /// Check whether one artifact carries a matching exact-input stamp.
35    ///
36    /// An existing artifact without a stamp is not considered fresh. Call
37    /// [`mark_artifact_fresh`](Self::mark_artifact_fresh) only after the
38    /// artifact has been produced successfully from this snapshot.
39    pub fn artifact_is_fresh(self, artifact_path: &Path) -> io::Result<bool> {
40        let metadata = fs::metadata(artifact_path)?;
41        if !metadata.is_file() || metadata.len() == 0 {
42            return Ok(false);
43        }
44
45        match fs::read_to_string(watched_input_stamp_path(artifact_path)) {
46            Ok(stamp) => Ok(stamp == self.stamp_contents()),
47            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
48            Err(error) => Err(error),
49        }
50    }
51
52    /// Atomically record that an existing artifact was built from this input snapshot.
53    pub fn mark_artifact_fresh(self, artifact_path: &Path) -> io::Result<()> {
54        let metadata = fs::metadata(artifact_path)?;
55        if !metadata.is_file() || metadata.len() == 0 {
56            return Err(io::Error::new(
57                io::ErrorKind::InvalidInput,
58                format!(
59                    "cannot stamp missing or empty artifact: {}",
60                    artifact_path.display()
61                ),
62            ));
63        }
64
65        write_atomic(
66            &watched_input_stamp_path(artifact_path),
67            self.stamp_contents().as_bytes(),
68        )
69    }
70
71    fn stamp_contents(self) -> String {
72        format!("{WATCHED_INPUT_STAMP_VERSION}\nsha256:{}\n", self.digest)
73    }
74}
75
76/// Check whether an ICP artifact exists, is nonempty, and is fresh against watched inputs.
77#[must_use]
78pub fn icp_artifact_ready_for_build(
79    workspace_root: &Path,
80    artifact_relative_path: &str,
81    watched_relative_paths: &[&str],
82) -> bool {
83    let Ok(watched_inputs) = WatchedInputSnapshot::capture(workspace_root, watched_relative_paths)
84    else {
85        return false;
86    };
87
88    icp_artifact_ready_with_snapshot(workspace_root, artifact_relative_path, watched_inputs)
89}
90
91/// Check one ICP artifact against one already-captured watched-input snapshot.
92#[must_use]
93pub fn icp_artifact_ready_with_snapshot(
94    workspace_root: &Path,
95    artifact_relative_path: &str,
96    watched_inputs: WatchedInputSnapshot,
97) -> bool {
98    let artifact_path = workspace_root.join(artifact_relative_path);
99
100    match fs::metadata(&artifact_path) {
101        Ok(meta) if meta.is_file() && meta.len() > 0 => watched_inputs
102            .artifact_is_fresh(&artifact_path)
103            .unwrap_or(false),
104        _ => false,
105    }
106}
107
108fn watched_input_stamp_path(artifact_path: &Path) -> std::path::PathBuf {
109    let mut stamp_name = artifact_path
110        .file_name()
111        .map_or_else(|| OsString::from("artifact"), OsString::from);
112    stamp_name.push(".ic-testkit-input");
113    artifact_path.with_file_name(stamp_name)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::WatchedInputSnapshot;
119    use super::icp_artifact_ready_for_build;
120    use std::{
121        fs,
122        path::PathBuf,
123        sync::atomic::{AtomicU64, Ordering},
124        time::{SystemTime, UNIX_EPOCH},
125    };
126
127    static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
128
129    fn temp_workspace() -> PathBuf {
130        let unique = SystemTime::now()
131            .duration_since(UNIX_EPOCH)
132            .expect("system time before epoch")
133            .as_nanos();
134        let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
135        let path =
136            std::env::temp_dir().join(format!("ic-testkit-icp-artifact-test-{unique}-{sequence}"));
137        fs::create_dir_all(path.join(".icp/local/canisters/counter"))
138            .expect("create temp workspace");
139        path
140    }
141
142    #[test]
143    fn icp_artifact_ready_requires_matching_content_stamp() {
144        let workspace_root = temp_workspace();
145        let artifact_relative_path = ".icp/local/canisters/counter/counter.wasm.gz";
146        let artifact_path = workspace_root.join(artifact_relative_path);
147        fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
148        fs::write(&artifact_path, b"wasm").expect("write artifact");
149
150        assert!(!icp_artifact_ready_for_build(
151            &workspace_root,
152            artifact_relative_path,
153            &["Cargo.toml"],
154        ));
155
156        let snapshot = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
157            .expect("capture exact watched inputs");
158        snapshot
159            .mark_artifact_fresh(&artifact_path)
160            .expect("stamp artifact inputs");
161        assert!(icp_artifact_ready_for_build(
162            &workspace_root,
163            artifact_relative_path,
164            &["Cargo.toml"],
165        ));
166
167        fs::write(workspace_root.join("Cargo.toml"), "changed").expect("update watched input");
168        assert!(!icp_artifact_ready_for_build(
169            &workspace_root,
170            artifact_relative_path,
171            &["Cargo.toml"],
172        ));
173
174        let changed = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
175            .expect("capture changed watched inputs");
176        assert_ne!(snapshot.digest(), changed.digest());
177
178        let _ = fs::remove_dir_all(workspace_root);
179    }
180
181    #[test]
182    fn watched_input_digest_ignores_checkout_root_and_input_order() {
183        let first_root = temp_workspace();
184        let second_root = temp_workspace();
185        for root in [&first_root, &second_root] {
186            fs::create_dir_all(root.join("src")).expect("create watched source directory");
187            fs::write(root.join("Cargo.toml"), "[workspace]").expect("write manifest input");
188            fs::write(root.join("src/lib.rs"), "pub fn value() -> u8 { 7 }")
189                .expect("write source input");
190        }
191
192        let first = WatchedInputSnapshot::capture(&first_root, &["Cargo.toml", "src"])
193            .expect("capture first checkout");
194        let second = WatchedInputSnapshot::capture(&second_root, &["src", "Cargo.toml"])
195            .expect("capture second checkout");
196        assert_eq!(first.digest(), second.digest());
197
198        let _ = fs::remove_dir_all(first_root);
199        let _ = fs::remove_dir_all(second_root);
200    }
201}