ic_build/artifacts/
icp.rs1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct WatchedInputSnapshot {
14 digest: InputDigest,
15}
16
17impl WatchedInputSnapshot {
18 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 #[must_use]
36 pub const fn digest(self) -> InputDigest {
37 self.digest
38 }
39
40 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 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#[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#[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 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}