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