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 let directories = minimal_manifest_directories(directories);
164 if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
165 return Err(CcBypassReason::TooManyInputs);
166 }
167 let working_dir = normalize_components(working_dir);
168 let mut inputs = Vec::with_capacity(files.len() + directories.len());
169 let mut total_bytes = 0_u64;
170 let mut identified = Vec::with_capacity(files.len());
177 for path in files {
178 let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
179 path: path.clone(),
180 message: error.to_string(),
181 })?;
182 if !metadata.is_file() {
183 return Err(CcBypassReason::InputRead {
184 path,
185 message: "input is not a regular file".into(),
186 });
187 }
188 total_bytes = total_bytes.saturating_add(metadata.len());
189 if total_bytes > MAX_INPUT_BYTES {
190 return Err(CcBypassReason::TooManyInputs);
191 }
192 let identity = FileIdentity::describe(&path, &metadata);
193 identified.push((path, identity));
194 }
195 let queries = identified
196 .iter()
197 .filter_map(|(_, identity)| identity.clone())
198 .collect::<Vec<_>>();
199 let mut recorded = digests.find(FileDigestScope::CcInput, &queries).into_iter();
200 let mut fresh = Vec::new();
201 for (path, identity) in identified {
202 let remembered = identity
203 .as_ref()
204 .and_then(|_| recorded.next().flatten())
205 .filter(|digest| {
206 identity
207 .as_ref()
208 .is_some_and(|identity| identity.len == digest.size)
209 });
210 let digest = match remembered {
211 Some(digest) => digest,
212 None => {
213 if contains_timestamp_macro(&path)? {
214 return Err(CcBypassReason::EmbeddedTimestampMacro(path));
215 }
216 let digest = CacheDigest::blake3_file(&path).map_err(|error| {
217 CcBypassReason::InputRead {
218 path: path.clone(),
219 message: error.to_string(),
220 }
221 })?;
222 if let Some(identity) = identity
223 && identity.len == digest.size
224 {
225 fresh.push(RecordedFileDigest {
226 file: identity,
227 digest: digest.clone(),
228 });
229 }
230 digest
231 }
232 };
233 inputs.push(CcActionInput { path, digest });
234 }
235 if !fresh.is_empty() {
236 digests.record(FileDigestScope::CcInput, fresh);
237 }
238 let mut manifest_entries = 0_usize;
239 for directory in directories {
240 let digest = include_manifest(&directory, &mut manifest_entries)?;
241 inputs.push(CcActionInput {
242 path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
243 digest,
244 });
245 }
246 Ok(Self {
247 working_dir,
248 inputs,
249 })
250 }
251
252 pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
254 self.inputs
255 .iter()
256 .filter(|input| !is_manifest_input(&input.path))
257 }
258
259 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
266 for input in self.files() {
267 let modified = std::fs::metadata(&input.path)
268 .and_then(|metadata| metadata.modified())
269 .map_err(|error| CcBypassReason::InputRead {
270 path: input.path.clone(),
271 message: error.to_string(),
272 })?;
273 if modified >= started_at {
274 return Err(CcBypassReason::InputModifiedDuringCompilation(
275 input.path.clone(),
276 ));
277 }
278 }
279 Ok(())
280 }
281
282 pub fn verify(&self) -> Result<(), CcBypassReason> {
285 for input in self.files() {
286 let matches = input.digest.matches_file(&input.path).map_err(|error| {
287 CcBypassReason::InputRead {
288 path: input.path.clone(),
289 message: error.to_string(),
290 }
291 })?;
292 if !matches {
293 return Err(CcBypassReason::InputChanged(input.path.clone()));
294 }
295 }
296 Ok(())
297 }
298
299 pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
302 if normalize_components(&context.working_dir) != self.working_dir {
303 return Err(CcBypassReason::DiscoveryWorkingDirectory);
304 }
305 context.inputs.extend(self.inputs);
306 Ok(())
307 }
308}
309
310fn minimal_manifest_directories(directories: BTreeSet<PathBuf>) -> Vec<PathBuf> {
318 let mut directories = directories
319 .into_iter()
320 .map(|directory| {
321 let normalized = normalize_components(&directory);
322 (directory, normalized)
323 })
324 .collect::<Vec<_>>();
325 directories.sort_by(|(left, left_normalized), (right, right_normalized)| {
326 left_normalized
327 .components()
328 .count()
329 .cmp(&right_normalized.components().count())
330 .then_with(|| left_normalized.cmp(right_normalized))
331 .then_with(|| left.cmp(right))
332 });
333
334 let mut minimal = Vec::<(PathBuf, PathBuf)>::new();
335 for (directory, normalized) in directories {
336 if !minimal
337 .iter()
338 .any(|(_, ancestor)| manifest_covers(ancestor, &normalized))
339 {
340 minimal.push((directory, normalized));
341 }
342 }
343 minimal
344 .into_iter()
345 .map(|(directory, _)| directory)
346 .collect()
347}
348
349fn manifest_covers(ancestor: &Path, descendant: &Path) -> bool {
356 let Ok(relative) = descendant.strip_prefix(ancestor) else {
357 return false;
358 };
359 if relative.as_os_str().is_empty() {
360 return false;
361 }
362 let mut current = ancestor.to_path_buf();
363 for component in relative.components() {
364 current.push(component);
365 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
366 return false;
367 };
368 if !metadata.is_dir() || metadata.file_type().is_symlink() {
369 return false;
370 }
371 }
372 true
373}
374
375fn is_manifest_input(path: &Path) -> bool {
376 path.to_str()
377 .is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
378}
379
380pub fn manifest_snapshot(
388 directories: &BTreeSet<PathBuf>,
389) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
390 let mut budget = 0_usize;
391 minimal_manifest_directories(directories.iter().cloned().collect())
392 .into_iter()
393 .map(|directory| {
394 include_manifest(&directory, &mut budget).map(|digest| (directory, digest))
395 })
396 .collect()
397}
398
399const INCLUDABLE_EXTENSIONS: &[&str] = &[
410 "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
411 "ipp", "pch", "tcc",
412];
413
414fn is_includable(name: &str) -> bool {
423 match name.rsplit_once('.') {
424 Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
425 .binary_search(&extension.to_ascii_lowercase().as_str())
426 .is_ok(),
427 _ => !name.starts_with('.'),
429 }
430}
431
432fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
439 let mut names = Vec::new();
440 let mut pending = vec![(directory.to_path_buf(), String::new())];
441 while let Some((current, prefix)) = pending.pop() {
442 let entries = match std::fs::read_dir(¤t) {
443 Ok(entries) => entries,
444 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
445 Err(error) => {
446 return Err(CcBypassReason::InputRead {
447 path: current,
448 message: error.to_string(),
449 });
450 }
451 };
452 for entry in entries {
453 let entry = entry.map_err(|error| CcBypassReason::InputRead {
454 path: current.clone(),
455 message: error.to_string(),
456 })?;
457 let name = entry.file_name();
458 let Some(name) = name.to_str() else {
459 return Err(CcBypassReason::NonUtf8Path(entry.path()));
460 };
461 let relative = if prefix.is_empty() {
462 name.to_string()
463 } else {
464 format!("{prefix}/{name}")
465 };
466 let file_type = entry
467 .file_type()
468 .map_err(|error| CcBypassReason::InputRead {
469 path: entry.path(),
470 message: error.to_string(),
471 })?;
472 if file_type.is_dir() {
473 pending.push((entry.path(), relative));
474 continue;
475 }
476 if !is_includable(name) {
477 continue;
478 }
479 *budget += 1;
480 if *budget > MAX_MANIFEST_ENTRIES {
481 return Err(CcBypassReason::TooManyInputs);
482 }
483 names.push(relative);
484 }
485 }
486 names.sort();
487 Ok(CacheDigest::blake3(names.join("\n").as_bytes()))
488}
489
490fn contains_timestamp_macro(path: &Path) -> Result<bool, CcBypassReason> {
497 let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
498 path: path.to_path_buf(),
499 message: error.to_string(),
500 })?;
501 let longest = TIMESTAMP_MACROS
502 .iter()
503 .map(|macro_name| macro_name.len())
504 .max()
505 .unwrap_or_default();
506 let mut reader = std::io::BufReader::new(file);
507 let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
508 let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
509 loop {
510 let read = reader
511 .read(&mut chunk)
512 .map_err(|error| CcBypassReason::InputRead {
513 path: path.to_path_buf(),
514 message: error.to_string(),
515 })?;
516 if read == 0 {
517 return Ok(false);
518 }
519 window.extend_from_slice(&chunk[..read]);
520 if TIMESTAMP_MACROS
521 .iter()
522 .any(|macro_name| contains_subslice(&window, macro_name))
523 {
524 return Ok(true);
525 }
526 let keep = window.len().saturating_sub(longest.saturating_sub(1));
528 window.drain(..keep);
529 }
530}
531
532fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
533 if needle.is_empty() || haystack.len() < needle.len() {
534 return false;
535 }
536 haystack
537 .windows(needle.len())
538 .any(|window| window == needle)
539}
540
541#[cfg(test)]
542#[path = "depfile_tests.rs"]
543mod tests;