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 parts = relative
270 .components()
271 .map(|part| 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 .collect::<Result<Vec<_>, _>>()?;
290 Ok((parts.join("/"), value))
291 })
292 .collect::<Result<Vec<_>, InputEvidenceError>>()?;
293 Ok(digest_values(&self.spec.identity, &mut ordered))
294 }
295
296 pub fn validate_after(&self, after: &Self) -> Result<(), InputEvidenceError> {
306 if self.spec.identity != after.spec.identity {
307 return Err(InputEvidenceError::IdentityChanged);
308 }
309 if self.spec.excluded_roots != after.spec.excluded_roots
310 || self.spec.excluded_names != after.spec.excluded_names
311 {
312 return Err(InputEvidenceError::ExclusionsChanged);
313 }
314 let retained = Self::capture(&self.spec)?;
315 for (path, before) in &self.entries {
316 if retained.entries.get(path) != Some(before)
317 || after.entries.get(path).is_some_and(|value| value != before)
318 {
319 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
320 }
321 }
322 for (path, value) in retained.entries.iter().chain(&after.entries) {
323 if self.entries.contains_key(path) {
324 continue;
325 }
326 if self.observed_absence(path) {
327 if *value == InputEvidenceValue::Missing {
328 continue;
329 }
330 return Err(InputEvidenceError::ChangedInput { path: path.clone() });
331 }
332 return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
333 }
334 Ok(())
335 }
336
337 fn observed_absence(&self, path: &Path) -> bool {
338 let Some(directory) = path
339 .ancestors()
340 .skip(1)
341 .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
342 else {
343 return false;
344 };
345 let Ok(suffix) = path.strip_prefix(directory) else {
346 return false;
347 };
348 if !suffix
349 .components()
350 .all(|component| matches!(component, Component::Normal(_)))
351 {
352 return false;
353 }
354 let Some(Component::Normal(name)) = suffix.components().next() else {
355 return false;
356 };
357 let first = directory.join(name);
358 !self
359 .spec
360 .excluded_names
361 .iter()
362 .any(|excluded| excluded == name)
363 && !self
364 .spec
365 .excluded_roots
366 .iter()
367 .any(|root| first.starts_with(root))
368 && !self.entries.contains_key(&first)
369 }
370}
371
372fn digest_values<K: AsRef<str>>(
373 identity: &[u8],
374 ordered: &mut [(K, &InputEvidenceValue)],
375) -> InputDigest {
376 ordered.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
377 let mut digest = Sha256::new();
378 framed_field(&mut digest, identity);
379 for (path, value) in ordered.iter() {
380 framed_field(&mut digest, path.as_ref().as_bytes());
381 match value {
382 InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
383 InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
384 InputEvidenceValue::File { sha256 } => {
385 framed_field(&mut digest, sha256.to_hex().as_bytes());
386 }
387 }
388 }
389 InputDigest::from_bytes(digest.finalize().into())
390}
391
392fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
393 digest.update(
394 u64::try_from(bytes.len())
395 .expect("field length fits u64")
396 .to_be_bytes(),
397 );
398 digest.update(bytes);
399}
400
401fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
402 let context_path = source
403 .get_ref()
404 .and_then(|error| error.downcast_ref::<InputPathError>())
405 .map_or_else(|| path.to_owned(), |error| error.path.clone());
406 InputEvidenceError::Io {
407 path: context_path,
408 source,
409 }
410}
411
412impl fmt::Display for InputEvidenceError {
413 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
414 match self {
415 Self::IdentityChanged => formatter.write_str("non-file input identity changed"),
416 Self::ExclusionsChanged => formatter.write_str("input exclusion policy changed"),
417 Self::ChangedInput { path } => write!(formatter, "input changed: {}", path.display()),
418 Self::UnobservedInput { path } => write!(
419 formatter,
420 "input was not observed before work: {}",
421 path.display()
422 ),
423 Self::Io { path, source } => write!(
424 formatter,
425 "input evidence failed for {}: {source}",
426 path.display()
427 ),
428 }
429 }
430}
431
432impl std::error::Error for InputEvidenceError {
433 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
434 match self {
435 Self::Io { source, .. } => Some(source),
436 _ => None,
437 }
438 }
439}