ic_testkit/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> {
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 #[must_use]
33 pub const fn digest(self) -> InputDigest {
34 self.digest
35 }
36
37 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 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 ic_host_fs::durable::write_bytes(
79 &watched_input_stamp_path(artifact_path),
80 self.stamp_contents().as_bytes(),
81 )
82 .map_err(io::Error::other)
83 }
84
85 fn stamp_contents(self) -> String {
86 format!("{WATCHED_INPUT_STAMP_VERSION}\nsha256:{}\n", self.digest)
87 }
88}
89
90#[must_use]
92pub fn icp_artifact_ready_for_build(
93 workspace_root: &Path,
94 artifact_relative_path: &str,
95 watched_relative_paths: &[&str],
96) -> bool {
97 let Ok(watched_inputs) = WatchedInputSnapshot::capture(workspace_root, watched_relative_paths)
98 else {
99 return false;
100 };
101
102 icp_artifact_ready_with_snapshot(workspace_root, artifact_relative_path, watched_inputs)
103}
104
105#[must_use]
107pub fn icp_artifact_ready_with_snapshot(
108 workspace_root: &Path,
109 artifact_relative_path: &str,
110 watched_inputs: WatchedInputSnapshot,
111) -> bool {
112 let artifact_path = workspace_root.join(artifact_relative_path);
113
114 watched_inputs
115 .artifact_is_fresh(&artifact_path)
116 .unwrap_or(false)
117}
118
119fn watched_input_stamp_path(artifact_path: &Path) -> std::path::PathBuf {
120 let mut stamp_name = artifact_path
121 .file_name()
122 .map_or_else(|| OsString::from("artifact"), OsString::from);
123 stamp_name.push(".ic-testkit-input");
124 artifact_path.with_file_name(stamp_name)
125}
126
127#[cfg(test)]
128mod tests {
129 use super::WatchedInputSnapshot;
130 use super::icp_artifact_ready_for_build;
131 use crate::artifacts::test_support::unique_temp_directory;
132 use std::{fs, path::PathBuf};
133
134 fn temp_workspace() -> PathBuf {
135 let path = unique_temp_directory("icp-artifact-test");
136 fs::create_dir_all(path.join(".icp/local/canisters/counter"))
137 .expect("create temp workspace");
138 path
139 }
140
141 #[test]
142 fn icp_artifact_ready_requires_matching_content_stamp() {
143 let workspace_root = temp_workspace();
144 let artifact_relative_path = ".icp/local/canisters/counter/counter.wasm.gz";
145 let artifact_path = workspace_root.join(artifact_relative_path);
146 fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
147 fs::write(&artifact_path, b"wasm").expect("write artifact");
148
149 assert!(!icp_artifact_ready_for_build(
150 &workspace_root,
151 artifact_relative_path,
152 &["Cargo.toml"],
153 ));
154
155 let snapshot = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
156 .expect("capture exact watched inputs");
157 snapshot
158 .mark_artifact_fresh(&artifact_path)
159 .expect("stamp artifact inputs");
160 assert!(icp_artifact_ready_for_build(
161 &workspace_root,
162 artifact_relative_path,
163 &["Cargo.toml"],
164 ));
165
166 fs::write(workspace_root.join("Cargo.toml"), "changed").expect("update watched input");
167 assert!(!icp_artifact_ready_for_build(
168 &workspace_root,
169 artifact_relative_path,
170 &["Cargo.toml"],
171 ));
172
173 let changed = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
174 .expect("capture changed watched inputs");
175 assert_ne!(snapshot.digest(), changed.digest());
176
177 let _ = fs::remove_dir_all(workspace_root);
178 }
179
180 #[test]
181 fn watched_input_digest_ignores_checkout_root_and_input_order() {
182 let first_root = temp_workspace();
183 let second_root = temp_workspace();
184 for root in [&first_root, &second_root] {
185 fs::create_dir_all(root.join("src")).expect("create watched source directory");
186 fs::write(root.join("Cargo.toml"), "[workspace]").expect("write manifest input");
187 fs::write(root.join("src/lib.rs"), "pub fn value() -> u8 { 7 }")
188 .expect("write source input");
189 }
190
191 let first = WatchedInputSnapshot::capture(&first_root, &["Cargo.toml", "src"])
192 .expect("capture first checkout");
193 let second = WatchedInputSnapshot::capture(&second_root, &["src", "Cargo.toml"])
194 .expect("capture second checkout");
195 assert_eq!(first.digest(), second.digest());
196
197 let _ = fs::remove_dir_all(first_root);
198 let _ = fs::remove_dir_all(second_root);
199 }
200
201 #[test]
202 fn artifact_freshness_rejects_malformed_and_oversized_stamps() {
203 let root = temp_workspace();
204 let artifact = root.join("artifact.wasm");
205 fs::write(root.join("Cargo.toml"), "workspace").expect("write watched input");
206 fs::write(&artifact, b"wasm").expect("write artifact");
207 let snapshot =
208 WatchedInputSnapshot::capture(&root, &["Cargo.toml"]).expect("capture watched inputs");
209 let stamp_path = super::watched_input_stamp_path(&artifact);
210 let expected = snapshot.stamp_contents();
211
212 assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
213 for contents in [
214 String::new(),
215 expected[..expected.len() - 1].to_owned(),
216 expected.replacen("sha256:", "sha257:", 1),
217 format!("{expected}\n"),
218 ] {
219 fs::write(&stamp_path, contents).expect("write malformed stamp");
220 assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
221 }
222
223 fs::write(&stamp_path, [0xff]).expect("write invalid UTF-8 stamp");
224 assert_eq!(
225 snapshot.artifact_is_fresh(&artifact).unwrap_err().kind(),
226 std::io::ErrorKind::InvalidData,
227 );
228
229 fs::write(&stamp_path, &expected).expect("write matching prefix");
231 fs::OpenOptions::new()
232 .write(true)
233 .open(&stamp_path)
234 .expect("open oversized stamp")
235 .set_len(1024 * 1024 * 1024)
236 .expect("extend oversized stamp");
237 assert!(!snapshot.artifact_is_fresh(&artifact).unwrap());
238
239 snapshot.mark_artifact_fresh(&artifact).unwrap();
240 assert!(snapshot.artifact_is_fresh(&artifact).unwrap());
241 fs::remove_file(&stamp_path).expect("remove stamp");
242 fs::create_dir(&stamp_path).expect("replace stamp with unreadable directory");
243 assert!(snapshot.artifact_is_fresh(&artifact).is_err());
244 assert!(!icp_artifact_ready_for_build(
245 &root,
246 "artifact.wasm",
247 &["Cargo.toml"],
248 ));
249 let _ = fs::remove_dir_all(root);
250 }
251}