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, FileDigestResolution, FileDigestScope, FileIdentity,
9    FileSnapshot, RecordedFileDigest, digest_file, digest_file_validated,
10};
11use std::collections::{BTreeMap, BTreeSet};
12use std::io::Read;
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16/// Marker distinguishing an include-directory name manifest from a file input.
17pub const INCLUDE_MANIFEST_PREFIX: &str = "@include-manifest:";
18
19/// Directives whose operands are consumed by the assembler, after the
20/// compiler has finished producing its dependency list.
21const ASSEMBLER_INPUT_DIRECTIVES: &[&[u8]] = &[b".include", b".incbin", b".sinclude"];
22
23const SCAN_CHUNK_BYTES: usize = 64 * 1024;
24
25/// A parsed GNU-style dependency list.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct CcDepfile {
28    /// Prerequisite files named by the first rule.
29    pub files: Vec<PathBuf>,
30}
31
32impl CcDepfile {
33    /// Read and parse the dependency list the compiler wrote.
34    pub fn read(path: &Path) -> Result<Self, CcBypassReason> {
35        let contents =
36            std::fs::read_to_string(path).map_err(|error| CcBypassReason::DepfileRead {
37                path: path.to_path_buf(),
38                message: error.to_string(),
39            })?;
40        Self::parse(&contents)
41    }
42
43    /// Read the dependency output emitted by the selected compiler family.
44    pub fn read_for(path: &Path, family: CcCompilerFamily) -> Result<Self, CcBypassReason> {
45        if family.is_msvc() {
46            Self::read_msvc(path)
47        } else {
48            Self::read(path)
49        }
50    }
51
52    /// Read MSVC's `/sourceDependencies` JSON output.
53    pub fn read_msvc(path: &Path) -> Result<Self, CcBypassReason> {
54        let contents = std::fs::read(path).map_err(|error| CcBypassReason::DepfileRead {
55            path: path.to_path_buf(),
56            message: error.to_string(),
57        })?;
58        let value: serde_json::Value = serde_json::from_slice(&contents)
59            .map_err(|error| CcBypassReason::MalformedDepfile(error.to_string()))?;
60        let data = value
61            .get("Data")
62            .and_then(serde_json::Value::as_object)
63            .ok_or_else(|| CcBypassReason::MalformedDepfile("missing Data object".into()))?;
64        if data
65            .get("ImportedModules")
66            .and_then(serde_json::Value::as_array)
67            .is_some_and(|modules| !modules.is_empty())
68            || data.get("ProvidedModule").is_some_and(|module| {
69                !module.is_null() && module.as_str().is_none_or(|s| !s.is_empty())
70            })
71        {
72            return Err(CcBypassReason::MalformedDepfile(
73                "C++ module dependencies are not modeled".into(),
74            ));
75        }
76        let includes = data
77            .get("Includes")
78            .and_then(serde_json::Value::as_array)
79            .ok_or_else(|| CcBypassReason::MalformedDepfile("missing Includes array".into()))?;
80        let files = includes
81            .iter()
82            .map(|entry| {
83                entry.as_str().map(PathBuf::from).ok_or_else(|| {
84                    CcBypassReason::MalformedDepfile("non-string include path".into())
85                })
86            })
87            .collect::<Result<Vec<_>, _>>()?;
88        Ok(Self { files })
89    }
90
91    /// Parse a GNU-style dependency list.
92    ///
93    /// Only the first rule is read. The adapter never passes `-MP`, so a
94    /// well-formed file the adapter asked for has exactly one rule, and
95    /// anything further is ignored rather than guessed at.
96    pub fn parse(contents: &str) -> Result<Self, CcBypassReason> {
97        let joined = join_continuations(contents)?;
98        let (_, prerequisites) = joined
99            .lines()
100            .find_map(|line| line.split_once(RULE_SEPARATOR))
101            .ok_or_else(|| CcBypassReason::MalformedDepfile("no dependency rule".into()))?;
102        let files = split_prerequisites(prerequisites)?;
103        Ok(Self { files })
104    }
105}
106
107const RULE_SEPARATOR: &str = ": ";
108
109impl CcDepfile {
110    /// Render the dependency list a caller asked for, the way the driver
111    /// writes one: the rule's targets, then every prerequisite on its own
112    /// continued line, then, for `-MP`, an empty rule per header so make does
113    /// not fail when one is deleted. `source` is the file that was compiled
114    /// and gets no phony rule, exactly as the driver leaves it out.
115    ///
116    /// Escapes are the ones `parse` reads back: a space, a `#`, and a `$` in
117    /// a path. A quoted target gets the same treatment; a literal one is
118    /// written as given, which is what `-MT` promises.
119    pub fn render(
120        targets: &[crate::DepfileTarget],
121        files: &[PathBuf],
122        source: &Path,
123        phony_targets: bool,
124    ) -> String {
125        let mut rendered = String::new();
126        for (index, target) in targets.iter().enumerate() {
127            if index > 0 {
128                rendered.push(' ');
129            }
130            if target.quoted {
131                rendered.push_str(&escape_make_word(&target.name));
132            } else {
133                rendered.push_str(&target.name);
134            }
135        }
136        rendered.push(':');
137        for file in files {
138            rendered.push_str(" \\\n ");
139            rendered.push_str(&escape_make_word(&file.to_string_lossy()));
140        }
141        rendered.push('\n');
142        if phony_targets {
143            for file in files.iter().filter(|file| file.as_path() != source) {
144                rendered.push_str(&escape_make_word(&file.to_string_lossy()));
145                rendered.push_str(":\n");
146            }
147        }
148        rendered
149    }
150}
151
152/// Quote a word for make the way the driver does in a dependency list.
153fn escape_make_word(word: &str) -> String {
154    let mut escaped = String::with_capacity(word.len());
155    for character in word.chars() {
156        match character {
157            ' ' => escaped.push_str("\\ "),
158            '#' => escaped.push_str("\\#"),
159            '$' => escaped.push_str("$$"),
160            other => escaped.push(other),
161        }
162    }
163    escaped
164}
165
166/// Join physical lines the compiler wrapped with a trailing backslash.
167fn join_continuations(contents: &str) -> Result<String, CcBypassReason> {
168    let mut joined = String::with_capacity(contents.len());
169    let mut continued = false;
170    for line in contents.lines() {
171        let trimmed = line.strip_suffix('\r').unwrap_or(line);
172        let (text, continues) = match trimmed.strip_suffix('\\') {
173            Some(text) => (text, true),
174            None => (trimmed, false),
175        };
176        if continued {
177            joined.push(' ');
178        }
179        joined.push_str(text.trim_end_matches(['\t']));
180        if !continues {
181            joined.push('\n');
182        }
183        continued = continues;
184    }
185    if continued {
186        return Err(CcBypassReason::MalformedDepfile(
187            "unterminated line continuation".into(),
188        ));
189    }
190    Ok(joined)
191}
192
193/// Split a prerequisite list, honoring exactly the escapes make defines.
194///
195/// Anything else escaped is a spelling this parser does not model, and a
196/// mis-parsed prerequisite would silently drop an input from the key.
197fn split_prerequisites(value: &str) -> Result<Vec<PathBuf>, CcBypassReason> {
198    // Most compiler paths contain no make escapes. Copy each whole UTF-8
199    // path once instead of growing a String one character at a time.
200    if memchr::memchr2(b'\\', b'$', value.as_bytes()).is_none() {
201        return Ok(value
202            .split([' ', '\t'])
203            .filter(|word| !word.is_empty())
204            .map(PathBuf::from)
205            .collect());
206    }
207    split_escaped_prerequisites(value)
208}
209
210fn split_escaped_prerequisites(value: &str) -> Result<Vec<PathBuf>, CcBypassReason> {
211    let mut files = Vec::new();
212    let mut current = String::new();
213    let mut characters = value.chars().peekable();
214    while let Some(character) = characters.next() {
215        match character {
216            ' ' | '\t' => {
217                if !current.is_empty() {
218                    files.push(PathBuf::from(std::mem::take(&mut current)));
219                }
220            }
221            '\\' => match characters.next() {
222                Some(' ') => current.push(' '),
223                Some('#') => current.push('#'),
224                Some(other) => {
225                    return Err(CcBypassReason::MalformedDepfile(format!(
226                        "unmodeled escape \\{other}"
227                    )));
228                }
229                None => {
230                    return Err(CcBypassReason::MalformedDepfile(
231                        "trailing escape character".into(),
232                    ));
233                }
234            },
235            '$' => match characters.next() {
236                Some('$') => current.push('$'),
237                Some(other) => {
238                    return Err(CcBypassReason::MalformedDepfile(format!(
239                        "unmodeled variable reference ${other}"
240                    )));
241                }
242                None => {
243                    return Err(CcBypassReason::MalformedDepfile(
244                        "trailing variable reference".into(),
245                    ));
246                }
247            },
248            other => current.push(other),
249        }
250    }
251    if !current.is_empty() {
252        files.push(PathBuf::from(current));
253    }
254    Ok(files)
255}
256
257/// A complete, content-addressed compiler input manifest.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct CcDiscoveredInputs {
260    working_dir: PathBuf,
261    /// Content-addressed inputs, including include-directory manifests.
262    pub inputs: Vec<CcActionInput>,
263    /// What each file input looked like on disk when its digest was
264    /// established, index-aligned with `inputs`; `None` for manifests and
265    /// where the filesystem gave nothing to compare against later.
266    identities: Vec<Option<FileIdentity>>,
267}
268
269impl CcDiscoveredInputs {
270    /// Digest every file the compilation read, and summarize the directories it
271    /// searched.
272    ///
273    /// Digesting the files answers "did any input change". The directory
274    /// manifests answer the question a dependency list cannot: whether a header
275    /// that was *not* read now exists somewhere that would shadow one that was.
276    pub fn collect(
277        working_dir: &Path,
278        files: BTreeSet<PathBuf>,
279        directories: BTreeSet<PathBuf>,
280        digests: &dyn FileDigestCache,
281    ) -> Result<Self, CcBypassReason> {
282        if !working_dir.is_absolute() {
283            return Err(CcBypassReason::RelativeWorkingDirectory(
284                working_dir.to_path_buf(),
285            ));
286        }
287        let directories = minimal_manifest_directories(directories);
288        if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
289            return Err(CcBypassReason::TooManyInputs);
290        }
291        let working_dir = normalize_components(working_dir);
292        let mut inputs = Vec::with_capacity(files.len() + directories.len());
293        let mut total_bytes = 0_u64;
294        // Stat everything first so one batched ledger lookup can stand in for
295        // rereading headers the session already scanned and hashed. A ledger
296        // entry in the cc scope was recorded after the timestamp-macro scan
297        // passed, so a hit skips the scan for the same reason it skips the
298        // hash: the identity says the contents have not changed since both
299        // were established.
300        let mut identified = Vec::with_capacity(files.len());
301        for path in files {
302            let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
303                path: path.clone(),
304                message: error.to_string(),
305            })?;
306            if !metadata.is_file() {
307                return Err(CcBypassReason::InputRead {
308                    path,
309                    message: "input is not a regular file".into(),
310                });
311            }
312            total_bytes = total_bytes.saturating_add(metadata.len());
313            if total_bytes > MAX_INPUT_BYTES {
314                return Err(CcBypassReason::TooManyInputs);
315            }
316            let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
317                CcBypassReason::InputRead {
318                    path: path.clone(),
319                    message: error.to_string(),
320                }
321            })?;
322            identified.push((path, identity));
323        }
324        let queries = identified
325            .iter()
326            .filter_map(|(_, identity)| identity.clone())
327            .collect::<Vec<_>>();
328        let mut recorded = digests
329            .resolve(FileDigestScope::CcInput, &queries)
330            .into_iter();
331        let mut identities = Vec::with_capacity(inputs.capacity());
332        let mut fresh = Vec::new();
333        for (path, identity) in identified {
334            let resolution = identity
335                .as_ref()
336                .and_then(|_| recorded.next())
337                .unwrap_or(FileDigestResolution::Unresolved);
338            let cached = match resolution {
339                FileDigestResolution::Digest(digest)
340                    if identity.as_ref().is_some_and(|identity| {
341                        identity.len == digest.size && identity.still_describes().unwrap_or(false)
342                    }) =>
343                {
344                    Some(Ok(digest))
345                }
346                FileDigestResolution::EmbeddedTimestampMacro
347                    if identity
348                        .as_ref()
349                        .is_some_and(|identity| identity.still_describes().unwrap_or(false)) =>
350                {
351                    Some(Err(CcBypassReason::EmbeddedTimestampMacro(path.clone())))
352                }
353                FileDigestResolution::Digest(_)
354                | FileDigestResolution::EmbeddedTimestampMacro
355                | FileDigestResolution::Unresolved => None,
356            };
357            let digest = if let Some(cached) = cached {
358                identities.push(identity);
359                cached?
360            } else {
361                let observed =
362                    digest_file_validated(FileDigestScope::CcInput, &path).map_err(|error| {
363                        CcBypassReason::InputRead {
364                            path: path.clone(),
365                            message: error.to_string(),
366                        }
367                    })?;
368                let (resolution, observed_identity) = if let Some(observed) = observed {
369                    let identity = observed.cache_identity.clone();
370                    if let FileDigestResolution::Digest(digest) = &observed.resolution
371                        && let Some(file) = observed.cache_identity
372                        && file.len == digest.size
373                    {
374                        fresh.push(RecordedFileDigest {
375                            file,
376                            digest: digest.clone(),
377                        });
378                    }
379                    (observed.resolution, identity)
380                } else {
381                    let resolution =
382                        digest_file(FileDigestScope::CcInput, &path).map_err(|error| {
383                            CcBypassReason::InputRead {
384                                path: path.clone(),
385                                message: error.to_string(),
386                            }
387                        })?;
388                    (resolution, None)
389                };
390                let FileDigestResolution::Digest(digest) = resolution else {
391                    return Err(CcBypassReason::EmbeddedTimestampMacro(path));
392                };
393                identities.push(observed_identity);
394                digest
395            };
396            inputs.push(CcActionInput { path, digest });
397        }
398        if !fresh.is_empty() {
399            digests.record(FileDigestScope::CcInput, fresh);
400        }
401        let mut manifest_entries = 0_usize;
402        for directory in directories {
403            let digest = include_manifest(&directory, &mut manifest_entries)?;
404            inputs.push(CcActionInput {
405                path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
406                digest,
407            });
408            identities.push(None);
409        }
410        Ok(Self {
411            working_dir,
412            inputs,
413            identities,
414        })
415    }
416
417    /// File inputs, excluding include-directory manifests.
418    pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
419        self.inputs
420            .iter()
421            .filter(|input| !is_manifest_input(&input.path))
422    }
423
424    /// Reject inputs whose modification time overlaps the compiler invocation.
425    ///
426    /// Contents are hashed after the compiler reports the paths it read. This
427    /// timestamp barrier prevents a write that landed during the compile from
428    /// being mistaken for the contents that produced the object; `verify`
429    /// closes the remaining race after hashing.
430    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
431        self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
432    }
433
434    /// Reject inputs that changed from snapshots captured before the driver
435    /// ran, falling back to the wall-clock barrier for discovered headers.
436    pub fn verify_not_modified_since_with_snapshots(
437        &self,
438        started_at: SystemTime,
439        before: &BTreeMap<PathBuf, FileSnapshot>,
440    ) -> Result<(), CcBypassReason> {
441        for input in self.files() {
442            if let Some(previous) = before.get(&input.path)
443                && previous.proves_content_change()
444            {
445                let metadata =
446                    std::fs::metadata(&input.path).map_err(|error| CcBypassReason::InputRead {
447                        path: input.path.clone(),
448                        message: error.to_string(),
449                    })?;
450                let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
451                    .map_err(|error| CcBypassReason::InputRead {
452                        path: input.path.clone(),
453                        message: error.to_string(),
454                    })?
455                    .or_else(|| FileIdentity::describe(&input.path, &metadata));
456                if previous.matches(identity.as_ref(), &input.digest) {
457                    continue;
458                }
459                return Err(CcBypassReason::InputModifiedDuringCompilation(
460                    input.path.clone(),
461                ));
462            }
463            let metadata =
464                std::fs::metadata(&input.path).map_err(|error| CcBypassReason::InputRead {
465                    path: input.path.clone(),
466                    message: error.to_string(),
467                })?;
468            let modified = metadata
469                .modified()
470                .map_err(|error| CcBypassReason::InputRead {
471                    path: input.path.clone(),
472                    message: error.to_string(),
473                })?;
474            if modified >= started_at {
475                return Err(CcBypassReason::InputModifiedDuringCompilation(
476                    input.path.clone(),
477                ));
478            }
479        }
480        Ok(())
481    }
482
483    /// Compatibility form for callers that captured metadata identities.
484    pub fn verify_not_modified_since_with_identities(
485        &self,
486        started_at: SystemTime,
487        before: &BTreeMap<PathBuf, FileIdentity>,
488    ) -> Result<(), CcBypassReason> {
489        let snapshots = before
490            .iter()
491            .map(|(path, identity)| (path.clone(), identity.clone().into()))
492            .collect();
493        self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
494    }
495
496    /// Confirm every discovered file before publication, degrading a changed
497    /// input to a miss rather than storing an object under a stale key.
498    ///
499    /// A file still wearing the identity `collect` recorded is confirmed by
500    /// that stat alone where the identity carries a change time, which cannot
501    /// be set from user space and so shows a rewrite that restored the old
502    /// modification time. One whose identity moved, that had none, or that a
503    /// platform without change times described, is read and hashed again.
504    pub fn verify(&self) -> Result<(), CcBypassReason> {
505        for (index, input) in self.inputs.iter().enumerate() {
506            if is_manifest_input(&input.path) {
507                continue;
508            }
509            let read_error = |error: std::io::Error| CcBypassReason::InputRead {
510                path: input.path.clone(),
511                message: error.to_string(),
512            };
513            if let Some(Some(identity)) = self.identities.get(index)
514                && identity.can_skip_content_verification()
515                && identity.still_describes().map_err(read_error)?
516            {
517                continue;
518            }
519            let matches = input.digest.matches_file(&input.path).map_err(|error| {
520                CcBypassReason::InputRead {
521                    path: input.path.clone(),
522                    message: error.to_string(),
523                }
524            })?;
525            if !matches {
526                return Err(CcBypassReason::InputChanged(input.path.clone()));
527            }
528        }
529        Ok(())
530    }
531
532    /// Merge the manifest into an action context after confirming both use the
533    /// same compiler working directory.
534    pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
535        if normalize_components(&context.working_dir) != self.working_dir {
536            return Err(CcBypassReason::DiscoveryWorkingDirectory);
537        }
538        context.inputs.extend(self.inputs);
539        Ok(())
540    }
541}
542
543/// Drop include directories already covered by an ancestor's recursive manifest.
544///
545/// Discovered headers often contribute hundreds of nested parent directories,
546/// especially for amalgamated C sources. Keeping both an ancestor and its
547/// descendants walks and hashes the same subtree repeatedly, and can exhaust
548/// the manifest-entry budget even though the ancestor already names every
549/// includable file below it.
550fn minimal_manifest_directories(directories: BTreeSet<PathBuf>) -> Vec<PathBuf> {
551    let mut directories = directories
552        .into_iter()
553        .map(|directory| {
554            let normalized = manifest_directory_identity(&directory);
555            (directory, normalized)
556        })
557        .collect::<Vec<_>>();
558    directories.sort_by(|(left, left_normalized), (right, right_normalized)| {
559        left_normalized
560            .as_deref()
561            .unwrap_or(left)
562            .components()
563            .count()
564            .cmp(
565                &right_normalized
566                    .as_deref()
567                    .unwrap_or(right)
568                    .components()
569                    .count(),
570            )
571            .then_with(|| left_normalized.cmp(right_normalized))
572            .then_with(|| left.cmp(right))
573    });
574
575    let mut minimal = Vec::<(PathBuf, Option<PathBuf>)>::new();
576    for (directory, normalized) in directories {
577        if !minimal
578            .iter()
579            .any(|(_, ancestor)| match (ancestor, &normalized) {
580                (Some(ancestor), Some(normalized)) => manifest_covers(ancestor, normalized),
581                _ => false,
582            })
583        {
584            minimal.push((directory, normalized));
585        }
586    }
587    minimal
588        .into_iter()
589        .map(|(directory, _)| directory)
590        .collect()
591}
592
593/// Lexical parent removal is safe only when the removed directory exists and
594/// is not a symlink: `link/..` can name a completely different physical tree.
595/// An unprovable path keeps its own manifest instead of joining another root.
596fn manifest_directory_identity(directory: &Path) -> Option<PathBuf> {
597    let mut normalized = PathBuf::new();
598    for component in directory.components() {
599        match component {
600            std::path::Component::CurDir => {}
601            std::path::Component::ParentDir => {
602                let metadata = std::fs::symlink_metadata(&normalized).ok()?;
603                if !metadata.is_dir() || metadata.file_type().is_symlink() {
604                    return None;
605                }
606                normalized.pop();
607            }
608            component => normalized.push(component.as_os_str()),
609        }
610    }
611    Some(normalized)
612}
613
614/// Whether walking `ancestor` recursively is guaranteed to visit `descendant`.
615///
616/// Component-aware normalization rejects a lexical prefix that escapes through
617/// `..`. Directory symlinks need an explicit check because `read_dir` follows
618/// the directory it starts at but the recursive walk deliberately does not
619/// follow symlink entries beneath it.
620fn manifest_covers(ancestor: &Path, descendant: &Path) -> bool {
621    let Ok(relative) = descendant.strip_prefix(ancestor) else {
622        return false;
623    };
624    if relative.as_os_str().is_empty() {
625        return true;
626    }
627    let mut current = ancestor.to_path_buf();
628    for component in relative.components() {
629        current.push(component);
630        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
631            return false;
632        };
633        if !metadata.is_dir() || metadata.file_type().is_symlink() {
634            return false;
635        }
636    }
637    true
638}
639
640fn is_manifest_input(path: &Path) -> bool {
641    path.to_str()
642        .is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
643}
644
645/// Digest the includable names in each directory, reading no file contents.
646///
647/// Taken once before the compiler runs and again before publishing, this is
648/// what detects a header that appeared in a search directory *while* the
649/// compilation was in flight. The manifest recorded in the key is the one from
650/// after the compile, and without this check that later state would be claimed
651/// as the state the compiler saw.
652pub fn manifest_snapshot(
653    directories: &BTreeSet<PathBuf>,
654) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
655    let mut budget = 0_usize;
656    minimal_manifest_directories(directories.iter().cloned().collect())
657        .into_iter()
658        .map(|directory| {
659            include_manifest(&directory, &mut budget).map(|digest| (directory, digest))
660        })
661        .collect()
662}
663
664/// Extensions a file must carry to be a plausible `#include` target.
665///
666/// An extensionless name also qualifies: C++ standard headers are spelled that
667/// way and projects ship their own.
668///
669/// `gch` and `pch` are here because a precompiled header answers an `#include`
670/// without being named by one. GCC prefers `foo.h.gch` over `foo.h` on its own,
671/// with nothing on the command line to say so, which is precisely the
672/// substitution these manifests exist to notice -- and the one case the
673/// adapter's explicit precompiled-header bypass cannot see.
674const INCLUDABLE_EXTENSIONS: &[&str] = &[
675    "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
676    "ipp", "pch", "s", "tcc",
677];
678
679/// Whether a file name could be what an `#include` directive names.
680///
681/// The manifest exists to notice a file appearing where it would shadow a
682/// header that was read. A build writes its own objects, dependency files, and
683/// archives into these directories -- often the very directory a generated
684/// header lives in -- and none of those can shadow an include. Counting them
685/// would make the key depend on how many sibling compilations had finished,
686/// which is not a property of this compilation at all.
687fn is_includable(name: &str) -> bool {
688    match name.rsplit_once('.') {
689        Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
690            .binary_search(&extension.to_ascii_lowercase().as_str())
691            .is_ok(),
692        // No extension, or a leading-dot name like `.keep`.
693        _ => !name.starts_with('.'),
694    }
695}
696
697/// Digest the sorted includable file names beneath a directory.
698///
699/// Names only: the contents of anything actually read are digested as inputs,
700/// so this exists purely to notice a file appearing where it could shadow one
701/// of them. A directory that does not exist has an empty manifest, which is
702/// what makes "the directory was created" a key change rather than an error.
703///
704/// Every call walks the directory afresh. Directory timestamps advance in
705/// coarse ticks on some filesystems, so unchanged metadata cannot prove that no
706/// header appeared or vanished; a cached answer would make the snapshots taken
707/// before and after a compilation agree even when the compiler saw a different
708/// header search result.
709fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
710    include_manifest_with(directory, budget, &|path| std::fs::read_dir(path))
711}
712
713fn include_manifest_with(
714    directory: &Path,
715    budget: &mut usize,
716    read_dir: &impl Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
717) -> Result<CacheDigest, CcBypassReason> {
718    let mut names = Vec::new();
719    let mut pending = vec![(directory.to_path_buf(), String::new())];
720    while let Some((current, prefix)) = pending.pop() {
721        let entries = match read_dir(&current) {
722            Ok(entries) => entries,
723            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
724            Err(error) => {
725                return Err(CcBypassReason::InputRead {
726                    path: current,
727                    message: error.to_string(),
728                });
729            }
730        };
731        for entry in entries {
732            let entry = entry.map_err(|error| CcBypassReason::InputRead {
733                path: current.clone(),
734                message: error.to_string(),
735            })?;
736            let name = entry.file_name();
737            let Some(name) = name.to_str() else {
738                return Err(CcBypassReason::NonUtf8Path(entry.path()));
739            };
740            let relative = if prefix.is_empty() {
741                name.to_string()
742            } else {
743                format!("{prefix}/{name}")
744            };
745            let file_type = entry
746                .file_type()
747                .map_err(|error| CcBypassReason::InputRead {
748                    path: entry.path(),
749                    message: error.to_string(),
750                })?;
751            if file_type.is_dir() {
752                pending.push((entry.path(), relative));
753                continue;
754            }
755            if !is_includable(name) {
756                continue;
757            }
758            *budget += 1;
759            if *budget > MAX_MANIFEST_ENTRIES {
760                return Err(CcBypassReason::TooManyInputs);
761            }
762            names.push(relative);
763        }
764    }
765    names.sort();
766    let digest = CacheDigest::blake3(names.join("\n").as_bytes());
767    Ok(digest)
768}
769
770/// Whether a preprocessor input can make the assembler read another file.
771///
772/// Searching for the directive text, including in comments and inactive
773/// conditional branches, deliberately accepts false positives. Missing a real
774/// directive would publish an object whose complete inputs are absent from the
775/// key; bypassing an otherwise cacheable object is the safe outcome instead.
776pub(crate) fn contains_assembler_input_directive(path: &Path) -> Result<bool, CcBypassReason> {
777    let needles = ASSEMBLER_INPUT_DIRECTIVES;
778    let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
779        path: path.to_path_buf(),
780        message: error.to_string(),
781    })?;
782    let longest = needles
783        .iter()
784        .map(|needle| needle.len())
785        .max()
786        .unwrap_or_default();
787    let mut reader = std::io::BufReader::new(file);
788    let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
789    let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
790    loop {
791        let read = reader
792            .read(&mut chunk)
793            .map_err(|error| CcBypassReason::InputRead {
794                path: path.to_path_buf(),
795                message: error.to_string(),
796            })?;
797        if read == 0 {
798            return Ok(false);
799        }
800        window.extend_from_slice(&chunk[..read]);
801        // Every directive begins with a dot and an ASCII letter. Search for
802        // that letter in either case, then check the dot and complete token.
803        // Searching for the letter also skips runs of dots in assembly labels.
804        if needles.iter().any(|needle| {
805            memchr::memchr2_iter(needle[1], needle[1].to_ascii_uppercase(), &window).any(|offset| {
806                offset.checked_sub(1).is_some_and(|start| {
807                    window[start..]
808                        .get(..needle.len())
809                        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(needle))
810                })
811            })
812        }) {
813            return Ok(true);
814        }
815        let keep = window.len().saturating_sub(longest.saturating_sub(1));
816        window.drain(..keep);
817    }
818}
819
820#[cfg(test)]
821#[path = "depfile_tests.rs"]
822mod tests;
823
824#[cfg(test)]
825mod plain_depfile_tests {
826    use super::*;
827
828    #[test]
829    fn plain_paths_preserve_whitespace_unicode_and_make_escapes() {
830        for input in [
831            "",
832            " \t ",
833            "src/main.c include/header.h",
834            "\t路径/é.c  a#b.h\t",
835            "a\u{a0}b.h",
836            r"a\ b.h a\#b.h a$$b.h",
837            r"bad\q.h",
838            "bad$",
839            "bad\\",
840        ] {
841            let expected = split_escaped_prerequisites(input);
842            let actual = split_prerequisites(input);
843            match (actual, expected) {
844                (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{input}"),
845                (Err(actual), Err(expected)) => {
846                    assert_eq!(format!("{actual:?}"), format!("{expected:?}"))
847                }
848                results => panic!("mismatched results for {input}: {results:?}"),
849            }
850        }
851    }
852
853    #[test]
854    #[ignore = "manual performance measurement; run in release mode"]
855    fn benchmark_plain_depfile() {
856        for count in [256, 4096] {
857            for escaped in [false, true] {
858                let prerequisites = (0..count)
859                    .map(|index| {
860                        format!(
861                            " /workspace/{}/include/library-{index}/header-{index}.h",
862                            if escaped {
863                                r"some\ directory"
864                            } else {
865                                "some-directory"
866                            }
867                        )
868                    })
869                    .collect::<String>();
870                let mut before = Vec::new();
871                let mut after = Vec::new();
872                for round in 0..15 {
873                    for optimized in if round % 2 == 0 {
874                        [false, true]
875                    } else {
876                        [true, false]
877                    } {
878                        let start = std::time::Instant::now();
879                        for _ in 0..50 {
880                            let result = if optimized {
881                                split_prerequisites(std::hint::black_box(&prerequisites))
882                            } else {
883                                split_escaped_prerequisites(std::hint::black_box(&prerequisites))
884                            };
885                            std::hint::black_box(result.unwrap());
886                        }
887                        let sample = start.elapsed().as_secs_f64() * 1e6 / 50.0;
888                        if optimized {
889                            after.push(sample);
890                        } else {
891                            before.push(sample);
892                        }
893                    }
894                }
895                before.sort_by(f64::total_cmp);
896                after.sort_by(f64::total_cmp);
897                eprintln!(
898                    "{count} paths, escaped={escaped}: before {:.3} us, after {:.3} us",
899                    before[7], after[7]
900                );
901            }
902        }
903    }
904}