ic_build/artifacts/inputs/
mod.rs1pub(super) mod boundaries;
4mod depfiles;
5#[cfg(test)]
6mod tests;
7
8pub use depfiles::{parse_cargo_depfile, read_cargo_depfile_inputs};
9
10use crate::artifacts::{
11 cache_fs::canonicalize_allow_missing,
12 digest::{InputDigest, InputPathError, InputTreeEntry, InputTreeWalk},
13};
14use ic_host_artifacts::artifact::Sha256Digest;
15use sha2::{Digest as _, Sha256};
16use std::{
17 collections::{BTreeMap, BTreeSet},
18 ffi::OsString,
19 io,
20 path::{Component, Path, PathBuf},
21};
22
23#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct InputEvidenceSpec {
29 identity: Vec<u8>,
30 roots: Vec<PathBuf>,
31 excluded_roots: Vec<PathBuf>,
32 excluded_names: Vec<OsString>,
33}
34
35#[derive(Clone, Debug)]
40pub struct InputEvidence {
41 spec: InputEvidenceSpec,
42 entries: BTreeMap<PathBuf, InputEvidenceValue>,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum InputEvidenceValue {
48 Missing,
50 Directory,
52 File {
54 sha256: Sha256Digest,
56 },
57}
58
59#[derive(Debug, thiserror::Error)]
61pub enum InputEvidenceError {
62 #[error("non-file input identity changed")]
64 IdentityChanged,
65 #[error("input exclusion policy changed")]
67 ExclusionsChanged,
68 #[error("input changed: {}", path.display())]
70 ChangedInput {
71 path: PathBuf,
73 },
74 #[error("input was not observed before work: {}", path.display())]
76 UnobservedInput {
77 path: PathBuf,
79 },
80 #[error("input evidence failed for {}: {source}", path.display())]
82 Io {
83 path: PathBuf,
85 source: io::Error,
87 },
88}
89
90impl InputEvidenceSpec {
91 #[must_use]
93 pub fn new(identity: impl Into<Vec<u8>>) -> Self {
94 Self {
95 identity: identity.into(),
96 roots: Vec::new(),
97 excluded_roots: Vec::new(),
98 excluded_names: Vec::new(),
99 }
100 }
101
102 #[must_use]
104 pub fn with_input(mut self, path: impl Into<PathBuf>) -> Self {
105 self.roots.push(path.into());
106 self
107 }
108
109 #[must_use]
111 pub fn with_excluded_root(mut self, path: impl Into<PathBuf>) -> Self {
112 self.excluded_roots.push(path.into());
113 self
114 }
115
116 #[must_use]
118 pub fn with_excluded_entry_name(mut self, name: impl Into<OsString>) -> Self {
119 self.excluded_names.push(name.into());
120 self
121 }
122 fn normalized(&self) -> Result<Self, InputEvidenceError> {
123 let mut spec = self.clone();
124 for path in spec.roots.iter().chain(&spec.excluded_roots) {
125 if !path.is_absolute() {
126 return Err(input_io(
127 path,
128 io::Error::new(io::ErrorKind::InvalidInput, "input path must be absolute"),
129 ));
130 }
131 }
132 for name in &spec.excluded_names {
133 let mut components = Path::new(name).components();
134 if !matches!(components.next(), Some(Component::Normal(_)))
135 || components.next().is_some()
136 {
137 return Err(input_io(
138 Path::new(name),
139 io::Error::new(
140 io::ErrorKind::InvalidInput,
141 "excluded entry must be one normal name",
142 ),
143 ));
144 }
145 }
146 spec.roots.sort();
147 spec.roots.dedup();
148 spec.excluded_roots = spec
149 .excluded_roots
150 .iter()
151 .map(|path| canonicalize_allow_missing(path).map_err(|source| input_io(path, source)))
152 .collect::<Result<_, _>>()?;
153 spec.excluded_roots.sort();
154 spec.excluded_roots.dedup();
155 spec.excluded_names.sort();
156 spec.excluded_names.dedup();
157 Ok(spec)
158 }
159
160 fn observes(&self, path: &Path, value: InputEvidenceValue) -> bool {
161 if self
162 .roots
163 .binary_search_by(|root| root.as_path().cmp(path))
164 .is_ok()
165 {
166 return true;
167 }
168 if value == InputEvidenceValue::Missing {
169 return false;
170 }
171 path.ancestors().skip(1).any(|ancestor| {
172 self.roots
173 .binary_search_by(|root| root.as_path().cmp(ancestor))
174 .is_ok()
175 && path.strip_prefix(ancestor).is_ok_and(|suffix| {
176 suffix.components().all(|component| {
177 !self
178 .excluded_names
179 .iter()
180 .any(|name| name == component.as_os_str())
181 })
182 })
183 })
184 }
185}
186
187impl InputEvidence {
188 pub fn capture(spec: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
197 let spec = spec.normalized()?;
198 let mut entries = BTreeMap::new();
199 let mut visited = BTreeSet::new();
200 let mut walk =
201 InputTreeWalk::evidence(&spec.excluded_roots, &spec.excluded_names, &mut visited);
202 for root in &spec.roots {
203 if entries.contains_key(root) {
207 continue;
208 }
209 walk.visit(root, root, true, &mut |entry| {
210 let (path, value) = match entry {
211 InputTreeEntry::File { path, .. } => {
212 let hash = ic_host_fs::read::hash_file_no_follow(path, u64::MAX)
213 .map_err(io::Error::from)?;
214 (
215 path,
216 InputEvidenceValue::File {
217 sha256: hash.sha256,
218 },
219 )
220 }
221 InputTreeEntry::Directory { path, .. } => (path, InputEvidenceValue::Directory),
222 InputTreeEntry::Missing { path } => (path, InputEvidenceValue::Missing),
223 };
224 if entries
225 .insert(path.to_owned(), value)
226 .is_some_and(|first| first != value)
227 {
228 return Err(io::Error::new(
229 io::ErrorKind::InvalidData,
230 "input changed across overlapping observations",
231 ));
232 }
233 Ok(())
234 })
235 .map_err(|source| input_io(root, source))?;
236 }
237 Ok(Self { spec, entries })
238 }
239
240 #[must_use]
242 pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
243 &self.entries
244 }
245
246 pub fn digest(&self) -> Result<InputDigest, InputEvidenceError> {
257 let mut ordered = self
258 .entries
259 .iter()
260 .map(|(path, value)| {
261 path.to_str().map(|key| (key, value)).ok_or_else(|| {
262 input_io(
263 path,
264 io::Error::new(
265 io::ErrorKind::InvalidData,
266 "input digest requires UTF-8 paths",
267 ),
268 )
269 })
270 })
271 .collect::<Result<Vec<_>, _>>()?;
272 Ok(digest_values(&self.spec.identity, &mut ordered))
273 }
274
275 pub fn digest_relative_to(&self, root: &Path) -> Result<InputDigest, InputEvidenceError> {
289 if !root.is_absolute()
290 || root
291 .components()
292 .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
293 {
294 return Err(input_io(
295 root,
296 io::Error::new(
297 io::ErrorKind::InvalidInput,
298 "digest root must be absolute and normal",
299 ),
300 ));
301 }
302 let mut ordered = self
303 .entries
304 .iter()
305 .map(|(path, value)| {
306 let relative = path.strip_prefix(root).map_err(|_| {
307 input_io(
308 path,
309 io::Error::new(io::ErrorKind::InvalidInput, "input is outside digest root"),
310 )
311 })?;
312 let mut key = String::new();
313 for part in relative.components() {
314 let name = match part {
315 Component::Normal(name) => name.to_str().ok_or_else(|| {
316 input_io(
317 path,
318 io::Error::new(
319 io::ErrorKind::InvalidData,
320 "input digest requires UTF-8 paths",
321 ),
322 )
323 }),
324 _ => Err(input_io(
325 path,
326 io::Error::new(
327 io::ErrorKind::InvalidInput,
328 "input digest requires normal relative paths",
329 ),
330 )),
331 }?;
332 if !key.is_empty() {
333 key.push('/');
334 }
335 key.push_str(name);
336 }
337 Ok((key, value))
338 })
339 .collect::<Result<Vec<_>, InputEvidenceError>>()?;
340 Ok(digest_values(&self.spec.identity, &mut ordered))
341 }
342
343 pub fn validate_after(&self, after: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
358 let after = after.normalized()?;
359 if self.spec.identity != after.identity {
360 return Err(InputEvidenceError::IdentityChanged);
361 }
362 if self.spec.excluded_roots != after.excluded_roots
363 || self.spec.excluded_names != after.excluded_names
364 {
365 return Err(InputEvidenceError::ExclusionsChanged);
366 }
367 let mut combined = after.clone();
368 combined.roots.extend(self.spec.roots.iter().cloned());
369 let mut retained = Self::capture(&combined)?;
370 for (path, before) in &self.entries {
371 if retained.entries.get(path) != Some(before) {
372 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
373 }
374 }
375 for (path, value) in &retained.entries {
376 if self.entries.contains_key(path) {
377 continue;
378 }
379 if self.observed_absence(path) {
380 if *value == InputEvidenceValue::Missing {
381 continue;
382 }
383 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
384 }
385 return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
386 }
387 retained
388 .entries
389 .retain(|path, value| after.observes(path, *value));
390 retained.spec = after;
391 Ok(retained)
392 }
393
394 fn observed_absence(&self, path: &Path) -> bool {
395 let Some(directory) = path
396 .ancestors()
397 .skip(1)
398 .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
399 else {
400 return false;
401 };
402 let Ok(suffix) = path.strip_prefix(directory) else {
403 return false;
404 };
405 if !suffix
406 .components()
407 .all(|component| matches!(component, Component::Normal(_)))
408 {
409 return false;
410 }
411 let Some(Component::Normal(name)) = suffix.components().next() else {
412 return false;
413 };
414 let first = directory.join(name);
415 !self
416 .spec
417 .excluded_names
418 .iter()
419 .any(|excluded| excluded == name)
420 && !self
421 .spec
422 .excluded_roots
423 .iter()
424 .any(|root| first.starts_with(root))
425 && !self.entries.contains_key(&first)
426 }
427}
428
429fn digest_values<K: AsRef<str>>(
430 identity: &[u8],
431 ordered: &mut [(K, &InputEvidenceValue)],
432) -> InputDigest {
433 ordered.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
434 let mut digest = Sha256::new();
435 framed_field(&mut digest, identity);
436 for (path, value) in ordered.iter() {
437 framed_field(&mut digest, path.as_ref().as_bytes());
438 match value {
439 InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
440 InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
441 InputEvidenceValue::File { sha256 } => {
442 let encoded = sha256.hex_bytes();
443 framed_field(&mut digest, &encoded);
444 }
445 }
446 }
447 InputDigest::from_bytes(digest.finalize().into())
448}
449
450fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
451 digest.update(
452 u64::try_from(bytes.len())
453 .expect("field length fits u64")
454 .to_be_bytes(),
455 );
456 digest.update(bytes);
457}
458
459fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
460 let context_path = source
461 .get_ref()
462 .and_then(|error| error.downcast_ref::<InputPathError>())
463 .map_or_else(|| path.to_owned(), |error| error.path.clone());
464 InputEvidenceError::Io {
465 path: context_path,
466 source,
467 }
468}