Skip to main content

ic_build/artifacts/
icp.rs

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