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 fmt, 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)]
61pub enum InputEvidenceError {
62 IdentityChanged,
64 ExclusionsChanged,
66 ChangedInput {
68 path: PathBuf,
70 },
71 UnobservedInput {
73 path: PathBuf,
75 },
76 Io {
78 path: PathBuf,
80 source: io::Error,
82 },
83}
84
85impl InputEvidenceSpec {
86 #[must_use]
88 pub fn new(identity: impl Into<Vec<u8>>) -> Self {
89 Self {
90 identity: identity.into(),
91 roots: Vec::new(),
92 excluded_roots: Vec::new(),
93 excluded_names: Vec::new(),
94 }
95 }
96
97 #[must_use]
99 pub fn with_input(mut self, path: impl Into<PathBuf>) -> Self {
100 self.roots.push(path.into());
101 self
102 }
103
104 #[must_use]
106 pub fn with_excluded_root(mut self, path: impl Into<PathBuf>) -> Self {
107 self.excluded_roots.push(path.into());
108 self
109 }
110
111 #[must_use]
113 pub fn with_excluded_entry_name(mut self, name: impl Into<OsString>) -> Self {
114 self.excluded_names.push(name.into());
115 self
116 }
117 fn normalized(&self) -> Result<Self, InputEvidenceError> {
118 let mut spec = self.clone();
119 for path in spec.roots.iter().chain(&spec.excluded_roots) {
120 if !path.is_absolute() {
121 return Err(input_io(
122 path,
123 io::Error::new(io::ErrorKind::InvalidInput, "input path must be absolute"),
124 ));
125 }
126 }
127 for name in &spec.excluded_names {
128 let mut components = Path::new(name).components();
129 if !matches!(components.next(), Some(Component::Normal(_)))
130 || components.next().is_some()
131 {
132 return Err(input_io(
133 Path::new(name),
134 io::Error::new(
135 io::ErrorKind::InvalidInput,
136 "excluded entry must be one normal name",
137 ),
138 ));
139 }
140 }
141 spec.roots.sort();
142 spec.roots.dedup();
143 spec.excluded_roots = spec
144 .excluded_roots
145 .iter()
146 .map(|path| canonicalize_allow_missing(path).map_err(|source| input_io(path, source)))
147 .collect::<Result<_, _>>()?;
148 spec.excluded_roots.sort();
149 spec.excluded_roots.dedup();
150 spec.excluded_names.sort();
151 spec.excluded_names.dedup();
152 Ok(spec)
153 }
154
155 fn observes(&self, path: &Path, value: InputEvidenceValue) -> bool {
156 if self
157 .roots
158 .binary_search_by(|root| root.as_path().cmp(path))
159 .is_ok()
160 {
161 return true;
162 }
163 if value == InputEvidenceValue::Missing {
164 return false;
165 }
166 path.ancestors().skip(1).any(|ancestor| {
167 self.roots
168 .binary_search_by(|root| root.as_path().cmp(ancestor))
169 .is_ok()
170 && path.strip_prefix(ancestor).is_ok_and(|suffix| {
171 suffix.components().all(|component| {
172 !self
173 .excluded_names
174 .iter()
175 .any(|name| name == component.as_os_str())
176 })
177 })
178 })
179 }
180}
181
182impl InputEvidence {
183 pub fn capture(spec: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
192 let spec = spec.normalized()?;
193 let mut entries = BTreeMap::new();
194 let mut visited = BTreeSet::new();
195 let mut walk =
196 InputTreeWalk::evidence(&spec.excluded_roots, &spec.excluded_names, &mut visited);
197 for root in &spec.roots {
198 if entries.contains_key(root) {
202 continue;
203 }
204 walk.visit(root, root, true, &mut |entry| {
205 let (path, value) = match entry {
206 InputTreeEntry::File { path, .. } => {
207 let hash = ic_host_fs::read::hash_file_no_follow(path, u64::MAX)
208 .map_err(io::Error::from)?;
209 (
210 path,
211 InputEvidenceValue::File {
212 sha256: hash.sha256,
213 },
214 )
215 }
216 InputTreeEntry::Directory { path, .. } => (path, InputEvidenceValue::Directory),
217 InputTreeEntry::Missing { path } => (path, InputEvidenceValue::Missing),
218 };
219 if entries
220 .insert(path.to_owned(), value)
221 .is_some_and(|first| first != value)
222 {
223 return Err(io::Error::new(
224 io::ErrorKind::InvalidData,
225 "input changed across overlapping observations",
226 ));
227 }
228 Ok(())
229 })
230 .map_err(|source| input_io(root, source))?;
231 }
232 Ok(Self { spec, entries })
233 }
234
235 #[must_use]
237 pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
238 &self.entries
239 }
240
241 pub fn digest(&self) -> Result<InputDigest, InputEvidenceError> {
252 let mut ordered = self
253 .entries
254 .iter()
255 .map(|(path, value)| {
256 path.to_str().map(|key| (key, value)).ok_or_else(|| {
257 input_io(
258 path,
259 io::Error::new(
260 io::ErrorKind::InvalidData,
261 "input digest requires UTF-8 paths",
262 ),
263 )
264 })
265 })
266 .collect::<Result<Vec<_>, _>>()?;
267 Ok(digest_values(&self.spec.identity, &mut ordered))
268 }
269
270 pub fn digest_relative_to(&self, root: &Path) -> Result<InputDigest, InputEvidenceError> {
284 if !root.is_absolute()
285 || root
286 .components()
287 .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
288 {
289 return Err(input_io(
290 root,
291 io::Error::new(
292 io::ErrorKind::InvalidInput,
293 "digest root must be absolute and normal",
294 ),
295 ));
296 }
297 let mut ordered = self
298 .entries
299 .iter()
300 .map(|(path, value)| {
301 let relative = path.strip_prefix(root).map_err(|_| {
302 input_io(
303 path,
304 io::Error::new(io::ErrorKind::InvalidInput, "input is outside digest root"),
305 )
306 })?;
307 let mut key = String::new();
308 for part in relative.components() {
309 let name = match part {
310 Component::Normal(name) => name.to_str().ok_or_else(|| {
311 input_io(
312 path,
313 io::Error::new(
314 io::ErrorKind::InvalidData,
315 "input digest requires UTF-8 paths",
316 ),
317 )
318 }),
319 _ => Err(input_io(
320 path,
321 io::Error::new(
322 io::ErrorKind::InvalidInput,
323 "input digest requires normal relative paths",
324 ),
325 )),
326 }?;
327 if !key.is_empty() {
328 key.push('/');
329 }
330 key.push_str(name);
331 }
332 Ok((key, value))
333 })
334 .collect::<Result<Vec<_>, InputEvidenceError>>()?;
335 Ok(digest_values(&self.spec.identity, &mut ordered))
336 }
337
338 pub fn validate_after(&self, after: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
353 let after = after.normalized()?;
354 if self.spec.identity != after.identity {
355 return Err(InputEvidenceError::IdentityChanged);
356 }
357 if self.spec.excluded_roots != after.excluded_roots
358 || self.spec.excluded_names != after.excluded_names
359 {
360 return Err(InputEvidenceError::ExclusionsChanged);
361 }
362 let mut combined = after.clone();
363 combined.roots.extend(self.spec.roots.iter().cloned());
364 let mut retained = Self::capture(&combined)?;
365 for (path, before) in &self.entries {
366 if retained.entries.get(path) != Some(before) {
367 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
368 }
369 }
370 for (path, value) in &retained.entries {
371 if self.entries.contains_key(path) {
372 continue;
373 }
374 if self.observed_absence(path) {
375 if *value == InputEvidenceValue::Missing {
376 continue;
377 }
378 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
379 }
380 return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
381 }
382 retained
383 .entries
384 .retain(|path, value| after.observes(path, *value));
385 retained.spec = after;
386 Ok(retained)
387 }
388
389 fn observed_absence(&self, path: &Path) -> bool {
390 let Some(directory) = path
391 .ancestors()
392 .skip(1)
393 .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
394 else {
395 return false;
396 };
397 let Ok(suffix) = path.strip_prefix(directory) else {
398 return false;
399 };
400 if !suffix
401 .components()
402 .all(|component| matches!(component, Component::Normal(_)))
403 {
404 return false;
405 }
406 let Some(Component::Normal(name)) = suffix.components().next() else {
407 return false;
408 };
409 let first = directory.join(name);
410 !self
411 .spec
412 .excluded_names
413 .iter()
414 .any(|excluded| excluded == name)
415 && !self
416 .spec
417 .excluded_roots
418 .iter()
419 .any(|root| first.starts_with(root))
420 && !self.entries.contains_key(&first)
421 }
422}
423
424fn digest_values<K: AsRef<str>>(
425 identity: &[u8],
426 ordered: &mut [(K, &InputEvidenceValue)],
427) -> InputDigest {
428 ordered.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
429 let mut digest = Sha256::new();
430 framed_field(&mut digest, identity);
431 for (path, value) in ordered.iter() {
432 framed_field(&mut digest, path.as_ref().as_bytes());
433 match value {
434 InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
435 InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
436 InputEvidenceValue::File { sha256 } => {
437 let encoded = sha256.hex_bytes();
438 framed_field(&mut digest, &encoded);
439 }
440 }
441 }
442 InputDigest::from_bytes(digest.finalize().into())
443}
444
445fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
446 digest.update(
447 u64::try_from(bytes.len())
448 .expect("field length fits u64")
449 .to_be_bytes(),
450 );
451 digest.update(bytes);
452}
453
454fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
455 let context_path = source
456 .get_ref()
457 .and_then(|error| error.downcast_ref::<InputPathError>())
458 .map_or_else(|| path.to_owned(), |error| error.path.clone());
459 InputEvidenceError::Io {
460 path: context_path,
461 source,
462 }
463}
464
465impl fmt::Display for InputEvidenceError {
466 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467 match self {
468 Self::IdentityChanged => formatter.write_str("non-file input identity changed"),
469 Self::ExclusionsChanged => formatter.write_str("input exclusion policy changed"),
470 Self::ChangedInput { path } => write!(formatter, "input changed: {}", path.display()),
471 Self::UnobservedInput { path } => write!(
472 formatter,
473 "input was not observed before work: {}",
474 path.display()
475 ),
476 Self::Io { path, source } => write!(
477 formatter,
478 "input evidence failed for {}: {source}",
479 path.display()
480 ),
481 }
482 }
483}
484
485impl std::error::Error for InputEvidenceError {
486 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
487 match self {
488 Self::Io { source, .. } => Some(source),
489 _ => None,
490 }
491 }
492}