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