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