Skip to main content

ic_build/artifacts/inputs/
mod.rs

1//! Standalone filesystem evidence for caller-owned build inventories.
2
3pub(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/// Caller-selected filesystem roots, exclusions and opaque non-file identity.
24///
25/// Paths must be absolute. No source, output or tool policy is selected implicitly.
26/// Excluded entry names apply to children, not to explicitly declared roots.
27#[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/// Per-path bytes, directory membership or explicit absence observed before work.
36///
37/// Capture/validation requires caller custody over sources and parent directories;
38/// this value does not lock files, admit a release or retain output artifacts.
39#[derive(Clone, Debug)]
40pub struct InputEvidence {
41    spec: InputEvidenceSpec,
42    entries: BTreeMap<PathBuf, InputEvidenceValue>,
43}
44
45/// Exact filesystem fact recorded for an input path.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum InputEvidenceValue {
48    /// The selected path did not exist.
49    Missing,
50    /// A directory whose non-excluded children were traversed.
51    Directory,
52    /// Raw SHA-256 of a regular file, without a digest-domain prefix.
53    File {
54        /// Digest of the complete file bytes.
55        sha256: Sha256Digest,
56    },
57}
58
59/// Failure to capture or validate standalone input authority.
60#[derive(Debug)]
61pub enum InputEvidenceError {
62    /// The caller's non-file identity changed.
63    IdentityChanged,
64    /// Exclusion policy changed between observations.
65    ExclusionsChanged,
66    /// Previously observed bytes, absence or membership changed.
67    ChangedInput {
68        /// Changed input path.
69        path: PathBuf,
70    },
71    /// An after-inventory path had no pre-build authority.
72    UnobservedInput {
73        /// Newly discovered input path.
74        path: PathBuf,
75    },
76    /// Filesystem or input-admission failure, retaining its original cause.
77    Io {
78        /// Path being inspected.
79        path: PathBuf,
80        /// Original error.
81        source: io::Error,
82    },
83}
84
85impl InputEvidenceSpec {
86    /// Start an empty recipe with caller-derived environment/tool/profile identity.
87    #[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    /// Declare a file, recursive directory, or not-yet-existing input.
98    #[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    /// Exclude one caller-owned generated/output tree.
105    #[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    /// Skip this exact child name at every directory level.
112    #[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    /// Capture input authority without building or publishing an artifact cache.
184    ///
185    /// Directories record membership, including empty directories. Missing roots
186    /// are explicit evidence; links and special files refuse. Keep a genuine
187    /// source write-exclusion boundary through capture, work and validation.
188    ///
189    /// # Errors
190    /// Returns non-absolute paths, invalid exclusions, unsupported files or I/O.
191    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            // A previously traversed root or child already has complete facts under
199            // the same exclusions. Explicit roots beneath skipped names remain absent
200            // from the map and are still visited independently.
201            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    /// Borrow the immutable per-path observations for consumer-owned projection.
236    #[must_use]
237    pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
238        &self.entries
239    }
240
241    /// Hash the caller identity and UTF-8 path/value map using big-endian lengths.
242    ///
243    /// Fields are identity, then lexically sorted path/value pairs. Values are
244    /// `absent`, `directory`, or lowercase raw SHA-256 hexadecimal. Each field
245    /// starts with its u64 byte length in big-endian order. This preserves the
246    /// existing length-framed consumer evidence encoding without selecting a
247    /// cache namespace or adding a domain/version prefix.
248    ///
249    /// # Errors
250    /// Returns non-UTF-8 paths that this evidence encoding cannot represent.
251    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    /// Hash the captured inventory using UTF-8 paths relative to an explicit root.
271    ///
272    /// Path components are separated with `/`, so an identical inventory and
273    /// caller identity produce the same digest after checkout relocation. No
274    /// filesystem is read here. Directory membership, missing paths and file
275    /// bytes retain the ordinary evidence encoding; the absolute-path [`Self::digest`]
276    /// remains unchanged. This is descriptive input evidence, not a build or
277    /// executable attestation. Callers own the inventory and its source custody.
278    /// Standard path-component normalization applies, including redundant `.`.
279    ///
280    /// # Errors
281    /// Rejects a relative root, parent traversal, paths outside the root and
282    /// non-UTF-8 relative names instead of dropping inputs.
283    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    /// Capture and validate a post-work inventory against pre-work authority.
339    ///
340    /// Observes the union of original and selected roots once, so disappearing
341    /// depfiles cannot drop old checks. Returns only the selected inventory,
342    /// preserving its digest encoding. Previously observed paths are not rehashed
343    /// when a declared root overlaps a traversed directory. Keep source custody
344    /// through observation and use of the returned evidence.
345    ///
346    /// Late bytes cannot become pre-build authority. A newly selected missing
347    /// path is admitted only when an earlier complete, non-excluded directory
348    /// scan proved its first child absent.
349    ///
350    /// # Errors
351    /// Returns changed identity/policy/inputs, unobserved inputs or I/O failures.
352    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}