ic_build/artifacts/inputs/
mod.rs1mod depfiles;
4#[cfg(test)]
5mod tests;
6
7pub use depfiles::{discover_cargo_depfile_inputs, parse_cargo_depfile, read_cargo_depfile_inputs};
8
9use crate::artifacts::{
10 cache_fs::canonicalize_allow_missing,
11 digest::{InputDigest, InputPathError, InputTreeEntry, InputTreeWalk},
12};
13use ic_host_artifacts::artifact::Sha256Digest;
14use sha2::{Digest as _, Sha256};
15use std::{
16 collections::{BTreeMap, BTreeSet},
17 ffi::OsString,
18 fmt, io,
19 path::{Component, Path, PathBuf},
20};
21
22#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct InputEvidenceSpec {
28 identity: Vec<u8>,
29 roots: Vec<PathBuf>,
30 excluded_roots: Vec<PathBuf>,
31 excluded_names: Vec<OsString>,
32}
33
34#[derive(Clone, Debug)]
39pub struct InputEvidence {
40 spec: InputEvidenceSpec,
41 entries: BTreeMap<PathBuf, InputEvidenceValue>,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum InputEvidenceValue {
47 Missing,
49 Directory,
51 File {
53 sha256: InputDigest,
55 },
56}
57
58#[derive(Debug)]
60pub enum InputEvidenceError {
61 IdentityChanged,
63 ExclusionsChanged,
65 ChangedInput {
67 path: PathBuf,
69 },
70 UnobservedInput {
72 path: PathBuf,
74 },
75 Io {
77 path: PathBuf,
79 source: io::Error,
81 },
82}
83
84impl InputEvidenceSpec {
85 #[must_use]
87 pub fn new(identity: impl Into<Vec<u8>>) -> Self {
88 Self {
89 identity: identity.into(),
90 roots: Vec::new(),
91 excluded_roots: Vec::new(),
92 excluded_names: Vec::new(),
93 }
94 }
95
96 #[must_use]
98 pub fn with_input(mut self, path: impl Into<PathBuf>) -> Self {
99 self.roots.push(path.into());
100 self
101 }
102
103 #[must_use]
105 pub fn with_excluded_root(mut self, path: impl Into<PathBuf>) -> Self {
106 self.excluded_roots.push(path.into());
107 self
108 }
109
110 #[must_use]
112 pub fn with_excluded_entry_name(mut self, name: impl Into<OsString>) -> Self {
113 self.excluded_names.push(name.into());
114 self
115 }
116}
117
118impl InputEvidence {
119 pub fn capture(spec: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
128 let mut spec = spec.clone();
129 for path in spec.roots.iter().chain(&spec.excluded_roots) {
130 if !path.is_absolute() {
131 return Err(input_io(
132 path,
133 io::Error::new(io::ErrorKind::InvalidInput, "input path must be absolute"),
134 ));
135 }
136 }
137 for name in &spec.excluded_names {
138 let mut components = Path::new(name).components();
139 if !matches!(components.next(), Some(Component::Normal(_)))
140 || components.next().is_some()
141 {
142 return Err(input_io(
143 Path::new(name),
144 io::Error::new(
145 io::ErrorKind::InvalidInput,
146 "excluded entry must be one normal name",
147 ),
148 ));
149 }
150 }
151 spec.roots.sort();
152 spec.roots.dedup();
153 spec.excluded_roots = spec
154 .excluded_roots
155 .iter()
156 .map(|path| canonicalize_allow_missing(path).map_err(|source| input_io(path, source)))
157 .collect::<Result<_, _>>()?;
158 spec.excluded_roots.sort();
159 spec.excluded_roots.dedup();
160 spec.excluded_names.sort();
161 spec.excluded_names.dedup();
162 let mut entries = BTreeMap::new();
163 let mut visited = BTreeSet::new();
164 let mut walk =
165 InputTreeWalk::evidence(&spec.excluded_roots, &spec.excluded_names, &mut visited);
166 for root in &spec.roots {
167 walk.visit(root, root, true, &mut |entry| {
168 let (path, value) = match entry {
169 InputTreeEntry::File { path, .. } => {
170 let hash = ic_host_fs::read::hash_file_no_follow(path, u64::MAX)
171 .map_err(io::Error::from)?;
172 (
173 path,
174 InputEvidenceValue::File {
175 sha256: InputDigest::from_bytes(*hash.sha256.as_bytes()),
176 },
177 )
178 }
179 InputTreeEntry::Directory { path, .. } => (path, InputEvidenceValue::Directory),
180 InputTreeEntry::Missing { path } => (path, InputEvidenceValue::Missing),
181 };
182 if entries
183 .insert(path.to_owned(), value)
184 .is_some_and(|first| first != value)
185 {
186 return Err(io::Error::new(
187 io::ErrorKind::InvalidData,
188 "input changed across overlapping observations",
189 ));
190 }
191 Ok(())
192 })
193 .map_err(|source| input_io(root, source))?;
194 }
195 Ok(Self { spec, entries })
196 }
197
198 #[must_use]
200 pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
201 &self.entries
202 }
203
204 pub fn digest(&self) -> Result<InputDigest, InputEvidenceError> {
215 let mut ordered = self
216 .entries
217 .iter()
218 .map(|(path, value)| {
219 path.to_str().map(|key| (key, value)).ok_or_else(|| {
220 input_io(
221 path,
222 io::Error::new(
223 io::ErrorKind::InvalidData,
224 "input digest requires UTF-8 paths",
225 ),
226 )
227 })
228 })
229 .collect::<Result<Vec<_>, _>>()?;
230 Ok(digest_values(&self.spec.identity, &mut ordered))
231 }
232
233 pub fn digest_relative_to(&self, root: &Path) -> Result<InputDigest, InputEvidenceError> {
247 if !root.is_absolute()
248 || root
249 .components()
250 .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
251 {
252 return Err(input_io(
253 root,
254 io::Error::new(
255 io::ErrorKind::InvalidInput,
256 "digest root must be absolute and normal",
257 ),
258 ));
259 }
260 let mut ordered = self
261 .entries
262 .iter()
263 .map(|(path, value)| {
264 let relative = path.strip_prefix(root).map_err(|_| {
265 input_io(
266 path,
267 io::Error::new(io::ErrorKind::InvalidInput, "input is outside digest root"),
268 )
269 })?;
270 let mut key = String::new();
271 for part in relative.components() {
272 let name = match part {
273 Component::Normal(name) => name.to_str().ok_or_else(|| {
274 input_io(
275 path,
276 io::Error::new(
277 io::ErrorKind::InvalidData,
278 "input digest requires UTF-8 paths",
279 ),
280 )
281 }),
282 _ => Err(input_io(
283 path,
284 io::Error::new(
285 io::ErrorKind::InvalidInput,
286 "input digest requires normal relative paths",
287 ),
288 )),
289 }?;
290 if !key.is_empty() {
291 key.push('/');
292 }
293 key.push_str(name);
294 }
295 Ok((key, value))
296 })
297 .collect::<Result<Vec<_>, InputEvidenceError>>()?;
298 Ok(digest_values(&self.spec.identity, &mut ordered))
299 }
300
301 pub fn validate_after(&self, after: &Self) -> Result<(), InputEvidenceError> {
311 if self.spec.identity != after.spec.identity {
312 return Err(InputEvidenceError::IdentityChanged);
313 }
314 if self.spec.excluded_roots != after.spec.excluded_roots
315 || self.spec.excluded_names != after.spec.excluded_names
316 {
317 return Err(InputEvidenceError::ExclusionsChanged);
318 }
319 let retained = Self::capture(&self.spec)?;
320 for (path, before) in &self.entries {
321 if retained.entries.get(path) != Some(before)
322 || after.entries.get(path).is_some_and(|value| value != before)
323 {
324 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
325 }
326 }
327 for (path, value) in retained.entries.iter().chain(&after.entries) {
328 if self.entries.contains_key(path) {
329 continue;
330 }
331 if self.observed_absence(path) {
332 if *value == InputEvidenceValue::Missing {
333 continue;
334 }
335 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
336 }
337 return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
338 }
339 Ok(())
340 }
341
342 fn observed_absence(&self, path: &Path) -> bool {
343 let Some(directory) = path
344 .ancestors()
345 .skip(1)
346 .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
347 else {
348 return false;
349 };
350 let Ok(suffix) = path.strip_prefix(directory) else {
351 return false;
352 };
353 if !suffix
354 .components()
355 .all(|component| matches!(component, Component::Normal(_)))
356 {
357 return false;
358 }
359 let Some(Component::Normal(name)) = suffix.components().next() else {
360 return false;
361 };
362 let first = directory.join(name);
363 !self
364 .spec
365 .excluded_names
366 .iter()
367 .any(|excluded| excluded == name)
368 && !self
369 .spec
370 .excluded_roots
371 .iter()
372 .any(|root| first.starts_with(root))
373 && !self.entries.contains_key(&first)
374 }
375}
376
377fn digest_values<K: AsRef<str>>(
378 identity: &[u8],
379 ordered: &mut [(K, &InputEvidenceValue)],
380) -> InputDigest {
381 ordered.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
382 let mut digest = Sha256::new();
383 framed_field(&mut digest, identity);
384 for (path, value) in ordered.iter() {
385 framed_field(&mut digest, path.as_ref().as_bytes());
386 match value {
387 InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
388 InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
389 InputEvidenceValue::File { sha256 } => {
390 let encoded = Sha256Digest::from_bytes(*sha256.as_bytes()).hex_bytes();
391 framed_field(&mut digest, &encoded);
392 }
393 }
394 }
395 InputDigest::from_bytes(digest.finalize().into())
396}
397
398fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
399 digest.update(
400 u64::try_from(bytes.len())
401 .expect("field length fits u64")
402 .to_be_bytes(),
403 );
404 digest.update(bytes);
405}
406
407fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
408 let context_path = source
409 .get_ref()
410 .and_then(|error| error.downcast_ref::<InputPathError>())
411 .map_or_else(|| path.to_owned(), |error| error.path.clone());
412 InputEvidenceError::Io {
413 path: context_path,
414 source,
415 }
416}
417
418impl fmt::Display for InputEvidenceError {
419 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
420 match self {
421 Self::IdentityChanged => formatter.write_str("non-file input identity changed"),
422 Self::ExclusionsChanged => formatter.write_str("input exclusion policy changed"),
423 Self::ChangedInput { path } => write!(formatter, "input changed: {}", path.display()),
424 Self::UnobservedInput { path } => write!(
425 formatter,
426 "input was not observed before work: {}",
427 path.display()
428 ),
429 Self::Io { path, source } => write!(
430 formatter,
431 "input evidence failed for {}: {source}",
432 path.display()
433 ),
434 }
435 }
436}
437
438impl std::error::Error for InputEvidenceError {
439 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
440 match self {
441 Self::Io { source, .. } => Some(source),
442 _ => None,
443 }
444 }
445}