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| (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> {
45 let metadata = fs::metadata(artifact_path)?;
46 if !metadata.is_file() || metadata.len() == 0 {
47 return Ok(false);
48 }
49
50 match fs::read_to_string(watched_input_stamp_path(artifact_path)) {
51 Ok(stamp) => Ok(stamp == self.stamp_contents()),
52 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
53 Err(error) => Err(error),
54 }
55 }
56
57 pub fn mark_artifact_fresh(self, artifact_path: &Path) -> io::Result<()> {
63 let metadata = fs::metadata(artifact_path)?;
64 if !metadata.is_file() || metadata.len() == 0 {
65 return Err(io::Error::new(
66 io::ErrorKind::InvalidInput,
67 format!(
68 "cannot stamp missing or empty artifact: {}",
69 artifact_path.display()
70 ),
71 ));
72 }
73
74 write_atomic(
75 &watched_input_stamp_path(artifact_path),
76 self.stamp_contents().as_bytes(),
77 )
78 }
79
80 fn stamp_contents(self) -> String {
81 format!("{WATCHED_INPUT_STAMP_VERSION}\nsha256:{}\n", self.digest)
82 }
83}
84
85#[must_use]
87pub fn icp_artifact_ready_for_build(
88 workspace_root: &Path,
89 artifact_relative_path: &str,
90 watched_relative_paths: &[&str],
91) -> bool {
92 let Ok(watched_inputs) = WatchedInputSnapshot::capture(workspace_root, watched_relative_paths)
93 else {
94 return false;
95 };
96
97 icp_artifact_ready_with_snapshot(workspace_root, artifact_relative_path, watched_inputs)
98}
99
100#[must_use]
102pub fn icp_artifact_ready_with_snapshot(
103 workspace_root: &Path,
104 artifact_relative_path: &str,
105 watched_inputs: WatchedInputSnapshot,
106) -> bool {
107 let artifact_path = workspace_root.join(artifact_relative_path);
108
109 watched_inputs
110 .artifact_is_fresh(&artifact_path)
111 .unwrap_or(false)
112}
113
114fn watched_input_stamp_path(artifact_path: &Path) -> std::path::PathBuf {
115 let mut stamp_name = artifact_path
116 .file_name()
117 .map_or_else(|| OsString::from("artifact"), OsString::from);
118 stamp_name.push(".ic-testkit-input");
119 artifact_path.with_file_name(stamp_name)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::WatchedInputSnapshot;
125 use super::icp_artifact_ready_for_build;
126 use std::{
127 fs,
128 path::PathBuf,
129 sync::atomic::{AtomicU64, Ordering},
130 time::{SystemTime, UNIX_EPOCH},
131 };
132
133 static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
134
135 fn temp_workspace() -> PathBuf {
136 let unique = SystemTime::now()
137 .duration_since(UNIX_EPOCH)
138 .expect("system time before epoch")
139 .as_nanos();
140 let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
141 let path =
142 std::env::temp_dir().join(format!("ic-testkit-icp-artifact-test-{unique}-{sequence}"));
143 fs::create_dir_all(path.join(".icp/local/canisters/counter"))
144 .expect("create temp workspace");
145 path
146 }
147
148 #[test]
149 fn icp_artifact_ready_requires_matching_content_stamp() {
150 let workspace_root = temp_workspace();
151 let artifact_relative_path = ".icp/local/canisters/counter/counter.wasm.gz";
152 let artifact_path = workspace_root.join(artifact_relative_path);
153 fs::write(workspace_root.join("Cargo.toml"), "workspace").expect("write watched input");
154 fs::write(&artifact_path, b"wasm").expect("write artifact");
155
156 assert!(!icp_artifact_ready_for_build(
157 &workspace_root,
158 artifact_relative_path,
159 &["Cargo.toml"],
160 ));
161
162 let snapshot = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
163 .expect("capture exact watched inputs");
164 snapshot
165 .mark_artifact_fresh(&artifact_path)
166 .expect("stamp artifact inputs");
167 assert!(icp_artifact_ready_for_build(
168 &workspace_root,
169 artifact_relative_path,
170 &["Cargo.toml"],
171 ));
172
173 fs::write(workspace_root.join("Cargo.toml"), "changed").expect("update watched input");
174 assert!(!icp_artifact_ready_for_build(
175 &workspace_root,
176 artifact_relative_path,
177 &["Cargo.toml"],
178 ));
179
180 let changed = WatchedInputSnapshot::capture(&workspace_root, &["Cargo.toml"])
181 .expect("capture changed watched inputs");
182 assert_ne!(snapshot.digest(), changed.digest());
183
184 let _ = fs::remove_dir_all(workspace_root);
185 }
186
187 #[test]
188 fn watched_input_digest_ignores_checkout_root_and_input_order() {
189 let first_root = temp_workspace();
190 let second_root = temp_workspace();
191 for root in [&first_root, &second_root] {
192 fs::create_dir_all(root.join("src")).expect("create watched source directory");
193 fs::write(root.join("Cargo.toml"), "[workspace]").expect("write manifest input");
194 fs::write(root.join("src/lib.rs"), "pub fn value() -> u8 { 7 }")
195 .expect("write source input");
196 }
197
198 let first = WatchedInputSnapshot::capture(&first_root, &["Cargo.toml", "src"])
199 .expect("capture first checkout");
200 let second = WatchedInputSnapshot::capture(&second_root, &["src", "Cargo.toml"])
201 .expect("capture second checkout");
202 assert_eq!(first.digest(), second.digest());
203
204 let _ = fs::remove_dir_all(first_root);
205 let _ = fs::remove_dir_all(second_root);
206 }
207}