Skip to main content

mbx_cache_cc/
depfile.rs

1//! Dependency-list parsing and input discovery for C and C++ compiles.
2
3use 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
15/// Marker distinguishing an include-directory name manifest from a file input.
16pub const INCLUDE_MANIFEST_PREFIX: &str = "@include-manifest:";
17
18/// Macros whose expansion is not a function of the compilation's inputs.
19const TIMESTAMP_MACROS: &[&[u8]] = &[b"__DATE__", b"__TIME__", b"__TIMESTAMP__"];
20
21const SCAN_CHUNK_BYTES: usize = 64 * 1024;
22
23/// A parsed GNU-style dependency list.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct CcDepfile {
26    /// Prerequisite files named by the first rule.
27    pub files: Vec<PathBuf>,
28}
29
30impl CcDepfile {
31    /// Read and parse the dependency list the compiler wrote.
32    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    /// Read the dependency output emitted by the selected compiler family.
42    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    /// Read MSVC's `/sourceDependencies` JSON output.
51    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    /// Parse a GNU-style dependency list.
90    ///
91    /// Only the first rule is read. The adapter never passes `-MP`, so a
92    /// well-formed file the adapter asked for has exactly one rule, and
93    /// anything further is ignored rather than guessed at.
94    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
107/// Join physical lines the compiler wrapped with a trailing backslash.
108fn 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
134/// Split a prerequisite list, honoring exactly the escapes make defines.
135///
136/// Anything else escaped is a spelling this parser does not model, and a
137/// mis-parsed prerequisite would silently drop an input from the key.
138fn 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/// A complete, content-addressed compiler input manifest.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct CcDiscoveredInputs {
188    working_dir: PathBuf,
189    /// Content-addressed inputs, including include-directory manifests.
190    pub inputs: Vec<CcActionInput>,
191}
192
193impl CcDiscoveredInputs {
194    /// Digest every file the compilation read, and summarize the directories it
195    /// searched.
196    ///
197    /// Digesting the files answers "did any input change". The directory
198    /// manifests answer the question a dependency list cannot: whether a header
199    /// that was *not* read now exists somewhere that would shadow one that was.
200    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        // Stat everything first so one batched ledger lookup can stand in for
219        // rereading headers the session already scanned and hashed. A ledger
220        // entry in the cc scope was recorded after the timestamp-macro scan
221        // passed, so a hit skips the scan for the same reason it skips the
222        // hash: the identity says the contents have not changed since both
223        // were established.
224        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    /// File inputs, excluding include-directory manifests.
301    pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
302        self.inputs
303            .iter()
304            .filter(|input| !is_manifest_input(&input.path))
305    }
306
307    /// Reject inputs whose modification time overlaps the compiler invocation.
308    ///
309    /// Contents are hashed after the compiler reports the paths it read. This
310    /// timestamp barrier prevents a write that landed during the compile from
311    /// being mistaken for the contents that produced the object; `verify`
312    /// closes the remaining race after hashing.
313    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    /// Rehash every discovered file before publication, degrading a changed
331    /// input to a miss rather than storing an object under a stale key.
332    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    /// Merge the manifest into an action context after confirming both use the
348    /// same compiler working directory.
349    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
358/// Drop include directories already covered by an ancestor's recursive manifest.
359///
360/// Discovered headers often contribute hundreds of nested parent directories,
361/// especially for amalgamated C sources. Keeping both an ancestor and its
362/// descendants walks and hashes the same subtree repeatedly, and can exhaust
363/// the manifest-entry budget even though the ancestor already names every
364/// includable file below it.
365fn 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
397/// Whether walking `ancestor` recursively is guaranteed to visit `descendant`.
398///
399/// Component-aware normalization rejects a lexical prefix that escapes through
400/// `..`. Directory symlinks need an explicit check because `read_dir` follows
401/// the directory it starts at but the recursive walk deliberately does not
402/// follow symlink entries beneath it.
403fn 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(&current) 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
428/// Digest the includable names in each directory, reading no file contents.
429///
430/// Taken once before the compiler runs and again before publishing, this is
431/// what detects a header that appeared in a search directory *while* the
432/// compilation was in flight. The manifest recorded in the key is the one from
433/// after the compile, and without this check that later state would be claimed
434/// as the state the compiler saw.
435pub 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
447/// Extensions a file must carry to be a plausible `#include` target.
448///
449/// An extensionless name also qualifies: C++ standard headers are spelled that
450/// way and projects ship their own.
451///
452/// `gch` and `pch` are here because a precompiled header answers an `#include`
453/// without being named by one. GCC prefers `foo.h.gch` over `foo.h` on its own,
454/// with nothing on the command line to say so, which is precisely the
455/// substitution these manifests exist to notice -- and the one case the
456/// adapter's explicit precompiled-header bypass cannot see.
457const INCLUDABLE_EXTENSIONS: &[&str] = &[
458    "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
459    "ipp", "pch", "tcc",
460];
461
462/// Whether a file name could be what an `#include` directive names.
463///
464/// The manifest exists to notice a file appearing where it would shadow a
465/// header that was read. A build writes its own objects, dependency files, and
466/// archives into these directories -- often the very directory a generated
467/// header lives in -- and none of those can shadow an include. Counting them
468/// would make the key depend on how many sibling compilations had finished,
469/// which is not a property of this compilation at all.
470fn 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        // No extension, or a leading-dot name like `.keep`.
476        _ => !name.starts_with('.'),
477    }
478}
479
480/// Digest the sorted includable file names beneath a directory.
481///
482/// Names only: the contents of anything actually read are digested as inputs,
483/// so this exists purely to notice a file appearing where it could shadow one
484/// of them. A directory that does not exist has an empty manifest, which is
485/// what makes "the directory was created" a key change rather than an error.
486fn 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(&current) {
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
538/// Whether a file mentions a macro whose expansion is not a function of the
539/// compilation's inputs.
540///
541/// The token is looked for rather than its expansion: a match inside a comment
542/// or a string literal bypasses a compilation that would in fact have been
543/// cacheable, which is the conservative direction.
544fn 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        // Keep the tail so a token split across two reads is still found.
575        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;