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, 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
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    /// Parse a GNU-style dependency list.
42    ///
43    /// Only the first rule is read. The adapter never passes `-MP`, so a
44    /// well-formed file the adapter asked for has exactly one rule, and
45    /// anything further is ignored rather than guessed at.
46    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
59/// Join physical lines the compiler wrapped with a trailing backslash.
60fn 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
86/// Split a prerequisite list, honoring exactly the escapes make defines.
87///
88/// Anything else escaped is a spelling this parser does not model, and a
89/// mis-parsed prerequisite would silently drop an input from the key.
90fn 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/// A complete, content-addressed compiler input manifest.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct CcDiscoveredInputs {
140    working_dir: PathBuf,
141    /// Content-addressed inputs, including include-directory manifests.
142    pub inputs: Vec<CcActionInput>,
143}
144
145impl CcDiscoveredInputs {
146    /// Digest every file the compilation read, and summarize the directories it
147    /// searched.
148    ///
149    /// Digesting the files answers "did any input change". The directory
150    /// manifests answer the question a dependency list cannot: whether a header
151    /// that was *not* read now exists somewhere that would shadow one that was.
152    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        // Stat everything first so one batched ledger lookup can stand in for
171        // rereading headers the session already scanned and hashed. A ledger
172        // entry in the cc scope was recorded after the timestamp-macro scan
173        // passed, so a hit skips the scan for the same reason it skips the
174        // hash: the identity says the contents have not changed since both
175        // were established.
176        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    /// File inputs, excluding include-directory manifests.
253    pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
254        self.inputs
255            .iter()
256            .filter(|input| !is_manifest_input(&input.path))
257    }
258
259    /// Reject inputs whose modification time overlaps the compiler invocation.
260    ///
261    /// Contents are hashed after the compiler reports the paths it read. This
262    /// timestamp barrier prevents a write that landed during the compile from
263    /// being mistaken for the contents that produced the object; `verify`
264    /// closes the remaining race after hashing.
265    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    /// Rehash every discovered file before publication, degrading a changed
283    /// input to a miss rather than storing an object under a stale key.
284    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    /// Merge the manifest into an action context after confirming both use the
300    /// same compiler working directory.
301    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
310/// Drop include directories already covered by an ancestor's recursive manifest.
311///
312/// Discovered headers often contribute hundreds of nested parent directories,
313/// especially for amalgamated C sources. Keeping both an ancestor and its
314/// descendants walks and hashes the same subtree repeatedly, and can exhaust
315/// the manifest-entry budget even though the ancestor already names every
316/// includable file below it.
317fn 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
349/// Whether walking `ancestor` recursively is guaranteed to visit `descendant`.
350///
351/// Component-aware normalization rejects a lexical prefix that escapes through
352/// `..`. Directory symlinks need an explicit check because `read_dir` follows
353/// the directory it starts at but the recursive walk deliberately does not
354/// follow symlink entries beneath it.
355fn 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(&current) 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
380/// Digest the includable names in each directory, reading no file contents.
381///
382/// Taken once before the compiler runs and again before publishing, this is
383/// what detects a header that appeared in a search directory *while* the
384/// compilation was in flight. The manifest recorded in the key is the one from
385/// after the compile, and without this check that later state would be claimed
386/// as the state the compiler saw.
387pub 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
399/// Extensions a file must carry to be a plausible `#include` target.
400///
401/// An extensionless name also qualifies: C++ standard headers are spelled that
402/// way and projects ship their own.
403///
404/// `gch` and `pch` are here because a precompiled header answers an `#include`
405/// without being named by one. GCC prefers `foo.h.gch` over `foo.h` on its own,
406/// with nothing on the command line to say so, which is precisely the
407/// substitution these manifests exist to notice -- and the one case the
408/// adapter's explicit precompiled-header bypass cannot see.
409const INCLUDABLE_EXTENSIONS: &[&str] = &[
410    "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
411    "ipp", "pch", "tcc",
412];
413
414/// Whether a file name could be what an `#include` directive names.
415///
416/// The manifest exists to notice a file appearing where it would shadow a
417/// header that was read. A build writes its own objects, dependency files, and
418/// archives into these directories -- often the very directory a generated
419/// header lives in -- and none of those can shadow an include. Counting them
420/// would make the key depend on how many sibling compilations had finished,
421/// which is not a property of this compilation at all.
422fn 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        // No extension, or a leading-dot name like `.keep`.
428        _ => !name.starts_with('.'),
429    }
430}
431
432/// Digest the sorted includable file names beneath a directory.
433///
434/// Names only: the contents of anything actually read are digested as inputs,
435/// so this exists purely to notice a file appearing where it could shadow one
436/// of them. A directory that does not exist has an empty manifest, which is
437/// what makes "the directory was created" a key change rather than an error.
438fn 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(&current) {
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
490/// Whether a file mentions a macro whose expansion is not a function of the
491/// compilation's inputs.
492///
493/// The token is looked for rather than its expansion: a match inside a comment
494/// or a string literal bypasses a compilation that would in fact have been
495/// cacheable, which is the conservative direction.
496fn 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        // Keep the tail so a token split across two reads is still found.
527        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;