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        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        // Stat everything first so one batched ledger lookup can stand in for
170        // rereading headers the session already scanned and hashed. A ledger
171        // entry in the cc scope was recorded after the timestamp-macro scan
172        // passed, so a hit skips the scan for the same reason it skips the
173        // hash: the identity says the contents have not changed since both
174        // were established.
175        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    /// File inputs, excluding include-directory manifests.
252    pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
253        self.inputs
254            .iter()
255            .filter(|input| !is_manifest_input(&input.path))
256    }
257
258    /// Reject inputs whose modification time overlaps the compiler invocation.
259    ///
260    /// Contents are hashed after the compiler reports the paths it read. This
261    /// timestamp barrier prevents a write that landed during the compile from
262    /// being mistaken for the contents that produced the object; `verify`
263    /// closes the remaining race after hashing.
264    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    /// Rehash every discovered file before publication, degrading a changed
282    /// input to a miss rather than storing an object under a stale key.
283    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    /// Merge the manifest into an action context after confirming both use the
299    /// same compiler working directory.
300    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
314/// Digest the includable names in each directory, reading no file contents.
315///
316/// Taken once before the compiler runs and again before publishing, this is
317/// what detects a header that appeared in a search directory *while* the
318/// compilation was in flight. The manifest recorded in the key is the one from
319/// after the compile, and without this check that later state would be claimed
320/// as the state the compiler saw.
321pub 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
333/// Extensions a file must carry to be a plausible `#include` target.
334///
335/// An extensionless name also qualifies: C++ standard headers are spelled that
336/// way and projects ship their own.
337///
338/// `gch` and `pch` are here because a precompiled header answers an `#include`
339/// without being named by one. GCC prefers `foo.h.gch` over `foo.h` on its own,
340/// with nothing on the command line to say so, which is precisely the
341/// substitution these manifests exist to notice -- and the one case the
342/// adapter's explicit precompiled-header bypass cannot see.
343const INCLUDABLE_EXTENSIONS: &[&str] = &[
344    "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
345    "ipp", "pch", "tcc",
346];
347
348/// Whether a file name could be what an `#include` directive names.
349///
350/// The manifest exists to notice a file appearing where it would shadow a
351/// header that was read. A build writes its own objects, dependency files, and
352/// archives into these directories -- often the very directory a generated
353/// header lives in -- and none of those can shadow an include. Counting them
354/// would make the key depend on how many sibling compilations had finished,
355/// which is not a property of this compilation at all.
356fn 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        // No extension, or a leading-dot name like `.keep`.
362        _ => !name.starts_with('.'),
363    }
364}
365
366/// Digest the sorted includable file names beneath a directory.
367///
368/// Names only: the contents of anything actually read are digested as inputs,
369/// so this exists purely to notice a file appearing where it could shadow one
370/// of them. A directory that does not exist has an empty manifest, which is
371/// what makes "the directory was created" a key change rather than an error.
372fn 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(&current) {
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
424/// Whether a file mentions a macro whose expansion is not a function of the
425/// compilation's inputs.
426///
427/// The token is looked for rather than its expansion: a match inside a comment
428/// or a string literal bypasses a compilation that would in fact have been
429/// cacheable, which is the conservative direction.
430fn 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        // Keep the tail so a token split across two reads is still found.
461        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;