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    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, thiserror::Error)]
61pub enum InputEvidenceError {
62    /// The caller's non-file identity changed.
63    #[error("non-file input identity changed")]
64    IdentityChanged,
65    /// Exclusion policy changed between observations.
66    #[error("input exclusion policy changed")]
67    ExclusionsChanged,
68    /// Previously observed bytes, absence or membership changed.
69    #[error("input changed: {}", path.display())]
70    ChangedInput {
71        /// Changed input path.
72        path: PathBuf,
73    },
74    /// An after-inventory path had no pre-build authority.
75    #[error("input was not observed before work: {}", path.display())]
76    UnobservedInput {
77        /// Newly discovered input path.
78        path: PathBuf,
79    },
80    /// Filesystem or input-admission failure, retaining its original cause.
81    #[error("input evidence failed for {}: {source}", path.display())]
82    Io {
83        /// Path being inspected.
84        path: PathBuf,
85        /// Original error.
86        source: io::Error,
87    },
88}
89
90impl InputEvidenceSpec {
91    /// Start an empty recipe with caller-derived environment/tool/profile identity.
92    #[must_use]
93    pub fn new(identity: impl Into<Vec<u8>>) -> Self {
94        Self {
95            identity: identity.into(),
96            roots: Vec::new(),
97            excluded_roots: Vec::new(),
98            excluded_names: Vec::new(),
99        }
100    }
101
102    /// Declare a file, recursive directory, or not-yet-existing input.
103    #[must_use]
104    pub fn with_input(mut self, path: impl Into<PathBuf>) -> Self {
105        self.roots.push(path.into());
106        self
107    }
108
109    /// Exclude one caller-owned generated/output tree.
110    #[must_use]
111    pub fn with_excluded_root(mut self, path: impl Into<PathBuf>) -> Self {
112        self.excluded_roots.push(path.into());
113        self
114    }
115
116    /// Skip this exact child name at every directory level.
117    #[must_use]
118    pub fn with_excluded_entry_name(mut self, name: impl Into<OsString>) -> Self {
119        self.excluded_names.push(name.into());
120        self
121    }
122    fn normalized(&self) -> Result<Self, InputEvidenceError> {
123        let mut spec = self.clone();
124        for path in spec.roots.iter().chain(&spec.excluded_roots) {
125            if !path.is_absolute() {
126                return Err(input_io(
127                    path,
128                    io::Error::new(io::ErrorKind::InvalidInput, "input path must be absolute"),
129                ));
130            }
131        }
132        for name in &spec.excluded_names {
133            let mut components = Path::new(name).components();
134            if !matches!(components.next(), Some(Component::Normal(_)))
135                || components.next().is_some()
136            {
137                return Err(input_io(
138                    Path::new(name),
139                    io::Error::new(
140                        io::ErrorKind::InvalidInput,
141                        "excluded entry must be one normal name",
142                    ),
143                ));
144            }
145        }
146        spec.roots.sort();
147        spec.roots.dedup();
148        spec.excluded_roots = spec
149            .excluded_roots
150            .iter()
151            .map(|path| canonicalize_allow_missing(path).map_err(|source| input_io(path, source)))
152            .collect::<Result<_, _>>()?;
153        spec.excluded_roots.sort();
154        spec.excluded_roots.dedup();
155        spec.excluded_names.sort();
156        spec.excluded_names.dedup();
157        Ok(spec)
158    }
159
160    fn observes(&self, path: &Path, value: InputEvidenceValue) -> bool {
161        if self
162            .roots
163            .binary_search_by(|root| root.as_path().cmp(path))
164            .is_ok()
165        {
166            return true;
167        }
168        if value == InputEvidenceValue::Missing {
169            return false;
170        }
171        path.ancestors().skip(1).any(|ancestor| {
172            self.roots
173                .binary_search_by(|root| root.as_path().cmp(ancestor))
174                .is_ok()
175                && path.strip_prefix(ancestor).is_ok_and(|suffix| {
176                    suffix.components().all(|component| {
177                        !self
178                            .excluded_names
179                            .iter()
180                            .any(|name| name == component.as_os_str())
181                    })
182                })
183        })
184    }
185}
186
187impl InputEvidence {
188    /// Capture input authority without building or publishing an artifact cache.
189    ///
190    /// Directories record membership, including empty directories. Missing roots
191    /// are explicit evidence; links and special files refuse. Keep a genuine
192    /// source write-exclusion boundary through capture, work and validation.
193    ///
194    /// # Errors
195    /// Returns non-absolute paths, invalid exclusions, unsupported files or I/O.
196    pub fn capture(spec: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
197        let spec = spec.normalized()?;
198        let mut entries = BTreeMap::new();
199        let mut visited = BTreeSet::new();
200        let mut walk =
201            InputTreeWalk::evidence(&spec.excluded_roots, &spec.excluded_names, &mut visited);
202        for root in &spec.roots {
203            // A previously traversed root or child already has complete facts under
204            // the same exclusions. Explicit roots beneath skipped names remain absent
205            // from the map and are still visited independently.
206            if entries.contains_key(root) {
207                continue;
208            }
209            walk.visit(root, root, true, &mut |entry| {
210                let (path, value) = match entry {
211                    InputTreeEntry::File { path, .. } => {
212                        let hash = ic_host_fs::read::hash_file_no_follow(path, u64::MAX)
213                            .map_err(io::Error::from)?;
214                        (
215                            path,
216                            InputEvidenceValue::File {
217                                sha256: hash.sha256,
218                            },
219                        )
220                    }
221                    InputTreeEntry::Directory { path, .. } => (path, InputEvidenceValue::Directory),
222                    InputTreeEntry::Missing { path } => (path, InputEvidenceValue::Missing),
223                };
224                if entries
225                    .insert(path.to_owned(), value)
226                    .is_some_and(|first| first != value)
227                {
228                    return Err(io::Error::new(
229                        io::ErrorKind::InvalidData,
230                        "input changed across overlapping observations",
231                    ));
232                }
233                Ok(())
234            })
235            .map_err(|source| input_io(root, source))?;
236        }
237        Ok(Self { spec, entries })
238    }
239
240    /// Borrow the immutable per-path observations for consumer-owned projection.
241    #[must_use]
242    pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
243        &self.entries
244    }
245
246    /// Hash the caller identity and UTF-8 path/value map using big-endian lengths.
247    ///
248    /// Fields are identity, then lexically sorted path/value pairs. Values are
249    /// `absent`, `directory`, or lowercase raw SHA-256 hexadecimal. Each field
250    /// starts with its u64 byte length in big-endian order. This preserves the
251    /// existing length-framed consumer evidence encoding without selecting a
252    /// cache namespace or adding a domain/version prefix.
253    ///
254    /// # Errors
255    /// Returns non-UTF-8 paths that this evidence encoding cannot represent.
256    pub fn digest(&self) -> Result<InputDigest, InputEvidenceError> {
257        let mut ordered = self
258            .entries
259            .iter()
260            .map(|(path, value)| {
261                path.to_str().map(|key| (key, value)).ok_or_else(|| {
262                    input_io(
263                        path,
264                        io::Error::new(
265                            io::ErrorKind::InvalidData,
266                            "input digest requires UTF-8 paths",
267                        ),
268                    )
269                })
270            })
271            .collect::<Result<Vec<_>, _>>()?;
272        Ok(digest_values(&self.spec.identity, &mut ordered))
273    }
274
275    /// Hash the captured inventory using UTF-8 paths relative to an explicit root.
276    ///
277    /// Path components are separated with `/`, so an identical inventory and
278    /// caller identity produce the same digest after checkout relocation. No
279    /// filesystem is read here. Directory membership, missing paths and file
280    /// bytes retain the ordinary evidence encoding; the absolute-path [`Self::digest`]
281    /// remains unchanged. This is descriptive input evidence, not a build or
282    /// executable attestation. Callers own the inventory and its source custody.
283    /// Standard path-component normalization applies, including redundant `.`.
284    ///
285    /// # Errors
286    /// Rejects a relative root, parent traversal, paths outside the root and
287    /// non-UTF-8 relative names instead of dropping inputs.
288    pub fn digest_relative_to(&self, root: &Path) -> Result<InputDigest, InputEvidenceError> {
289        if !root.is_absolute()
290            || root
291                .components()
292                .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
293        {
294            return Err(input_io(
295                root,
296                io::Error::new(
297                    io::ErrorKind::InvalidInput,
298                    "digest root must be absolute and normal",
299                ),
300            ));
301        }
302        let mut ordered = self
303            .entries
304            .iter()
305            .map(|(path, value)| {
306                let relative = path.strip_prefix(root).map_err(|_| {
307                    input_io(
308                        path,
309                        io::Error::new(io::ErrorKind::InvalidInput, "input is outside digest root"),
310                    )
311                })?;
312                let mut key = String::new();
313                for part in relative.components() {
314                    let name = match part {
315                        Component::Normal(name) => name.to_str().ok_or_else(|| {
316                            input_io(
317                                path,
318                                io::Error::new(
319                                    io::ErrorKind::InvalidData,
320                                    "input digest requires UTF-8 paths",
321                                ),
322                            )
323                        }),
324                        _ => Err(input_io(
325                            path,
326                            io::Error::new(
327                                io::ErrorKind::InvalidInput,
328                                "input digest requires normal relative paths",
329                            ),
330                        )),
331                    }?;
332                    if !key.is_empty() {
333                        key.push('/');
334                    }
335                    key.push_str(name);
336                }
337                Ok((key, value))
338            })
339            .collect::<Result<Vec<_>, InputEvidenceError>>()?;
340        Ok(digest_values(&self.spec.identity, &mut ordered))
341    }
342
343    /// Capture and validate a post-work inventory against pre-work authority.
344    ///
345    /// Observes the union of original and selected roots once, so disappearing
346    /// depfiles cannot drop old checks. Returns only the selected inventory,
347    /// preserving its digest encoding. Previously observed paths are not rehashed
348    /// when a declared root overlaps a traversed directory. Keep source custody
349    /// through observation and use of the returned evidence.
350    ///
351    /// Late bytes cannot become pre-build authority. A newly selected missing
352    /// path is admitted only when an earlier complete, non-excluded directory
353    /// scan proved its first child absent.
354    ///
355    /// # Errors
356    /// Returns changed identity/policy/inputs, unobserved inputs or I/O failures.
357    pub fn validate_after(&self, after: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
358        let after = after.normalized()?;
359        if self.spec.identity != after.identity {
360            return Err(InputEvidenceError::IdentityChanged);
361        }
362        if self.spec.excluded_roots != after.excluded_roots
363            || self.spec.excluded_names != after.excluded_names
364        {
365            return Err(InputEvidenceError::ExclusionsChanged);
366        }
367        let mut combined = after.clone();
368        combined.roots.extend(self.spec.roots.iter().cloned());
369        let mut retained = Self::capture(&combined)?;
370        for (path, before) in &self.entries {
371            if retained.entries.get(path) != Some(before) {
372                return Err(InputEvidenceError::ChangedInput { path: path.clone() });
373            }
374        }
375        for (path, value) in &retained.entries {
376            if self.entries.contains_key(path) {
377                continue;
378            }
379            if self.observed_absence(path) {
380                if *value == InputEvidenceValue::Missing {
381                    continue;
382                }
383                return Err(InputEvidenceError::ChangedInput { path: path.clone() });
384            }
385            return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
386        }
387        retained
388            .entries
389            .retain(|path, value| after.observes(path, *value));
390        retained.spec = after;
391        Ok(retained)
392    }
393
394    fn observed_absence(&self, path: &Path) -> bool {
395        let Some(directory) = path
396            .ancestors()
397            .skip(1)
398            .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
399        else {
400            return false;
401        };
402        let Ok(suffix) = path.strip_prefix(directory) else {
403            return false;
404        };
405        if !suffix
406            .components()
407            .all(|component| matches!(component, Component::Normal(_)))
408        {
409            return false;
410        }
411        let Some(Component::Normal(name)) = suffix.components().next() else {
412            return false;
413        };
414        let first = directory.join(name);
415        !self
416            .spec
417            .excluded_names
418            .iter()
419            .any(|excluded| excluded == name)
420            && !self
421                .spec
422                .excluded_roots
423                .iter()
424                .any(|root| first.starts_with(root))
425            && !self.entries.contains_key(&first)
426    }
427}
428
429fn digest_values<K: AsRef<str>>(
430    identity: &[u8],
431    ordered: &mut [(K, &InputEvidenceValue)],
432) -> InputDigest {
433    ordered.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
434    let mut digest = Sha256::new();
435    framed_field(&mut digest, identity);
436    for (path, value) in ordered.iter() {
437        framed_field(&mut digest, path.as_ref().as_bytes());
438        match value {
439            InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
440            InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
441            InputEvidenceValue::File { sha256 } => {
442                let encoded = sha256.hex_bytes();
443                framed_field(&mut digest, &encoded);
444            }
445        }
446    }
447    InputDigest::from_bytes(digest.finalize().into())
448}
449
450fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
451    digest.update(
452        u64::try_from(bytes.len())
453            .expect("field length fits u64")
454            .to_be_bytes(),
455    );
456    digest.update(bytes);
457}
458
459fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
460    let context_path = source
461        .get_ref()
462        .and_then(|error| error.downcast_ref::<InputPathError>())
463        .map_or_else(|| path.to_owned(), |error| error.path.clone());
464    InputEvidenceError::Io {
465        path: context_path,
466        source,
467    }
468}