ic_build/artifacts/inputs/
mod.rs1mod 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#[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#[derive(Clone, Debug)]
38pub struct InputEvidence {
39 spec: InputEvidenceSpec,
40 entries: BTreeMap<PathBuf, InputEvidenceValue>,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum InputEvidenceValue {
46 Missing,
48 Directory,
50 File {
52 sha256: InputDigest,
54 },
55}
56
57#[derive(Debug)]
59pub enum InputEvidenceError {
60 IdentityChanged,
62 ExclusionsChanged,
64 ChangedInput {
66 path: PathBuf,
68 },
69 UnobservedInput {
71 path: PathBuf,
73 },
74 Io {
76 path: PathBuf,
78 source: io::Error,
80 },
81}
82
83impl InputEvidenceSpec {
84 #[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 #[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 #[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 #[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 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 #[must_use]
199 pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
200 &self.entries
201 }
202
203 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 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}