1use crate::{
4 CcActionContext, CcActionInput, CcBypassReason, MAX_INPUT_BYTES, MAX_MANIFEST_ENTRIES,
5 MAX_PREDICTED_INPUTS, normalize_components,
6};
7use mbx_cache_core::{
8 CacheDigest, FileDigestCache, FileDigestScope, FileIdentity, RecordedFileDigest,
9};
10use std::collections::{BTreeMap, BTreeSet};
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14
15pub const INCLUDE_MANIFEST_PREFIX: &str = "@include-manifest:";
17
18const TIMESTAMP_MACROS: &[&[u8]] = &[b"__DATE__", b"__TIME__", b"__TIMESTAMP__"];
20
21const SCAN_CHUNK_BYTES: usize = 64 * 1024;
22
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct CcDepfile {
26 pub files: Vec<PathBuf>,
28}
29
30impl CcDepfile {
31 pub fn read(path: &Path) -> Result<Self, CcBypassReason> {
33 let contents =
34 std::fs::read_to_string(path).map_err(|error| CcBypassReason::DepfileRead {
35 path: path.to_path_buf(),
36 message: error.to_string(),
37 })?;
38 Self::parse(&contents)
39 }
40
41 pub fn parse(contents: &str) -> Result<Self, CcBypassReason> {
47 let joined = join_continuations(contents)?;
48 let (_, prerequisites) = joined
49 .lines()
50 .find_map(|line| line.split_once(RULE_SEPARATOR))
51 .ok_or_else(|| CcBypassReason::MalformedDepfile("no dependency rule".into()))?;
52 let files = split_prerequisites(prerequisites)?;
53 Ok(Self { files })
54 }
55}
56
57const RULE_SEPARATOR: &str = ": ";
58
59fn join_continuations(contents: &str) -> Result<String, CcBypassReason> {
61 let mut joined = String::with_capacity(contents.len());
62 let mut continued = false;
63 for line in contents.lines() {
64 let trimmed = line.strip_suffix('\r').unwrap_or(line);
65 let (text, continues) = match trimmed.strip_suffix('\\') {
66 Some(text) => (text, true),
67 None => (trimmed, false),
68 };
69 if continued {
70 joined.push(' ');
71 }
72 joined.push_str(text.trim_end_matches(['\t']));
73 if !continues {
74 joined.push('\n');
75 }
76 continued = continues;
77 }
78 if continued {
79 return Err(CcBypassReason::MalformedDepfile(
80 "unterminated line continuation".into(),
81 ));
82 }
83 Ok(joined)
84}
85
86fn split_prerequisites(value: &str) -> Result<Vec<PathBuf>, CcBypassReason> {
91 let mut files = Vec::new();
92 let mut current = String::new();
93 let mut characters = value.chars().peekable();
94 while let Some(character) = characters.next() {
95 match character {
96 ' ' | '\t' => {
97 if !current.is_empty() {
98 files.push(PathBuf::from(std::mem::take(&mut current)));
99 }
100 }
101 '\\' => match characters.next() {
102 Some(' ') => current.push(' '),
103 Some('#') => current.push('#'),
104 Some(other) => {
105 return Err(CcBypassReason::MalformedDepfile(format!(
106 "unmodeled escape \\{other}"
107 )));
108 }
109 None => {
110 return Err(CcBypassReason::MalformedDepfile(
111 "trailing escape character".into(),
112 ));
113 }
114 },
115 '$' => match characters.next() {
116 Some('$') => current.push('$'),
117 Some(other) => {
118 return Err(CcBypassReason::MalformedDepfile(format!(
119 "unmodeled variable reference ${other}"
120 )));
121 }
122 None => {
123 return Err(CcBypassReason::MalformedDepfile(
124 "trailing variable reference".into(),
125 ));
126 }
127 },
128 other => current.push(other),
129 }
130 }
131 if !current.is_empty() {
132 files.push(PathBuf::from(current));
133 }
134 Ok(files)
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct CcDiscoveredInputs {
140 working_dir: PathBuf,
141 pub inputs: Vec<CcActionInput>,
143}
144
145impl CcDiscoveredInputs {
146 pub fn collect(
153 working_dir: &Path,
154 files: BTreeSet<PathBuf>,
155 directories: BTreeSet<PathBuf>,
156 digests: &dyn FileDigestCache,
157 ) -> Result<Self, CcBypassReason> {
158 if !working_dir.is_absolute() {
159 return Err(CcBypassReason::RelativeWorkingDirectory(
160 working_dir.to_path_buf(),
161 ));
162 }
163 if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
164 return Err(CcBypassReason::TooManyInputs);
165 }
166 let working_dir = normalize_components(working_dir);
167 let mut inputs = Vec::with_capacity(files.len() + directories.len());
168 let mut total_bytes = 0_u64;
169 let mut identified = Vec::with_capacity(files.len());
176 for path in files {
177 let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
178 path: path.clone(),
179 message: error.to_string(),
180 })?;
181 if !metadata.is_file() {
182 return Err(CcBypassReason::InputRead {
183 path,
184 message: "input is not a regular file".into(),
185 });
186 }
187 total_bytes = total_bytes.saturating_add(metadata.len());
188 if total_bytes > MAX_INPUT_BYTES {
189 return Err(CcBypassReason::TooManyInputs);
190 }
191 let identity = FileIdentity::describe(&path, &metadata);
192 identified.push((path, identity));
193 }
194 let queries = identified
195 .iter()
196 .filter_map(|(_, identity)| identity.clone())
197 .collect::<Vec<_>>();
198 let mut recorded = digests.find(FileDigestScope::CcInput, &queries).into_iter();
199 let mut fresh = Vec::new();
200 for (path, identity) in identified {
201 let remembered = identity
202 .as_ref()
203 .and_then(|_| recorded.next().flatten())
204 .filter(|digest| {
205 identity
206 .as_ref()
207 .is_some_and(|identity| identity.len == digest.size)
208 });
209 let digest = match remembered {
210 Some(digest) => digest,
211 None => {
212 if contains_timestamp_macro(&path)? {
213 return Err(CcBypassReason::EmbeddedTimestampMacro(path));
214 }
215 let digest = CacheDigest::blake3_file(&path).map_err(|error| {
216 CcBypassReason::InputRead {
217 path: path.clone(),
218 message: error.to_string(),
219 }
220 })?;
221 if let Some(identity) = identity
222 && identity.len == digest.size
223 {
224 fresh.push(RecordedFileDigest {
225 file: identity,
226 digest: digest.clone(),
227 });
228 }
229 digest
230 }
231 };
232 inputs.push(CcActionInput { path, digest });
233 }
234 if !fresh.is_empty() {
235 digests.record(FileDigestScope::CcInput, fresh);
236 }
237 let mut manifest_entries = 0_usize;
238 for directory in directories {
239 let digest = include_manifest(&directory, &mut manifest_entries)?;
240 inputs.push(CcActionInput {
241 path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
242 digest,
243 });
244 }
245 Ok(Self {
246 working_dir,
247 inputs,
248 })
249 }
250
251 pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
253 self.inputs
254 .iter()
255 .filter(|input| !is_manifest_input(&input.path))
256 }
257
258 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
265 for input in self.files() {
266 let modified = std::fs::metadata(&input.path)
267 .and_then(|metadata| metadata.modified())
268 .map_err(|error| CcBypassReason::InputRead {
269 path: input.path.clone(),
270 message: error.to_string(),
271 })?;
272 if modified >= started_at {
273 return Err(CcBypassReason::InputModifiedDuringCompilation(
274 input.path.clone(),
275 ));
276 }
277 }
278 Ok(())
279 }
280
281 pub fn verify(&self) -> Result<(), CcBypassReason> {
284 for input in self.files() {
285 let matches = input.digest.matches_file(&input.path).map_err(|error| {
286 CcBypassReason::InputRead {
287 path: input.path.clone(),
288 message: error.to_string(),
289 }
290 })?;
291 if !matches {
292 return Err(CcBypassReason::InputChanged(input.path.clone()));
293 }
294 }
295 Ok(())
296 }
297
298 pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
301 if normalize_components(&context.working_dir) != self.working_dir {
302 return Err(CcBypassReason::DiscoveryWorkingDirectory);
303 }
304 context.inputs.extend(self.inputs);
305 Ok(())
306 }
307}
308
309fn is_manifest_input(path: &Path) -> bool {
310 path.to_str()
311 .is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
312}
313
314pub fn manifest_snapshot(
322 directories: &BTreeSet<PathBuf>,
323) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
324 let mut budget = 0_usize;
325 directories
326 .iter()
327 .map(|directory| {
328 include_manifest(directory, &mut budget).map(|digest| (directory.clone(), digest))
329 })
330 .collect()
331}
332
333const INCLUDABLE_EXTENSIONS: &[&str] = &[
344 "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
345 "ipp", "pch", "tcc",
346];
347
348fn is_includable(name: &str) -> bool {
357 match name.rsplit_once('.') {
358 Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
359 .binary_search(&extension.to_ascii_lowercase().as_str())
360 .is_ok(),
361 _ => !name.starts_with('.'),
363 }
364}
365
366fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
373 let mut names = Vec::new();
374 let mut pending = vec![(directory.to_path_buf(), String::new())];
375 while let Some((current, prefix)) = pending.pop() {
376 let entries = match std::fs::read_dir(¤t) {
377 Ok(entries) => entries,
378 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
379 Err(error) => {
380 return Err(CcBypassReason::InputRead {
381 path: current,
382 message: error.to_string(),
383 });
384 }
385 };
386 for entry in entries {
387 let entry = entry.map_err(|error| CcBypassReason::InputRead {
388 path: current.clone(),
389 message: error.to_string(),
390 })?;
391 let name = entry.file_name();
392 let Some(name) = name.to_str() else {
393 return Err(CcBypassReason::NonUtf8Path(entry.path()));
394 };
395 let relative = if prefix.is_empty() {
396 name.to_string()
397 } else {
398 format!("{prefix}/{name}")
399 };
400 let file_type = entry
401 .file_type()
402 .map_err(|error| CcBypassReason::InputRead {
403 path: entry.path(),
404 message: error.to_string(),
405 })?;
406 if file_type.is_dir() {
407 pending.push((entry.path(), relative));
408 continue;
409 }
410 if !is_includable(name) {
411 continue;
412 }
413 *budget += 1;
414 if *budget > MAX_MANIFEST_ENTRIES {
415 return Err(CcBypassReason::TooManyInputs);
416 }
417 names.push(relative);
418 }
419 }
420 names.sort();
421 Ok(CacheDigest::blake3(names.join("\n").as_bytes()))
422}
423
424fn contains_timestamp_macro(path: &Path) -> Result<bool, CcBypassReason> {
431 let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
432 path: path.to_path_buf(),
433 message: error.to_string(),
434 })?;
435 let longest = TIMESTAMP_MACROS
436 .iter()
437 .map(|macro_name| macro_name.len())
438 .max()
439 .unwrap_or_default();
440 let mut reader = std::io::BufReader::new(file);
441 let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
442 let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
443 loop {
444 let read = reader
445 .read(&mut chunk)
446 .map_err(|error| CcBypassReason::InputRead {
447 path: path.to_path_buf(),
448 message: error.to_string(),
449 })?;
450 if read == 0 {
451 return Ok(false);
452 }
453 window.extend_from_slice(&chunk[..read]);
454 if TIMESTAMP_MACROS
455 .iter()
456 .any(|macro_name| contains_subslice(&window, macro_name))
457 {
458 return Ok(true);
459 }
460 let keep = window.len().saturating_sub(longest.saturating_sub(1));
462 window.drain(..keep);
463 }
464}
465
466fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
467 if needle.is_empty() || haystack.len() < needle.len() {
468 return false;
469 }
470 haystack
471 .windows(needle.len())
472 .any(|window| window == needle)
473}
474
475#[cfg(test)]
476#[path = "depfile_tests.rs"]
477mod tests;