ic_testkit/artifacts/
icp.rs1use 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#[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| ((*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 #[must_use]
34 pub const fn digest(self) -> InputDigest {
35 self.digest
36 }
37
38 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 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#[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#[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}