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, read_stamp_with_limit, 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///
9/// This lightweight freshness helper records input identity only. It does not
10/// lock producers, validate output content, or retain artifact paths. Use
11/// [`super::ArtifactCacheSpec`] for transactional publication and retained outputs.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct WatchedInputSnapshot {
14    digest: InputDigest,
15}
16
17impl WatchedInputSnapshot {
18    /// Recursively hash the paths and contents of all watched inputs.
19    ///
20    /// File timestamps are deliberately excluded, so the same content produces
21    /// the same digest after a Git checkout or CI cache restore.
22    pub fn capture(workspace_root: &Path, watched_relative_paths: &[&str]) -> io::Result<Self> {
23        let paths = watched_relative_paths
24            .iter()
25            .map(|relative| (Path::new(relative), workspace_root.join(relative)));
26        Ok(Self {
27            digest: digest_labeled_paths("watched-inputs-v1", paths, &[])?,
28        })
29    }
30
31    /// Return the exact content digest of the watched inputs.
32    #[must_use]
33    pub const fn digest(self) -> InputDigest {
34        self.digest
35    }
36
37    /// Check whether one artifact carries a matching exact-input stamp.
38    ///
39    /// An existing artifact without a stamp is not considered fresh. Call
40    /// [`mark_artifact_fresh`](Self::mark_artifact_fresh) only after the
41    /// artifact has been produced successfully from this snapshot.
42    /// Output bytes are not hashed; a replaced nonempty artifact can still
43    /// carry the same matching input stamp.
44    /// Stamp reads are bounded by the expected stamp length plus one byte;
45    /// oversized stamps are stale. I/O errors and invalid UTF-8 in stamps within
46    /// that size limit are reported to the caller.
47    pub fn artifact_is_fresh(self, artifact_path: &Path) -> io::Result<bool> {
48        let metadata = fs::metadata(artifact_path)?;
49        if !metadata.is_file() || metadata.len() == 0 {
50            return Ok(false);
51        }
52
53        let expected = self.stamp_contents();
54        match read_stamp_with_limit(&watched_input_stamp_path(artifact_path), expected.len()) {
55            Ok(stamp) => Ok(stamp.is_some_and(|contents| contents == expected)),
56            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
57            Err(error) => Err(error),
58        }
59    }
60
61    /// Atomically record that an existing artifact was built from this input snapshot.
62    ///
63    /// The caller must coordinate producers and verify that inputs have not
64    /// changed during the build before stamping. This checks only that the
65    /// artifact is a nonempty regular file, not that its bytes match the build.
66    pub fn mark_artifact_fresh(self, artifact_path: &Path) -> io::Result<()> {
67        let metadata = fs::metadata(artifact_path)?;
68        if !metadata.is_file() || metadata.len() == 0 {
69            return Err(io::Error::new(
70                io::ErrorKind::InvalidInput,
71                format!(
72                    "cannot stamp missing or empty artifact: {}",
73                    artifact_path.display()
74                ),
75            ));
76        }
77
78        write_atomic(
79            &watched_input_stamp_path(artifact_path),
80            self.stamp_contents().as_bytes(),
81        )
82    }
83
84    fn stamp_contents(self) -> String {
85        format!("{WATCHED_INPUT_STAMP_VERSION}\nsha256:{}\n", self.digest)
86    }
87}
88
89/// Check whether an ICP artifact exists, is nonempty, and is fresh against watched inputs.
90#[must_use]
91pub fn icp_artifact_ready_for_build(
92    workspace_root: &Path,
93    artifact_relative_path: &str,
94    watched_relative_paths: &[&str],
95) -> bool {
96    let Ok(watched_inputs) = WatchedInputSnapshot::capture(workspace_root, watched_relative_paths)
97    else {
98        return false;
99    };
100
101    icp_artifact_ready_with_snapshot(workspace_root, artifact_relative_path, watched_inputs)
102}
103
104/// Check one ICP artifact against one already-captured watched-input snapshot.
105#[must_use]
106pub fn icp_artifact_ready_with_snapshot(
107    workspace_root: &Path,
108    artifact_relative_path: &str,
109    watched_inputs: WatchedInputSnapshot,
110) -> bool {
111    let artifact_path = workspace_root.join(artifact_relative_path);
112
113    watched_inputs
114        .artifact_is_fresh(&artifact_path)
115        .unwrap_or(false)
116}
117
118fn watched_input_stamp_path(artifact_path: &Path) -> std::path::PathBuf {
119    let mut stamp_name = artifact_path
120        .file_name()
121        .map_or_else(|| OsString::from("artifact"), OsString::from);
122    stamp_name.push(".ic-testkit-input");
123    artifact_path.with_file_name(stamp_name)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::WatchedInputSnapshot;
129    use super::icp_artifact_ready_for_build;
130    use std::{
131        fs,
132        path::PathBuf,
133        sync::atomic::{AtomicU64, Ordering},
134        time::{SystemTime, UNIX_EPOCH},
135    };
136
137    static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
138
139    fn temp_workspace() -> PathBuf {
140        let unique = SystemTime::now()
141            .duration_since(UNIX_EPOCH)
142            .expect("system time before epoch")
143            .as_nanos();
144        let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
145        let path =
146            std::env::temp_dir().join(format!("ic-testkit-icp-artifact-test-{unique}-{sequence}"));
147        fs::create_dir_all(path.join(".icp/local/canisters/counter"))
148            .expect("create temp workspace");
149        path
150    }
151
152    #[test]
153    fn icp_artifact_ready_requires_matching_content_stamp() {
154        let workspace_root = temp_workspace();
155        let artifact_relative_path = ".icp/local/canisters/counter/counter.wasm.gz";
156        let artifact_path = workspace_root.join(artifact_relative_path);
157        fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
158        fs::write(&artifact_path, b"wasm").expect("write artifact");
159
160        assert!(!icp_artifact_ready_for_build(
161            &workspace_root,
162            artifact_relative_path,
163            &["Cargo.toml"],
164        ));
165
166        let snapshot = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
167            .expect("capture exact watched inputs");
168        snapshot
169            .mark_artifact_fresh(&artifact_path)
170            .expect("stamp artifact inputs");
171        assert!(icp_artifact_ready_for_build(
172            &workspace_root,
173            artifact_relative_path,
174            &["Cargo.toml"],
175        ));
176
177        fs::write(workspace_root.join("Cargo.toml"), "changed").expect("update watched input");
178        assert!(!icp_artifact_ready_for_build(
179            &workspace_root,
180            artifact_relative_path,
181            &["Cargo.toml"],
182        ));
183
184        let changed = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
185            .expect("capture changed watched inputs");
186        assert_ne!(snapshot.digest(), changed.digest());
187
188        let _ = fs::remove_dir_all(workspace_root);
189    }
190
191    #[test]
192    fn watched_input_digest_ignores_checkout_root_and_input_order() {
193        let first_root = temp_workspace();
194        let second_root = temp_workspace();
195        for root in [&first_root, &second_root] {
196            fs::create_dir_all(root.join("src")).expect("create watched source directory");
197            fs::write(root.join("Cargo.toml"), "[workspace]").expect("write manifest input");
198            fs::write(root.join("src/lib.rs"), "pub fn value() -> u8 { 7 }")
199                .expect("write source input");
200        }
201
202        let first = WatchedInputSnapshot::capture(&first_root, &["Cargo.toml", "src"])
203            .expect("capture first checkout");
204        let second = WatchedInputSnapshot::capture(&second_root, &["src", "Cargo.toml"])
205            .expect("capture second checkout");
206        assert_eq!(first.digest(), second.digest());
207
208        let _ = fs::remove_dir_all(first_root);
209        let _ = fs::remove_dir_all(second_root);
210    }
211
212    #[test]
213    fn artifact_freshness_rejects_malformed_and_oversized_stamps() {
214        let root = temp_workspace();
215        let artifact = root.join("artifact.wasm");
216        fs::write(root.join("Cargo.toml"), "workspace").expect("write watched input");
217        fs::write(&artifact, b"wasm").expect("write artifact");
218        let snapshot =
219            WatchedInputSnapshot::capture(&root, &["Cargo.toml"]).expect("capture watched inputs");
220        let stamp_path = super::watched_input_stamp_path(&artifact);
221        let expected = snapshot.stamp_contents();
222
223        assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
224        for contents in [
225            String::new(),
226            expected[..expected.len() - 1].to_owned(),
227            expected.replacen("sha256:", "sha257:", 1),
228            format!("{expected}\n"),
229        ] {
230            fs::write(&stamp_path, contents).expect("write malformed stamp");
231            assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
232        }
233
234        fs::write(&stamp_path, [0xff]).expect("write invalid UTF-8 stamp");
235        assert_eq!(
236            snapshot.artifact_is_fresh(&artifact).unwrap_err().kind(),
237            std::io::ErrorKind::InvalidData,
238        );
239
240        // A sparse oversized sidecar must not be allocated or read in full.
241        fs::write(&stamp_path, &expected).expect("write matching prefix");
242        fs::OpenOptions::new()
243            .write(true)
244            .open(&stamp_path)
245            .expect("open oversized stamp")
246            .set_len(1024 * 1024 * 1024)
247            .expect("extend oversized stamp");
248        assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
249
250        snapshot.mark_artifact_fresh(&artifact).unwrap();
251        assert!(snapshot.artifact_is_fresh(&artifact).unwrap());
252        fs::remove_file(&stamp_path).expect("remove stamp");
253        fs::create_dir(&stamp_path).expect("replace stamp with unreadable directory");
254        assert!(snapshot.artifact_is_fresh(&artifact).is_err());
255        assert!(!icp_artifact_ready_for_build(
256            &root,
257            "artifact.wasm",
258            &["Cargo.toml"],
259        ));
260        let _ = fs::remove_dir_all(root);
261    }
262}