1use crate::{
4 CcActionContext, CcActionInput, CcBypassReason, CcCompilerFamily, MAX_INPUT_BYTES,
5 MAX_MANIFEST_ENTRIES, 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 read_for(path: &Path, family: CcCompilerFamily) -> Result<Self, CcBypassReason> {
43 if family.is_msvc() {
44 Self::read_msvc(path)
45 } else {
46 Self::read(path)
47 }
48 }
49
50 pub fn read_msvc(path: &Path) -> Result<Self, CcBypassReason> {
52 let contents = std::fs::read(path).map_err(|error| CcBypassReason::DepfileRead {
53 path: path.to_path_buf(),
54 message: error.to_string(),
55 })?;
56 let value: serde_json::Value = serde_json::from_slice(&contents)
57 .map_err(|error| CcBypassReason::MalformedDepfile(error.to_string()))?;
58 let data = value
59 .get("Data")
60 .and_then(serde_json::Value::as_object)
61 .ok_or_else(|| CcBypassReason::MalformedDepfile("missing Data object".into()))?;
62 if data
63 .get("ImportedModules")
64 .and_then(serde_json::Value::as_array)
65 .is_some_and(|modules| !modules.is_empty())
66 || data.get("ProvidedModule").is_some_and(|module| {
67 !module.is_null() && module.as_str().is_none_or(|s| !s.is_empty())
68 })
69 {
70 return Err(CcBypassReason::MalformedDepfile(
71 "C++ module dependencies are not modeled".into(),
72 ));
73 }
74 let includes = data
75 .get("Includes")
76 .and_then(serde_json::Value::as_array)
77 .ok_or_else(|| CcBypassReason::MalformedDepfile("missing Includes array".into()))?;
78 let files = includes
79 .iter()
80 .map(|entry| {
81 entry.as_str().map(PathBuf::from).ok_or_else(|| {
82 CcBypassReason::MalformedDepfile("non-string include path".into())
83 })
84 })
85 .collect::<Result<Vec<_>, _>>()?;
86 Ok(Self { files })
87 }
88
89 pub fn parse(contents: &str) -> Result<Self, CcBypassReason> {
95 let joined = join_continuations(contents)?;
96 let (_, prerequisites) = joined
97 .lines()
98 .find_map(|line| line.split_once(RULE_SEPARATOR))
99 .ok_or_else(|| CcBypassReason::MalformedDepfile("no dependency rule".into()))?;
100 let files = split_prerequisites(prerequisites)?;
101 Ok(Self { files })
102 }
103}
104
105const RULE_SEPARATOR: &str = ": ";
106
107fn join_continuations(contents: &str) -> Result<String, CcBypassReason> {
109 let mut joined = String::with_capacity(contents.len());
110 let mut continued = false;
111 for line in contents.lines() {
112 let trimmed = line.strip_suffix('\r').unwrap_or(line);
113 let (text, continues) = match trimmed.strip_suffix('\\') {
114 Some(text) => (text, true),
115 None => (trimmed, false),
116 };
117 if continued {
118 joined.push(' ');
119 }
120 joined.push_str(text.trim_end_matches(['\t']));
121 if !continues {
122 joined.push('\n');
123 }
124 continued = continues;
125 }
126 if continued {
127 return Err(CcBypassReason::MalformedDepfile(
128 "unterminated line continuation".into(),
129 ));
130 }
131 Ok(joined)
132}
133
134fn split_prerequisites(value: &str) -> Result<Vec<PathBuf>, CcBypassReason> {
139 let mut files = Vec::new();
140 let mut current = String::new();
141 let mut characters = value.chars().peekable();
142 while let Some(character) = characters.next() {
143 match character {
144 ' ' | '\t' => {
145 if !current.is_empty() {
146 files.push(PathBuf::from(std::mem::take(&mut current)));
147 }
148 }
149 '\\' => match characters.next() {
150 Some(' ') => current.push(' '),
151 Some('#') => current.push('#'),
152 Some(other) => {
153 return Err(CcBypassReason::MalformedDepfile(format!(
154 "unmodeled escape \\{other}"
155 )));
156 }
157 None => {
158 return Err(CcBypassReason::MalformedDepfile(
159 "trailing escape character".into(),
160 ));
161 }
162 },
163 '$' => match characters.next() {
164 Some('$') => current.push('$'),
165 Some(other) => {
166 return Err(CcBypassReason::MalformedDepfile(format!(
167 "unmodeled variable reference ${other}"
168 )));
169 }
170 None => {
171 return Err(CcBypassReason::MalformedDepfile(
172 "trailing variable reference".into(),
173 ));
174 }
175 },
176 other => current.push(other),
177 }
178 }
179 if !current.is_empty() {
180 files.push(PathBuf::from(current));
181 }
182 Ok(files)
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct CcDiscoveredInputs {
188 working_dir: PathBuf,
189 pub inputs: Vec<CcActionInput>,
191}
192
193impl CcDiscoveredInputs {
194 pub fn collect(
201 working_dir: &Path,
202 files: BTreeSet<PathBuf>,
203 directories: BTreeSet<PathBuf>,
204 digests: &dyn FileDigestCache,
205 ) -> Result<Self, CcBypassReason> {
206 if !working_dir.is_absolute() {
207 return Err(CcBypassReason::RelativeWorkingDirectory(
208 working_dir.to_path_buf(),
209 ));
210 }
211 let directories = minimal_manifest_directories(directories);
212 if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
213 return Err(CcBypassReason::TooManyInputs);
214 }
215 let working_dir = normalize_components(working_dir);
216 let mut inputs = Vec::with_capacity(files.len() + directories.len());
217 let mut total_bytes = 0_u64;
218 let mut identified = Vec::with_capacity(files.len());
225 for path in files {
226 let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
227 path: path.clone(),
228 message: error.to_string(),
229 })?;
230 if !metadata.is_file() {
231 return Err(CcBypassReason::InputRead {
232 path,
233 message: "input is not a regular file".into(),
234 });
235 }
236 total_bytes = total_bytes.saturating_add(metadata.len());
237 if total_bytes > MAX_INPUT_BYTES {
238 return Err(CcBypassReason::TooManyInputs);
239 }
240 let identity = FileIdentity::describe(&path, &metadata);
241 identified.push((path, identity));
242 }
243 let queries = identified
244 .iter()
245 .filter_map(|(_, identity)| identity.clone())
246 .collect::<Vec<_>>();
247 let mut recorded = digests.find(FileDigestScope::CcInput, &queries).into_iter();
248 let mut fresh = Vec::new();
249 for (path, identity) in identified {
250 let remembered = identity
251 .as_ref()
252 .and_then(|_| recorded.next().flatten())
253 .filter(|digest| {
254 identity
255 .as_ref()
256 .is_some_and(|identity| identity.len == digest.size)
257 });
258 let digest = match remembered {
259 Some(digest) => digest,
260 None => {
261 if contains_timestamp_macro(&path)? {
262 return Err(CcBypassReason::EmbeddedTimestampMacro(path));
263 }
264 let digest = CacheDigest::blake3_file(&path).map_err(|error| {
265 CcBypassReason::InputRead {
266 path: path.clone(),
267 message: error.to_string(),
268 }
269 })?;
270 if let Some(identity) = identity
271 && identity.len == digest.size
272 {
273 fresh.push(RecordedFileDigest {
274 file: identity,
275 digest: digest.clone(),
276 });
277 }
278 digest
279 }
280 };
281 inputs.push(CcActionInput { path, digest });
282 }
283 if !fresh.is_empty() {
284 digests.record(FileDigestScope::CcInput, fresh);
285 }
286 let mut manifest_entries = 0_usize;
287 for directory in directories {
288 let digest = include_manifest(&directory, &mut manifest_entries)?;
289 inputs.push(CcActionInput {
290 path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
291 digest,
292 });
293 }
294 Ok(Self {
295 working_dir,
296 inputs,
297 })
298 }
299
300 pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
302 self.inputs
303 .iter()
304 .filter(|input| !is_manifest_input(&input.path))
305 }
306
307 pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
314 for input in self.files() {
315 let modified = std::fs::metadata(&input.path)
316 .and_then(|metadata| metadata.modified())
317 .map_err(|error| CcBypassReason::InputRead {
318 path: input.path.clone(),
319 message: error.to_string(),
320 })?;
321 if modified >= started_at {
322 return Err(CcBypassReason::InputModifiedDuringCompilation(
323 input.path.clone(),
324 ));
325 }
326 }
327 Ok(())
328 }
329
330 pub fn verify(&self) -> Result<(), CcBypassReason> {
333 for input in self.files() {
334 let matches = input.digest.matches_file(&input.path).map_err(|error| {
335 CcBypassReason::InputRead {
336 path: input.path.clone(),
337 message: error.to_string(),
338 }
339 })?;
340 if !matches {
341 return Err(CcBypassReason::InputChanged(input.path.clone()));
342 }
343 }
344 Ok(())
345 }
346
347 pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
350 if normalize_components(&context.working_dir) != self.working_dir {
351 return Err(CcBypassReason::DiscoveryWorkingDirectory);
352 }
353 context.inputs.extend(self.inputs);
354 Ok(())
355 }
356}
357
358fn minimal_manifest_directories(directories: BTreeSet<PathBuf>) -> Vec<PathBuf> {
366 let mut directories = directories
367 .into_iter()
368 .map(|directory| {
369 let normalized = normalize_components(&directory);
370 (directory, normalized)
371 })
372 .collect::<Vec<_>>();
373 directories.sort_by(|(left, left_normalized), (right, right_normalized)| {
374 left_normalized
375 .components()
376 .count()
377 .cmp(&right_normalized.components().count())
378 .then_with(|| left_normalized.cmp(right_normalized))
379 .then_with(|| left.cmp(right))
380 });
381
382 let mut minimal = Vec::<(PathBuf, PathBuf)>::new();
383 for (directory, normalized) in directories {
384 if !minimal
385 .iter()
386 .any(|(_, ancestor)| manifest_covers(ancestor, &normalized))
387 {
388 minimal.push((directory, normalized));
389 }
390 }
391 minimal
392 .into_iter()
393 .map(|(directory, _)| directory)
394 .collect()
395}
396
397fn manifest_covers(ancestor: &Path, descendant: &Path) -> bool {
404 let Ok(relative) = descendant.strip_prefix(ancestor) else {
405 return false;
406 };
407 if relative.as_os_str().is_empty() {
408 return false;
409 }
410 let mut current = ancestor.to_path_buf();
411 for component in relative.components() {
412 current.push(component);
413 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
414 return false;
415 };
416 if !metadata.is_dir() || metadata.file_type().is_symlink() {
417 return false;
418 }
419 }
420 true
421}
422
423fn is_manifest_input(path: &Path) -> bool {
424 path.to_str()
425 .is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
426}
427
428pub fn manifest_snapshot(
436 directories: &BTreeSet<PathBuf>,
437) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
438 let mut budget = 0_usize;
439 minimal_manifest_directories(directories.iter().cloned().collect())
440 .into_iter()
441 .map(|directory| {
442 include_manifest(&directory, &mut budget).map(|digest| (directory, digest))
443 })
444 .collect()
445}
446
447const INCLUDABLE_EXTENSIONS: &[&str] = &[
458 "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
459 "ipp", "pch", "tcc",
460];
461
462fn is_includable(name: &str) -> bool {
471 match name.rsplit_once('.') {
472 Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
473 .binary_search(&extension.to_ascii_lowercase().as_str())
474 .is_ok(),
475 _ => !name.starts_with('.'),
477 }
478}
479
480fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
487 let mut names = Vec::new();
488 let mut pending = vec![(directory.to_path_buf(), String::new())];
489 while let Some((current, prefix)) = pending.pop() {
490 let entries = match std::fs::read_dir(¤t) {
491 Ok(entries) => entries,
492 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
493 Err(error) => {
494 return Err(CcBypassReason::InputRead {
495 path: current,
496 message: error.to_string(),
497 });
498 }
499 };
500 for entry in entries {
501 let entry = entry.map_err(|error| CcBypassReason::InputRead {
502 path: current.clone(),
503 message: error.to_string(),
504 })?;
505 let name = entry.file_name();
506 let Some(name) = name.to_str() else {
507 return Err(CcBypassReason::NonUtf8Path(entry.path()));
508 };
509 let relative = if prefix.is_empty() {
510 name.to_string()
511 } else {
512 format!("{prefix}/{name}")
513 };
514 let file_type = entry
515 .file_type()
516 .map_err(|error| CcBypassReason::InputRead {
517 path: entry.path(),
518 message: error.to_string(),
519 })?;
520 if file_type.is_dir() {
521 pending.push((entry.path(), relative));
522 continue;
523 }
524 if !is_includable(name) {
525 continue;
526 }
527 *budget += 1;
528 if *budget > MAX_MANIFEST_ENTRIES {
529 return Err(CcBypassReason::TooManyInputs);
530 }
531 names.push(relative);
532 }
533 }
534 names.sort();
535 Ok(CacheDigest::blake3(names.join("\n").as_bytes()))
536}
537
538fn contains_timestamp_macro(path: &Path) -> Result<bool, CcBypassReason> {
545 let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
546 path: path.to_path_buf(),
547 message: error.to_string(),
548 })?;
549 let longest = TIMESTAMP_MACROS
550 .iter()
551 .map(|macro_name| macro_name.len())
552 .max()
553 .unwrap_or_default();
554 let mut reader = std::io::BufReader::new(file);
555 let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
556 let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
557 loop {
558 let read = reader
559 .read(&mut chunk)
560 .map_err(|error| CcBypassReason::InputRead {
561 path: path.to_path_buf(),
562 message: error.to_string(),
563 })?;
564 if read == 0 {
565 return Ok(false);
566 }
567 window.extend_from_slice(&chunk[..read]);
568 if TIMESTAMP_MACROS
569 .iter()
570 .any(|macro_name| contains_subslice(&window, macro_name))
571 {
572 return Ok(true);
573 }
574 let keep = window.len().saturating_sub(longest.saturating_sub(1));
576 window.drain(..keep);
577 }
578}
579
580fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
581 if needle.is_empty() || haystack.len() < needle.len() {
582 return false;
583 }
584 haystack
585 .windows(needle.len())
586 .any(|window| window == needle)
587}
588
589#[cfg(test)]
590#[path = "depfile_tests.rs"]
591mod tests;