mod depfiles;
#[cfg(test)]
mod tests;
pub use depfiles::{discover_cargo_depfile_inputs, parse_cargo_depfile, read_cargo_depfile_inputs};
use crate::artifacts::{
cache_fs::canonicalize_allow_missing,
digest::{InputDigest, InputPathError, InputTreeEntry, InputTreeWalk},
};
use sha2::{Digest as _, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
ffi::OsString,
fmt, io,
path::{Component, Path, PathBuf},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InputEvidenceSpec {
identity: Vec<u8>,
roots: Vec<PathBuf>,
excluded_roots: Vec<PathBuf>,
excluded_names: Vec<OsString>,
}
#[derive(Clone, Debug)]
pub struct InputEvidence {
spec: InputEvidenceSpec,
entries: BTreeMap<PathBuf, InputEvidenceValue>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InputEvidenceValue {
Missing,
Directory,
File {
sha256: InputDigest,
},
}
#[derive(Debug)]
pub enum InputEvidenceError {
IdentityChanged,
ExclusionsChanged,
ChangedInput {
path: PathBuf,
},
UnobservedInput {
path: PathBuf,
},
Io {
path: PathBuf,
source: io::Error,
},
}
impl InputEvidenceSpec {
#[must_use]
pub fn new(identity: impl Into<Vec<u8>>) -> Self {
Self {
identity: identity.into(),
roots: Vec::new(),
excluded_roots: Vec::new(),
excluded_names: Vec::new(),
}
}
#[must_use]
pub fn with_input(mut self, path: impl Into<PathBuf>) -> Self {
self.roots.push(path.into());
self
}
#[must_use]
pub fn with_excluded_root(mut self, path: impl Into<PathBuf>) -> Self {
self.excluded_roots.push(path.into());
self
}
#[must_use]
pub fn with_excluded_entry_name(mut self, name: impl Into<OsString>) -> Self {
self.excluded_names.push(name.into());
self
}
}
impl InputEvidence {
pub fn capture(spec: &InputEvidenceSpec) -> Result<Self, InputEvidenceError> {
let mut spec = spec.clone();
for path in spec.roots.iter().chain(&spec.excluded_roots) {
if !path.is_absolute() {
return Err(input_io(
path,
io::Error::new(io::ErrorKind::InvalidInput, "input path must be absolute"),
));
}
}
for name in &spec.excluded_names {
let mut components = Path::new(name).components();
if !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(input_io(
Path::new(name),
io::Error::new(
io::ErrorKind::InvalidInput,
"excluded entry must be one normal name",
),
));
}
}
spec.roots.sort();
spec.roots.dedup();
spec.excluded_roots = spec
.excluded_roots
.iter()
.map(|path| canonicalize_allow_missing(path).map_err(|source| input_io(path, source)))
.collect::<Result<_, _>>()?;
spec.excluded_roots.sort();
spec.excluded_roots.dedup();
spec.excluded_names.sort();
spec.excluded_names.dedup();
let mut entries = BTreeMap::new();
let mut visited = BTreeSet::new();
let mut walk =
InputTreeWalk::evidence(&spec.excluded_roots, &spec.excluded_names, &mut visited);
for root in &spec.roots {
walk.visit(root, root, true, &mut |entry| {
let (path, value) = match entry {
InputTreeEntry::File { path, .. } => {
let hash = ic_host_fs::read::hash_file_no_follow(path, u64::MAX)
.map_err(io::Error::from)?;
(
path,
InputEvidenceValue::File {
sha256: InputDigest::from_bytes(*hash.sha256.as_bytes()),
},
)
}
InputTreeEntry::Directory { path, .. } => (path, InputEvidenceValue::Directory),
InputTreeEntry::Missing { path } => (path, InputEvidenceValue::Missing),
};
if entries
.insert(path.to_owned(), value)
.is_some_and(|first| first != value)
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"input changed across overlapping observations",
));
}
Ok(())
})
.map_err(|source| input_io(root, source))?;
}
Ok(Self { spec, entries })
}
#[must_use]
pub const fn entries(&self) -> &BTreeMap<PathBuf, InputEvidenceValue> {
&self.entries
}
pub fn digest(&self) -> Result<InputDigest, InputEvidenceError> {
let mut ordered = self
.entries
.iter()
.map(|(path, value)| {
path.to_str().map(|key| (key, value)).ok_or_else(|| {
input_io(
path,
io::Error::new(
io::ErrorKind::InvalidData,
"input digest requires UTF-8 paths",
),
)
})
})
.collect::<Result<Vec<_>, _>>()?;
ordered.sort_unstable_by_key(|(path, _)| *path);
let mut digest = Sha256::new();
framed_field(&mut digest, &self.spec.identity);
for (path, value) in ordered {
framed_field(&mut digest, path.as_bytes());
match value {
InputEvidenceValue::Missing => framed_field(&mut digest, b"absent"),
InputEvidenceValue::Directory => framed_field(&mut digest, b"directory"),
InputEvidenceValue::File { sha256 } => {
framed_field(&mut digest, sha256.to_hex().as_bytes());
}
}
}
Ok(InputDigest::from_bytes(digest.finalize().into()))
}
pub fn validate_after(&self, after: &Self) -> Result<(), InputEvidenceError> {
if self.spec.identity != after.spec.identity {
return Err(InputEvidenceError::IdentityChanged);
}
if self.spec.excluded_roots != after.spec.excluded_roots
|| self.spec.excluded_names != after.spec.excluded_names
{
return Err(InputEvidenceError::ExclusionsChanged);
}
let retained = Self::capture(&self.spec)?;
for (path, before) in &self.entries {
if retained.entries.get(path) != Some(before)
|| after.entries.get(path).is_some_and(|value| value != before)
{
return Err(InputEvidenceError::ChangedInput { path: path.clone() });
}
}
for (path, value) in retained.entries.iter().chain(&after.entries) {
if self.entries.contains_key(path) {
continue;
}
if self.observed_absence(path) {
if *value == InputEvidenceValue::Missing {
continue;
}
return Err(InputEvidenceError::ChangedInput { path: path.clone() });
}
return Err(InputEvidenceError::UnobservedInput { path: path.clone() });
}
Ok(())
}
fn observed_absence(&self, path: &Path) -> bool {
let Some(directory) = path
.ancestors()
.skip(1)
.find(|ancestor| self.entries.get(*ancestor) == Some(&InputEvidenceValue::Directory))
else {
return false;
};
let Ok(suffix) = path.strip_prefix(directory) else {
return false;
};
if !suffix
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return false;
}
let Some(Component::Normal(name)) = suffix.components().next() else {
return false;
};
let first = directory.join(name);
!self
.spec
.excluded_names
.iter()
.any(|excluded| excluded == name)
&& !self
.spec
.excluded_roots
.iter()
.any(|root| first.starts_with(root))
&& !self.entries.contains_key(&first)
}
}
fn framed_field(digest: &mut Sha256, bytes: &[u8]) {
digest.update(
u64::try_from(bytes.len())
.expect("field length fits u64")
.to_be_bytes(),
);
digest.update(bytes);
}
fn input_io(path: &Path, source: io::Error) -> InputEvidenceError {
let context_path = source
.get_ref()
.and_then(|error| error.downcast_ref::<InputPathError>())
.map_or_else(|| path.to_owned(), |error| error.path.clone());
InputEvidenceError::Io {
path: context_path,
source,
}
}
impl fmt::Display for InputEvidenceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IdentityChanged => formatter.write_str("non-file input identity changed"),
Self::ExclusionsChanged => formatter.write_str("input exclusion policy changed"),
Self::ChangedInput { path } => write!(formatter, "input changed: {}", path.display()),
Self::UnobservedInput { path } => write!(
formatter,
"input was not observed before work: {}",
path.display()
),
Self::Io { path, source } => write!(
formatter,
"input evidence failed for {}: {source}",
path.display()
),
}
}
}
impl std::error::Error for InputEvidenceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}