use crate::{
CcActionContext, CcActionInput, CcBypassReason, MAX_INPUT_BYTES, MAX_MANIFEST_ENTRIES,
MAX_PREDICTED_INPUTS, normalize_components,
};
use mbx_cache_core::{
CacheDigest, FileDigestCache, FileDigestScope, FileIdentity, RecordedFileDigest,
};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const INCLUDE_MANIFEST_PREFIX: &str = "@include-manifest:";
const TIMESTAMP_MACROS: &[&[u8]] = &[b"__DATE__", b"__TIME__", b"__TIMESTAMP__"];
const SCAN_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CcDepfile {
pub files: Vec<PathBuf>,
}
impl CcDepfile {
pub fn read(path: &Path) -> Result<Self, CcBypassReason> {
let contents =
std::fs::read_to_string(path).map_err(|error| CcBypassReason::DepfileRead {
path: path.to_path_buf(),
message: error.to_string(),
})?;
Self::parse(&contents)
}
pub fn parse(contents: &str) -> Result<Self, CcBypassReason> {
let joined = join_continuations(contents)?;
let (_, prerequisites) = joined
.lines()
.find_map(|line| line.split_once(RULE_SEPARATOR))
.ok_or_else(|| CcBypassReason::MalformedDepfile("no dependency rule".into()))?;
let files = split_prerequisites(prerequisites)?;
Ok(Self { files })
}
}
const RULE_SEPARATOR: &str = ": ";
fn join_continuations(contents: &str) -> Result<String, CcBypassReason> {
let mut joined = String::with_capacity(contents.len());
let mut continued = false;
for line in contents.lines() {
let trimmed = line.strip_suffix('\r').unwrap_or(line);
let (text, continues) = match trimmed.strip_suffix('\\') {
Some(text) => (text, true),
None => (trimmed, false),
};
if continued {
joined.push(' ');
}
joined.push_str(text.trim_end_matches(['\t']));
if !continues {
joined.push('\n');
}
continued = continues;
}
if continued {
return Err(CcBypassReason::MalformedDepfile(
"unterminated line continuation".into(),
));
}
Ok(joined)
}
fn split_prerequisites(value: &str) -> Result<Vec<PathBuf>, CcBypassReason> {
let mut files = Vec::new();
let mut current = String::new();
let mut characters = value.chars().peekable();
while let Some(character) = characters.next() {
match character {
' ' | '\t' => {
if !current.is_empty() {
files.push(PathBuf::from(std::mem::take(&mut current)));
}
}
'\\' => match characters.next() {
Some(' ') => current.push(' '),
Some('#') => current.push('#'),
Some(other) => {
return Err(CcBypassReason::MalformedDepfile(format!(
"unmodeled escape \\{other}"
)));
}
None => {
return Err(CcBypassReason::MalformedDepfile(
"trailing escape character".into(),
));
}
},
'$' => match characters.next() {
Some('$') => current.push('$'),
Some(other) => {
return Err(CcBypassReason::MalformedDepfile(format!(
"unmodeled variable reference ${other}"
)));
}
None => {
return Err(CcBypassReason::MalformedDepfile(
"trailing variable reference".into(),
));
}
},
other => current.push(other),
}
}
if !current.is_empty() {
files.push(PathBuf::from(current));
}
Ok(files)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CcDiscoveredInputs {
working_dir: PathBuf,
pub inputs: Vec<CcActionInput>,
}
impl CcDiscoveredInputs {
pub fn collect(
working_dir: &Path,
files: BTreeSet<PathBuf>,
directories: BTreeSet<PathBuf>,
digests: &dyn FileDigestCache,
) -> Result<Self, CcBypassReason> {
if !working_dir.is_absolute() {
return Err(CcBypassReason::RelativeWorkingDirectory(
working_dir.to_path_buf(),
));
}
if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
return Err(CcBypassReason::TooManyInputs);
}
let working_dir = normalize_components(working_dir);
let mut inputs = Vec::with_capacity(files.len() + directories.len());
let mut total_bytes = 0_u64;
let mut identified = Vec::with_capacity(files.len());
for path in files {
let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
path: path.clone(),
message: error.to_string(),
})?;
if !metadata.is_file() {
return Err(CcBypassReason::InputRead {
path,
message: "input is not a regular file".into(),
});
}
total_bytes = total_bytes.saturating_add(metadata.len());
if total_bytes > MAX_INPUT_BYTES {
return Err(CcBypassReason::TooManyInputs);
}
let identity = FileIdentity::describe(&path, &metadata);
identified.push((path, identity));
}
let queries = identified
.iter()
.filter_map(|(_, identity)| identity.clone())
.collect::<Vec<_>>();
let mut recorded = digests.find(FileDigestScope::CcInput, &queries).into_iter();
let mut fresh = Vec::new();
for (path, identity) in identified {
let remembered = identity
.as_ref()
.and_then(|_| recorded.next().flatten())
.filter(|digest| {
identity
.as_ref()
.is_some_and(|identity| identity.len == digest.size)
});
let digest = match remembered {
Some(digest) => digest,
None => {
if contains_timestamp_macro(&path)? {
return Err(CcBypassReason::EmbeddedTimestampMacro(path));
}
let digest = CacheDigest::blake3_file(&path).map_err(|error| {
CcBypassReason::InputRead {
path: path.clone(),
message: error.to_string(),
}
})?;
if let Some(identity) = identity
&& identity.len == digest.size
{
fresh.push(RecordedFileDigest {
file: identity,
digest: digest.clone(),
});
}
digest
}
};
inputs.push(CcActionInput { path, digest });
}
if !fresh.is_empty() {
digests.record(FileDigestScope::CcInput, fresh);
}
let mut manifest_entries = 0_usize;
for directory in directories {
let digest = include_manifest(&directory, &mut manifest_entries)?;
inputs.push(CcActionInput {
path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
digest,
});
}
Ok(Self {
working_dir,
inputs,
})
}
pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
self.inputs
.iter()
.filter(|input| !is_manifest_input(&input.path))
}
pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
for input in self.files() {
let modified = std::fs::metadata(&input.path)
.and_then(|metadata| metadata.modified())
.map_err(|error| CcBypassReason::InputRead {
path: input.path.clone(),
message: error.to_string(),
})?;
if modified >= started_at {
return Err(CcBypassReason::InputModifiedDuringCompilation(
input.path.clone(),
));
}
}
Ok(())
}
pub fn verify(&self) -> Result<(), CcBypassReason> {
for input in self.files() {
let matches = input.digest.matches_file(&input.path).map_err(|error| {
CcBypassReason::InputRead {
path: input.path.clone(),
message: error.to_string(),
}
})?;
if !matches {
return Err(CcBypassReason::InputChanged(input.path.clone()));
}
}
Ok(())
}
pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
if normalize_components(&context.working_dir) != self.working_dir {
return Err(CcBypassReason::DiscoveryWorkingDirectory);
}
context.inputs.extend(self.inputs);
Ok(())
}
}
fn is_manifest_input(path: &Path) -> bool {
path.to_str()
.is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
}
pub fn manifest_snapshot(
directories: &BTreeSet<PathBuf>,
) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
let mut budget = 0_usize;
directories
.iter()
.map(|directory| {
include_manifest(directory, &mut budget).map(|digest| (directory.clone(), digest))
})
.collect()
}
const INCLUDABLE_EXTENSIONS: &[&str] = &[
"c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
"ipp", "pch", "tcc",
];
fn is_includable(name: &str) -> bool {
match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
.binary_search(&extension.to_ascii_lowercase().as_str())
.is_ok(),
_ => !name.starts_with('.'),
}
}
fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
let mut names = Vec::new();
let mut pending = vec![(directory.to_path_buf(), String::new())];
while let Some((current, prefix)) = pending.pop() {
let entries = match std::fs::read_dir(¤t) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(CcBypassReason::InputRead {
path: current,
message: error.to_string(),
});
}
};
for entry in entries {
let entry = entry.map_err(|error| CcBypassReason::InputRead {
path: current.clone(),
message: error.to_string(),
})?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
return Err(CcBypassReason::NonUtf8Path(entry.path()));
};
let relative = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
};
let file_type = entry
.file_type()
.map_err(|error| CcBypassReason::InputRead {
path: entry.path(),
message: error.to_string(),
})?;
if file_type.is_dir() {
pending.push((entry.path(), relative));
continue;
}
if !is_includable(name) {
continue;
}
*budget += 1;
if *budget > MAX_MANIFEST_ENTRIES {
return Err(CcBypassReason::TooManyInputs);
}
names.push(relative);
}
}
names.sort();
Ok(CacheDigest::blake3(names.join("\n").as_bytes()))
}
fn contains_timestamp_macro(path: &Path) -> Result<bool, CcBypassReason> {
let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let longest = TIMESTAMP_MACROS
.iter()
.map(|macro_name| macro_name.len())
.max()
.unwrap_or_default();
let mut reader = std::io::BufReader::new(file);
let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
loop {
let read = reader
.read(&mut chunk)
.map_err(|error| CcBypassReason::InputRead {
path: path.to_path_buf(),
message: error.to_string(),
})?;
if read == 0 {
return Ok(false);
}
window.extend_from_slice(&chunk[..read]);
if TIMESTAMP_MACROS
.iter()
.any(|macro_name| contains_subslice(&window, macro_name))
{
return Ok(true);
}
let keep = window.len().saturating_sub(longest.saturating_sub(1));
window.drain(..keep);
}
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
#[cfg(test)]
#[path = "depfile_tests.rs"]
mod tests;