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