Skip to main content

ic_build/artifacts/inputs/
mod.rs

1//! Standalone filesystem evidence for caller-owned build inventories.
2
3mod 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/// Caller-selected filesystem roots, exclusions and opaque non-file identity.
22///
23/// Paths must be absolute. No source, output or tool policy is selected implicitly.
24/// Excluded entry names apply to children, not to explicitly declared roots.
25#[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/// Per-path bytes, directory membership or explicit absence observed before work.
34///
35/// Capture/validation requires caller custody over sources and parent directories;
36/// this value does not lock files, admit a release or retain output artifacts.
37#[derive(Clone, Debug)]
38pub struct InputEvidence {
39    spec: InputEvidenceSpec,
40    entries: BTreeMap<PathBuf, InputEvidenceValue>,
41}
42
43/// Exact filesystem fact recorded for an input path.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum InputEvidenceValue {
46    /// The selected path did not exist.
47    Missing,
48    /// A directory whose non-excluded children were traversed.
49    Directory,
50    /// Raw SHA-256 of a regular file, without a digest-domain prefix.
51    File {
52        /// Digest of the complete file bytes.
53        sha256: InputDigest,
54    },
55}
56
57/// Failure to capture or validate standalone input authority.
58#[derive(Debug)]
59pub enum InputEvidenceError {
60    /// The caller's non-file identity changed.
61    IdentityChanged,
62    /// Exclusion policy changed between observations.
63    ExclusionsChanged,
64    /// Previously observed bytes, absence or membership changed.
65    ChangedInput {
66        /// Changed input path.
67        path: PathBuf,
68    },
69    /// An after-inventory path had no pre-build authority.
70    UnobservedInput {
71        /// Newly discovered input path.
72        path: PathBuf,
73    },
74    /// Filesystem or input-admission failure, retaining its original cause.
75    Io {
76        /// Path being inspected.
77        path: PathBuf,
78        /// Original error.
79        source: io::Error,
80    },
81}
82
83impl InputEvidenceSpec {
84    /// Start an empty recipe with caller-derived environment/tool/profile identity.
85    #[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    /// Declare a file, recursive directory, or not-yet-existing input.
96    #[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    /// Exclude one caller-owned generated/output tree.
103    #[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    /// Skip this exact child name at every directory level.
110    #[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    /// Capture input authority without building or publishing an artifact cache.
119    ///
120    /// Directories record membership, including empty directories. Missing roots
121    /// are explicit evidence; links and special files refuse. Keep a genuine
122    /// source write-exclusion boundary through capture, work and validation.
123    ///
124    /// # Errors
125    /// Returns non-absolute paths, invalid exclusions, unsupported files or I/O.
126    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    /// Borrow the immutable per-path observations for consumer-owned projection.
198    #[must_use]
199    pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
200        &self.entries
201    }
202
203    /// Hash the caller identity and UTF-8 path/value map using big-endian lengths.
204    ///
205    /// Fields are identity, then lexically sorted path/value pairs. Values are
206    /// `absent`, `directory`, or lowercase raw SHA-256 hexadecimal. Each field
207    /// starts with its u64 byte length in big-endian order. This preserves the
208    /// existing length-framed consumer evidence encoding without selecting a
209    /// cache namespace or adding a domain/version prefix.
210    ///
211    /// # Errors
212    /// Returns non-UTF-8 paths that this evidence encoding cannot represent.
213    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    /// Hash the captured inventory using UTF-8 paths relative to an explicit root.
233    ///
234    /// Path components are separated with `/`, so an identical inventory and
235    /// caller identity produce the same digest after checkout relocation. No
236    /// filesystem is read here. Directory membership, missing paths and file
237    /// bytes retain the ordinary evidence encoding; the absolute-path [`Self::digest`]
238    /// remains unchanged. This is descriptive input evidence, not a build or
239    /// executable attestation. Callers own the inventory and its source custody.
240    /// Standard path-component normalization applies, including redundant `.`.
241    ///
242    /// # Errors
243    /// Rejects a relative root, parent traversal, paths outside the root and
244    /// non-UTF-8 relative names instead of dropping inputs.
245    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    /// Validate an expanded/replaced inventory against pre-work authority.
297    ///
298    /// Rechecks every original root even when a changed/disappearing depfile no
299    /// longer names it. Late bytes cannot become pre-build authority. A newly
300    /// named missing path is admissible only when a complete earlier directory
301    /// scan proved its first child absent and that child was not excluded.
302    ///
303    /// # Errors
304    /// Returns changed identity/policy/inputs, unobserved inputs or I/O failures.
305    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}