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    let mut files = Vec::new();
199    let mut current = String::new();
200    let mut characters = value.chars().peekable();
201    while let Some(character) = characters.next() {
202        match character {
203            ' ' | '\t' => {
204                if !current.is_empty() {
205                    files.push(PathBuf::from(std::mem::take(&mut current)));
206                }
207            }
208            '\\' => match characters.next() {
209                Some(' ') => current.push(' '),
210                Some('#') => current.push('#'),
211                Some(other) => {
212                    return Err(CcBypassReason::MalformedDepfile(format!(
213                        "unmodeled escape \\{other}"
214                    )));
215                }
216                None => {
217                    return Err(CcBypassReason::MalformedDepfile(
218                        "trailing escape character".into(),
219                    ));
220                }
221            },
222            '$' => match characters.next() {
223                Some('$') => current.push('$'),
224                Some(other) => {
225                    return Err(CcBypassReason::MalformedDepfile(format!(
226                        "unmodeled variable reference ${other}"
227                    )));
228                }
229                None => {
230                    return Err(CcBypassReason::MalformedDepfile(
231                        "trailing variable reference".into(),
232                    ));
233                }
234            },
235            other => current.push(other),
236        }
237    }
238    if !current.is_empty() {
239        files.push(PathBuf::from(current));
240    }
241    Ok(files)
242}
243
244/// A complete, content-addressed compiler input manifest.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct CcDiscoveredInputs {
247    working_dir: PathBuf,
248    /// Content-addressed inputs, including include-directory manifests.
249    pub inputs: Vec<CcActionInput>,
250    /// What each file input looked like on disk when its digest was
251    /// established, index-aligned with `inputs`; `None` for manifests and
252    /// where the filesystem gave nothing to compare against later.
253    identities: Vec<Option<FileIdentity>>,
254}
255
256impl CcDiscoveredInputs {
257    /// Digest every file the compilation read, and summarize the directories it
258    /// searched.
259    ///
260    /// Digesting the files answers "did any input change". The directory
261    /// manifests answer the question a dependency list cannot: whether a header
262    /// that was *not* read now exists somewhere that would shadow one that was.
263    pub fn collect(
264        working_dir: &Path,
265        files: BTreeSet<PathBuf>,
266        directories: BTreeSet<PathBuf>,
267        digests: &dyn FileDigestCache,
268    ) -> Result<Self, CcBypassReason> {
269        if !working_dir.is_absolute() {
270            return Err(CcBypassReason::RelativeWorkingDirectory(
271                working_dir.to_path_buf(),
272            ));
273        }
274        let directories = minimal_manifest_directories(directories);
275        if files.len() + directories.len() > MAX_PREDICTED_INPUTS {
276            return Err(CcBypassReason::TooManyInputs);
277        }
278        let working_dir = normalize_components(working_dir);
279        let mut inputs = Vec::with_capacity(files.len() + directories.len());
280        let mut total_bytes = 0_u64;
281        // Stat everything first so one batched ledger lookup can stand in for
282        // rereading headers the session already scanned and hashed. A ledger
283        // entry in the cc scope was recorded after the timestamp-macro scan
284        // passed, so a hit skips the scan for the same reason it skips the
285        // hash: the identity says the contents have not changed since both
286        // were established.
287        let mut identified = Vec::with_capacity(files.len());
288        for path in files {
289            let metadata = std::fs::metadata(&path).map_err(|error| CcBypassReason::InputRead {
290                path: path.clone(),
291                message: error.to_string(),
292            })?;
293            if !metadata.is_file() {
294                return Err(CcBypassReason::InputRead {
295                    path,
296                    message: "input is not a regular file".into(),
297                });
298            }
299            total_bytes = total_bytes.saturating_add(metadata.len());
300            if total_bytes > MAX_INPUT_BYTES {
301                return Err(CcBypassReason::TooManyInputs);
302            }
303            let identity = FileIdentity::for_digest_cache(&path, &metadata).map_err(|error| {
304                CcBypassReason::InputRead {
305                    path: path.clone(),
306                    message: error.to_string(),
307                }
308            })?;
309            identified.push((path, identity));
310        }
311        let queries = identified
312            .iter()
313            .filter_map(|(_, identity)| identity.clone())
314            .collect::<Vec<_>>();
315        let mut recorded = digests
316            .resolve(FileDigestScope::CcInput, &queries)
317            .into_iter();
318        let mut identities = Vec::with_capacity(inputs.capacity());
319        let mut fresh = Vec::new();
320        for (path, identity) in identified {
321            let resolution = identity
322                .as_ref()
323                .and_then(|_| recorded.next())
324                .unwrap_or(FileDigestResolution::Unresolved);
325            let cached = match resolution {
326                FileDigestResolution::Digest(digest)
327                    if identity.as_ref().is_some_and(|identity| {
328                        identity.len == digest.size && identity.still_describes().unwrap_or(false)
329                    }) =>
330                {
331                    Some(Ok(digest))
332                }
333                FileDigestResolution::EmbeddedTimestampMacro
334                    if identity
335                        .as_ref()
336                        .is_some_and(|identity| identity.still_describes().unwrap_or(false)) =>
337                {
338                    Some(Err(CcBypassReason::EmbeddedTimestampMacro(path.clone())))
339                }
340                FileDigestResolution::Digest(_)
341                | FileDigestResolution::EmbeddedTimestampMacro
342                | FileDigestResolution::Unresolved => None,
343            };
344            let digest = if let Some(cached) = cached {
345                identities.push(identity);
346                cached?
347            } else {
348                let observed =
349                    digest_file_validated(FileDigestScope::CcInput, &path).map_err(|error| {
350                        CcBypassReason::InputRead {
351                            path: path.clone(),
352                            message: error.to_string(),
353                        }
354                    })?;
355                let (resolution, observed_identity) = if let Some(observed) = observed {
356                    let identity = observed.cache_identity.clone();
357                    if let FileDigestResolution::Digest(digest) = &observed.resolution
358                        && let Some(file) = observed.cache_identity
359                        && file.len == digest.size
360                    {
361                        fresh.push(RecordedFileDigest {
362                            file,
363                            digest: digest.clone(),
364                        });
365                    }
366                    (observed.resolution, identity)
367                } else {
368                    let resolution =
369                        digest_file(FileDigestScope::CcInput, &path).map_err(|error| {
370                            CcBypassReason::InputRead {
371                                path: path.clone(),
372                                message: error.to_string(),
373                            }
374                        })?;
375                    (resolution, None)
376                };
377                let FileDigestResolution::Digest(digest) = resolution else {
378                    return Err(CcBypassReason::EmbeddedTimestampMacro(path));
379                };
380                identities.push(observed_identity);
381                digest
382            };
383            inputs.push(CcActionInput { path, digest });
384        }
385        if !fresh.is_empty() {
386            digests.record(FileDigestScope::CcInput, fresh);
387        }
388        let mut manifest_entries = 0_usize;
389        for directory in directories {
390            let digest = include_manifest(&directory, &mut manifest_entries)?;
391            inputs.push(CcActionInput {
392                path: PathBuf::from(format!("{INCLUDE_MANIFEST_PREFIX}{}", directory.display())),
393                digest,
394            });
395            identities.push(None);
396        }
397        Ok(Self {
398            working_dir,
399            inputs,
400            identities,
401        })
402    }
403
404    /// File inputs, excluding include-directory manifests.
405    pub fn files(&self) -> impl Iterator<Item = &CcActionInput> {
406        self.inputs
407            .iter()
408            .filter(|input| !is_manifest_input(&input.path))
409    }
410
411    /// Reject inputs whose modification time overlaps the compiler invocation.
412    ///
413    /// Contents are hashed after the compiler reports the paths it read. This
414    /// timestamp barrier prevents a write that landed during the compile from
415    /// being mistaken for the contents that produced the object; `verify`
416    /// closes the remaining race after hashing.
417    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), CcBypassReason> {
418        self.verify_not_modified_since_with_snapshots(started_at, &BTreeMap::new())
419    }
420
421    /// Reject inputs that changed from snapshots captured before the driver
422    /// ran, falling back to the wall-clock barrier for discovered headers.
423    pub fn verify_not_modified_since_with_snapshots(
424        &self,
425        started_at: SystemTime,
426        before: &BTreeMap<PathBuf, FileSnapshot>,
427    ) -> Result<(), CcBypassReason> {
428        for input in self.files() {
429            if let Some(previous) = before.get(&input.path)
430                && previous.proves_content_change()
431            {
432                let metadata =
433                    std::fs::metadata(&input.path).map_err(|error| CcBypassReason::InputRead {
434                        path: input.path.clone(),
435                        message: error.to_string(),
436                    })?;
437                let identity = FileIdentity::for_digest_cache(&input.path, &metadata)
438                    .map_err(|error| CcBypassReason::InputRead {
439                        path: input.path.clone(),
440                        message: error.to_string(),
441                    })?
442                    .or_else(|| FileIdentity::describe(&input.path, &metadata));
443                if previous.matches(identity.as_ref(), &input.digest) {
444                    continue;
445                }
446                return Err(CcBypassReason::InputModifiedDuringCompilation(
447                    input.path.clone(),
448                ));
449            }
450            let metadata =
451                std::fs::metadata(&input.path).map_err(|error| CcBypassReason::InputRead {
452                    path: input.path.clone(),
453                    message: error.to_string(),
454                })?;
455            let modified = metadata
456                .modified()
457                .map_err(|error| CcBypassReason::InputRead {
458                    path: input.path.clone(),
459                    message: error.to_string(),
460                })?;
461            if modified >= started_at {
462                return Err(CcBypassReason::InputModifiedDuringCompilation(
463                    input.path.clone(),
464                ));
465            }
466        }
467        Ok(())
468    }
469
470    /// Compatibility form for callers that captured metadata identities.
471    pub fn verify_not_modified_since_with_identities(
472        &self,
473        started_at: SystemTime,
474        before: &BTreeMap<PathBuf, FileIdentity>,
475    ) -> Result<(), CcBypassReason> {
476        let snapshots = before
477            .iter()
478            .map(|(path, identity)| (path.clone(), identity.clone().into()))
479            .collect();
480        self.verify_not_modified_since_with_snapshots(started_at, &snapshots)
481    }
482
483    /// Confirm every discovered file before publication, degrading a changed
484    /// input to a miss rather than storing an object under a stale key.
485    ///
486    /// A file still wearing the identity `collect` recorded is confirmed by
487    /// that stat alone where the identity carries a change time, which cannot
488    /// be set from user space and so shows a rewrite that restored the old
489    /// modification time. One whose identity moved, that had none, or that a
490    /// platform without change times described, is read and hashed again.
491    pub fn verify(&self) -> Result<(), CcBypassReason> {
492        for (index, input) in self.inputs.iter().enumerate() {
493            if is_manifest_input(&input.path) {
494                continue;
495            }
496            let read_error = |error: std::io::Error| CcBypassReason::InputRead {
497                path: input.path.clone(),
498                message: error.to_string(),
499            };
500            if let Some(Some(identity)) = self.identities.get(index)
501                && identity.can_skip_content_verification()
502                && identity.still_describes().map_err(read_error)?
503            {
504                continue;
505            }
506            let matches = input.digest.matches_file(&input.path).map_err(|error| {
507                CcBypassReason::InputRead {
508                    path: input.path.clone(),
509                    message: error.to_string(),
510                }
511            })?;
512            if !matches {
513                return Err(CcBypassReason::InputChanged(input.path.clone()));
514            }
515        }
516        Ok(())
517    }
518
519    /// Merge the manifest into an action context after confirming both use the
520    /// same compiler working directory.
521    pub fn apply_to(self, context: &mut CcActionContext) -> Result<(), CcBypassReason> {
522        if normalize_components(&context.working_dir) != self.working_dir {
523            return Err(CcBypassReason::DiscoveryWorkingDirectory);
524        }
525        context.inputs.extend(self.inputs);
526        Ok(())
527    }
528}
529
530/// Drop include directories already covered by an ancestor's recursive manifest.
531///
532/// Discovered headers often contribute hundreds of nested parent directories,
533/// especially for amalgamated C sources. Keeping both an ancestor and its
534/// descendants walks and hashes the same subtree repeatedly, and can exhaust
535/// the manifest-entry budget even though the ancestor already names every
536/// includable file below it.
537fn minimal_manifest_directories(directories: BTreeSet<PathBuf>) -> Vec<PathBuf> {
538    let mut directories = directories
539        .into_iter()
540        .map(|directory| {
541            let normalized = manifest_directory_identity(&directory);
542            (directory, normalized)
543        })
544        .collect::<Vec<_>>();
545    directories.sort_by(|(left, left_normalized), (right, right_normalized)| {
546        left_normalized
547            .as_deref()
548            .unwrap_or(left)
549            .components()
550            .count()
551            .cmp(
552                &right_normalized
553                    .as_deref()
554                    .unwrap_or(right)
555                    .components()
556                    .count(),
557            )
558            .then_with(|| left_normalized.cmp(right_normalized))
559            .then_with(|| left.cmp(right))
560    });
561
562    let mut minimal = Vec::<(PathBuf, Option<PathBuf>)>::new();
563    for (directory, normalized) in directories {
564        if !minimal
565            .iter()
566            .any(|(_, ancestor)| match (ancestor, &normalized) {
567                (Some(ancestor), Some(normalized)) => manifest_covers(ancestor, normalized),
568                _ => false,
569            })
570        {
571            minimal.push((directory, normalized));
572        }
573    }
574    minimal
575        .into_iter()
576        .map(|(directory, _)| directory)
577        .collect()
578}
579
580/// Lexical parent removal is safe only when the removed directory exists and
581/// is not a symlink: `link/..` can name a completely different physical tree.
582/// An unprovable path keeps its own manifest instead of joining another root.
583fn manifest_directory_identity(directory: &Path) -> Option<PathBuf> {
584    let mut normalized = PathBuf::new();
585    for component in directory.components() {
586        match component {
587            std::path::Component::CurDir => {}
588            std::path::Component::ParentDir => {
589                let metadata = std::fs::symlink_metadata(&normalized).ok()?;
590                if !metadata.is_dir() || metadata.file_type().is_symlink() {
591                    return None;
592                }
593                normalized.pop();
594            }
595            component => normalized.push(component.as_os_str()),
596        }
597    }
598    Some(normalized)
599}
600
601/// Whether walking `ancestor` recursively is guaranteed to visit `descendant`.
602///
603/// Component-aware normalization rejects a lexical prefix that escapes through
604/// `..`. Directory symlinks need an explicit check because `read_dir` follows
605/// the directory it starts at but the recursive walk deliberately does not
606/// follow symlink entries beneath it.
607fn manifest_covers(ancestor: &Path, descendant: &Path) -> bool {
608    let Ok(relative) = descendant.strip_prefix(ancestor) else {
609        return false;
610    };
611    if relative.as_os_str().is_empty() {
612        return true;
613    }
614    let mut current = ancestor.to_path_buf();
615    for component in relative.components() {
616        current.push(component);
617        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
618            return false;
619        };
620        if !metadata.is_dir() || metadata.file_type().is_symlink() {
621            return false;
622        }
623    }
624    true
625}
626
627fn is_manifest_input(path: &Path) -> bool {
628    path.to_str()
629        .is_some_and(|path| path.starts_with(INCLUDE_MANIFEST_PREFIX))
630}
631
632/// Digest the includable names in each directory, reading no file contents.
633///
634/// Taken once before the compiler runs and again before publishing, this is
635/// what detects a header that appeared in a search directory *while* the
636/// compilation was in flight. The manifest recorded in the key is the one from
637/// after the compile, and without this check that later state would be claimed
638/// as the state the compiler saw.
639pub fn manifest_snapshot(
640    directories: &BTreeSet<PathBuf>,
641) -> Result<BTreeMap<PathBuf, CacheDigest>, CcBypassReason> {
642    let mut budget = 0_usize;
643    minimal_manifest_directories(directories.iter().cloned().collect())
644        .into_iter()
645        .map(|directory| {
646            include_manifest(&directory, &mut budget).map(|digest| (directory, digest))
647        })
648        .collect()
649}
650
651/// Extensions a file must carry to be a plausible `#include` target.
652///
653/// An extensionless name also qualifies: C++ standard headers are spelled that
654/// way and projects ship their own.
655///
656/// `gch` and `pch` are here because a precompiled header answers an `#include`
657/// without being named by one. GCC prefers `foo.h.gch` over `foo.h` on its own,
658/// with nothing on the command line to say so, which is precisely the
659/// substitution these manifests exist to notice -- and the one case the
660/// adapter's explicit precompiled-header bypass cannot see.
661const INCLUDABLE_EXTENSIONS: &[&str] = &[
662    "c", "c++", "cc", "cpp", "cxx", "def", "gch", "h", "h++", "hh", "hpp", "hxx", "inc", "inl",
663    "ipp", "pch", "s", "tcc",
664];
665
666/// Whether a file name could be what an `#include` directive names.
667///
668/// The manifest exists to notice a file appearing where it would shadow a
669/// header that was read. A build writes its own objects, dependency files, and
670/// archives into these directories -- often the very directory a generated
671/// header lives in -- and none of those can shadow an include. Counting them
672/// would make the key depend on how many sibling compilations had finished,
673/// which is not a property of this compilation at all.
674fn is_includable(name: &str) -> bool {
675    match name.rsplit_once('.') {
676        Some((stem, extension)) if !stem.is_empty() => INCLUDABLE_EXTENSIONS
677            .binary_search(&extension.to_ascii_lowercase().as_str())
678            .is_ok(),
679        // No extension, or a leading-dot name like `.keep`.
680        _ => !name.starts_with('.'),
681    }
682}
683
684/// Digest the sorted includable file names beneath a directory.
685///
686/// Names only: the contents of anything actually read are digested as inputs,
687/// so this exists purely to notice a file appearing where it could shadow one
688/// of them. A directory that does not exist has an empty manifest, which is
689/// what makes "the directory was created" a key change rather than an error.
690fn include_manifest(directory: &Path, budget: &mut usize) -> Result<CacheDigest, CcBypassReason> {
691    include_manifest_with(directory, budget, &|path| std::fs::read_dir(path))
692}
693
694fn include_manifest_with(
695    directory: &Path,
696    budget: &mut usize,
697    read_dir: &impl Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
698) -> Result<CacheDigest, CcBypassReason> {
699    #[cfg(unix)]
700    if let Some(memo) = manifest_memo::find(directory) {
701        *budget = budget.saturating_add(memo.entries);
702        if *budget > MAX_MANIFEST_ENTRIES {
703            return Err(CcBypassReason::TooManyInputs);
704        }
705        return Ok(memo.digest);
706    }
707    #[cfg(unix)]
708    let mut directories = Vec::new();
709    #[cfg(unix)]
710    let initial_budget = *budget;
711    let mut names = Vec::new();
712    let mut pending = vec![(directory.to_path_buf(), String::new())];
713    while let Some((current, prefix)) = pending.pop() {
714        // Capture before enumeration and validate again after the complete
715        // walk. A directory changed while being read cannot seed the memo.
716        #[cfg(unix)]
717        directories.push((current.clone(), manifest_memo::Identity::read(&current)));
718        let entries = match read_dir(&current) {
719            Ok(entries) => entries,
720            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
721            Err(error) => {
722                return Err(CcBypassReason::InputRead {
723                    path: current,
724                    message: error.to_string(),
725                });
726            }
727        };
728        for entry in entries {
729            let entry = entry.map_err(|error| CcBypassReason::InputRead {
730                path: current.clone(),
731                message: error.to_string(),
732            })?;
733            let name = entry.file_name();
734            let Some(name) = name.to_str() else {
735                return Err(CcBypassReason::NonUtf8Path(entry.path()));
736            };
737            let relative = if prefix.is_empty() {
738                name.to_string()
739            } else {
740                format!("{prefix}/{name}")
741            };
742            let file_type = entry
743                .file_type()
744                .map_err(|error| CcBypassReason::InputRead {
745                    path: entry.path(),
746                    message: error.to_string(),
747                })?;
748            if file_type.is_dir() {
749                pending.push((entry.path(), relative));
750                continue;
751            }
752            if !is_includable(name) {
753                continue;
754            }
755            *budget += 1;
756            if *budget > MAX_MANIFEST_ENTRIES {
757                return Err(CcBypassReason::TooManyInputs);
758            }
759            names.push(relative);
760        }
761    }
762    names.sort();
763    let digest = CacheDigest::blake3(names.join("\n").as_bytes());
764    #[cfg(unix)]
765    manifest_memo::record(directory, &digest, *budget - initial_budget, directories);
766    Ok(digest)
767}
768
769// A wrapper checks the same manifests during prediction, discovery, and
770// publication. Reuse their exact name digest while every directory is unchanged.
771// This is process-local: no persisted timestamps can outlive a filesystem mount.
772#[cfg(unix)]
773mod manifest_memo {
774    use super::*;
775    use std::os::unix::fs::MetadataExt;
776    use std::sync::{Mutex, OnceLock};
777
778    const MAX_ROOTS: usize = 32;
779    const MAX_DIRECTORIES: usize = 4096;
780    static MEMOS: OnceLock<Mutex<BTreeMap<PathBuf, Memo>>> = OnceLock::new();
781
782    #[derive(Clone, PartialEq, Eq)]
783    pub(super) struct Identity {
784        device: u64,
785        inode: u64,
786        modified: (i64, i64),
787        changed: (i64, i64),
788        mode: u32,
789    }
790    impl Identity {
791        pub(super) fn read(path: &Path) -> Option<Self> {
792            let metadata = std::fs::metadata(path).ok()?;
793            // Whole-second timestamps cannot distinguish rapid edits. Preserve
794            // enumeration on filesystems that expose only that precision.
795            if !metadata.is_dir() || metadata.mtime_nsec() == 0 || metadata.ctime_nsec() == 0 {
796                return None;
797            }
798            // Reuse the digest ledger's filesystem qualification. In particular,
799            // Linux NFS identities omit ctime and must keep enumerating names.
800            let changed = FileIdentity::for_digest_cache(path, &metadata)
801                .ok()??
802                .changed?;
803            Some(Self {
804                device: metadata.dev(),
805                inode: metadata.ino(),
806                modified: (metadata.mtime(), metadata.mtime_nsec()),
807                changed,
808                mode: metadata.mode(),
809            })
810        }
811    }
812    #[derive(Clone)]
813    pub(super) struct Memo {
814        pub digest: CacheDigest,
815        pub entries: usize,
816        directories: Vec<(PathBuf, Identity)>,
817    }
818    impl Memo {
819        fn valid(&self) -> bool {
820            self.directories
821                .iter()
822                .all(|(path, identity)| Identity::read(path).as_ref() == Some(identity))
823        }
824    }
825    pub(super) fn find(directory: &Path) -> Option<Memo> {
826        let memo = MEMOS.get()?.lock().ok()?.get(directory)?.clone();
827        memo.valid().then_some(memo)
828    }
829    pub(super) fn record(
830        directory: &Path,
831        digest: &CacheDigest,
832        entries: usize,
833        directories: Vec<(PathBuf, Option<Identity>)>,
834    ) {
835        if directories.is_empty() || directories.len() > MAX_DIRECTORIES {
836            return;
837        }
838        let Some(directories) = directories
839            .into_iter()
840            .map(|(path, identity)| Some((path, identity?)))
841            .collect::<Option<Vec<_>>>()
842        else {
843            return;
844        };
845        let memo = Memo {
846            digest: digest.clone(),
847            entries,
848            directories,
849        };
850        if !memo.valid() {
851            return;
852        }
853        let Ok(mut memos) = MEMOS.get_or_init(Default::default).lock() else {
854            return;
855        };
856        if memos.len() >= MAX_ROOTS {
857            memos.clear();
858        }
859        memos.insert(directory.to_path_buf(), memo);
860    }
861}
862
863/// Whether a preprocessor input can make the assembler read another file.
864///
865/// Searching for the directive text, including in comments and inactive
866/// conditional branches, deliberately accepts false positives. Missing a real
867/// directive would publish an object whose complete inputs are absent from the
868/// key; bypassing an otherwise cacheable object is the safe outcome instead.
869pub(crate) fn contains_assembler_input_directive(path: &Path) -> Result<bool, CcBypassReason> {
870    contains_any(path, ASSEMBLER_INPUT_DIRECTIVES)
871}
872
873fn contains_any(path: &Path, needles: &[&[u8]]) -> Result<bool, CcBypassReason> {
874    let file = std::fs::File::open(path).map_err(|error| CcBypassReason::InputRead {
875        path: path.to_path_buf(),
876        message: error.to_string(),
877    })?;
878    let longest = needles
879        .iter()
880        .map(|needle| needle.len())
881        .max()
882        .unwrap_or_default();
883    let mut reader = std::io::BufReader::new(file);
884    let mut window = Vec::with_capacity(SCAN_CHUNK_BYTES + longest);
885    let mut chunk = vec![0_u8; SCAN_CHUNK_BYTES];
886    loop {
887        let read = reader
888            .read(&mut chunk)
889            .map_err(|error| CcBypassReason::InputRead {
890                path: path.to_path_buf(),
891                message: error.to_string(),
892            })?;
893        if read == 0 {
894            return Ok(false);
895        }
896        window.extend_from_slice(&chunk[..read]);
897        if needles
898            .iter()
899            .any(|needle| contains_subslice_ascii_case_insensitive(&window, needle))
900        {
901            return Ok(true);
902        }
903        let keep = window.len().saturating_sub(longest.saturating_sub(1));
904        window.drain(..keep);
905    }
906}
907
908fn contains_subslice_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
909    if needle.is_empty() || haystack.len() < needle.len() {
910        return false;
911    }
912    haystack.windows(needle.len()).any(|window| {
913        window
914            .iter()
915            .zip(needle)
916            .all(|(left, right)| left.eq_ignore_ascii_case(right))
917    })
918}
919
920#[cfg(test)]
921#[path = "depfile_tests.rs"]
922mod tests;