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        ordered.sort_unstable_by_key(|(path, _)| *path);
230        let mut digest = Sha256::new();
231        framed_field(&mut digest, &self.spec.identity);
232        for (path, value) in ordered {
233            framed_field(&mut digest, path.as_bytes());
234            match value {
235                InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
236                InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
237                InputEvidenceValue::File { sha256 } => {
238                    framed_field(&mut digest, sha256.to_hex().as_bytes());
239                }
240            }
241        }
242        Ok(InputDigest::from_bytes(digest.finalize().into()))
243    }
244
245    /// Validate an expanded/replaced inventory against pre-work authority.
246    ///
247    /// Rechecks every original root even when a changed/disappearing depfile no
248    /// longer names it. Late bytes cannot become pre-build authority. A newly
249    /// named missing path is admissible only when a complete earlier directory
250    /// scan proved its first child absent and that child was not excluded.
251    ///
252    /// # Errors
253    /// Returns changed identity/policy/inputs, unobserved inputs or I/O failures.
254    pub fn validate_after(&self, after: &Self) -> Result<(), InputEvidenceError> {
255        if self.spec.identity != after.spec.identity {
256            return Err(InputEvidenceError::IdentityChanged);
257        }
258        if self.spec.excluded_roots != after.spec.excluded_roots
259            || self.spec.excluded_names != after.spec.excluded_names
260        {
261            return Err(InputEvidenceError::ExclusionsChanged);
262        }
263        let retained = Self::capture(&self.spec)?;
264        for (path, before) in &self.entries {
265            if retained.entries.get(path) != Some(before)
266                || after.entries.get(path).is_some_and(|value| value != before)
267            {
268                return Err(InputEvidenceError::ChangedInput { path: path.clone() });
269            }
270        }
271        for (path, value) in retained.entries.iter().chain(&after.entries) {
272            if self.entries.contains_key(path) {
273                continue;
274            }
275            if self.observed_absence(path) {
276                if *value == InputEvidenceValue::Missing {
277                    continue;
278                }
279                return Err(InputEvidenceError::ChangedInput { path: path.clone() });
280            }
281            return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
282        }
283        Ok(())
284    }
285
286    fn observed_absence(&self, path: &Path) -> bool {
287        let Some(directory) = path
288            .ancestors()
289            .skip(1)
290            .find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
291        else {
292            return false;
293        };
294        let Ok(suffix) = path.strip_prefix(directory) else {
295            return false;
296        };
297        if !suffix
298            .components()
299            .all(|component| matches!(component, Component::Normal(_)))
300        {
301            return false;
302        }
303        let Some(Component::Normal(name)) = suffix.components().next() else {
304            return false;
305        };
306        let first = directory.join(name);
307        !self
308            .spec
309            .excluded_names
310            .iter()
311            .any(|excluded| excluded == name)
312            && !self
313                .spec
314                .excluded_roots
315                .iter()
316                .any(|root| first.starts_with(root))
317            && !self.entries.contains_key(&first)
318    }
319}
320
321fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
322    digest.update(
323        u64::try_from(bytes.len())
324            .expect("field length fits u64")
325            .to_be_bytes(),
326    );
327    digest.update(bytes);
328}
329
330fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
331    let context_path = source
332        .get_ref()
333        .and_then(|error| error.downcast_ref::<InputPathError>())
334        .map_or_else(|| path.to_owned(), |error| error.path.clone());
335    InputEvidenceError::Io {
336        path: context_path,
337        source,
338    }
339}
340
341impl fmt::Display for InputEvidenceError {
342    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self {
344            Self::IdentityChanged => formatter.write_str("non-file input identity changed"),
345            Self::ExclusionsChanged => formatter.write_str("input exclusion policy changed"),
346            Self::ChangedInput { path } => write!(formatter, "input changed: {}", path.display()),
347            Self::UnobservedInput { path } => write!(
348                formatter,
349                "input was not observed before work: {}",
350                path.display()
351            ),
352            Self::Io { path, source } => write!(
353                formatter,
354                "input evidence failed for {}: {source}",
355                path.display()
356            ),
357        }
358    }
359}
360
361impl std::error::Error for InputEvidenceError {
362    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
363        match self {
364            Self::Io { source, .. } => Some(source),
365            _ => None,
366        }
367    }
368}