Skip to main content

trust_rustc/
lib.rs

1//! Shared lowering/cache/mirror logic used by the `trust-rustc`
2//! (`RUSTC_WRAPPER`) and `trust-rustdoc` (`RUSTDOC`) shims.
3//!
4//! Both wrappers do the same job: given a rustc/rustdoc invocation, find
5//! the input `.rs` file, and — if it's strict-marked — lower the whole
6//! source tree into a temp directory keyed by an FNV-1a content hash, then
7//! rewrite the input path so the underlying tool sees plain Rust.
8//!
9//! The functions here are the parts that don't depend on whether we're
10//! about to exec `rustc` or `rustdoc`. The two `main.rs` files differ only
11//! in how they parse out the tool path / input arg.
12
13use anyhow::{bail, Context, Result};
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Version string mixed into the cache key. Bumps automatically with the
19/// package version; bump the package whenever lowering output changes in
20/// a way that would invalidate previously-cached files.
21const LOWERING_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23/// Fingerprint of the running wrapper binary (length ⊕ mtime), mixed into
24/// the cache key (RT-86). The package version alone is constant across a
25/// whole dev cycle, so a rebuilt wrapper with changed lowering code would
26/// happily reuse lowered output produced by the previous build — which is
27/// exactly the kind of stale-cache haunting that makes verification results
28/// flip between runs. A new binary now always means a fresh cache namespace.
29fn wrapper_fingerprint() -> u64 {
30    use std::sync::OnceLock;
31    static FP: OnceLock<u64> = OnceLock::new();
32    *FP.get_or_init(|| {
33        let Ok(exe) = env::current_exe() else {
34            return 0;
35        };
36        let Ok(meta) = fs::metadata(&exe) else {
37            return 0;
38        };
39        let mtime = meta
40            .modified()
41            .ok()
42            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
43            .and_then(|d| u64::try_from(d.as_nanos()).ok())
44            .unwrap_or(0);
45        meta.len() ^ mtime
46    })
47}
48
49/// FNV-1a 64-bit hash of the lowering-version string, the wrapper binary's
50/// fingerprint, and the source bytes. Fast, no deps, deterministic per
51/// wrapper build.
52pub fn source_cache_key(source: &str) -> u64 {
53    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
54    const FNV_PRIME: u64 = 0x100000001b3;
55    let mut hash = FNV_OFFSET;
56    for byte in LOWERING_VERSION
57        .bytes()
58        .chain(wrapper_fingerprint().to_le_bytes())
59        .chain(source.bytes())
60    {
61        hash ^= u64::from(byte);
62        hash = hash.wrapping_mul(FNV_PRIME);
63    }
64    hash
65}
66
67/// FNV-1a hash of the nearest `trust.toml` content at or above `input_path`,
68/// mixed into the cache key (RT-113) so a config change re-triggers linting
69/// even when the source is unchanged. Returns 0 when there is no config — the
70/// common case, leaving the key unchanged. Walks the same way
71/// [`trust_lints::TrustConfig::discover`] does, so the salt and the applied
72/// config always agree on which file is in effect.
73fn config_cache_salt(input_path: &Path) -> u64 {
74    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
75    const FNV_PRIME: u64 = 0x100000001b3;
76    let mut dir = input_path.parent();
77    while let Some(d) = dir {
78        if let Ok(text) = fs::read_to_string(d.join("trust.toml")) {
79            let mut hash = FNV_OFFSET;
80            for byte in text.bytes() {
81                hash ^= u64::from(byte);
82                hash = hash.wrapping_mul(FNV_PRIME);
83            }
84            return hash;
85        }
86        dir = d.parent();
87    }
88    0
89}
90
91/// Result of preparing a strict-source invocation: the path to the lowered
92/// crate-root file, and a `--remap-path-prefix=<cache>=<orig>` flag the
93/// caller should append to the tool args so diagnostics still point at the
94/// user's source.
95pub struct Prepared {
96    pub lowered_root: PathBuf,
97    pub remap_flag: String,
98}
99
100/// Walk `src_dir` recursively, parsing every `.rs` file with `syn` and
101/// collecting `(fn_name, [param_names...])` for every module-level `fn`
102/// definition (free fns, `pub` or otherwise; module-nested `fn`s
103/// included). Used by [`prepare_strict_input`] to build a crate-wide
104/// callee registry so cross-file named-arg call sites resolve (RT-40).
105///
106/// **What's covered:** plain free fns at module level, including inside
107/// `mod foo { ... }` blocks within a single file. **What's not:** trait
108/// methods, `impl` methods, and fns inside file-mod descendants that
109/// `syn::parse_file` can't reach (those will still be picked up when the
110/// file itself is parsed, because the recursive walk visits every `.rs`).
111///
112/// **Ambiguity policy:** if two files declare a fn with the same name
113/// but different param lists, the name is dropped from the index — same
114/// behaviour as the in-file collector. Dropping is safer than guessing
115/// which signature the caller meant.
116///
117/// Parse errors and unreadable files are silently skipped — the wrapper
118/// stays best-effort.  The downstream lowering pass will surface real
119/// errors on the file that actually has the syntax problem.
120pub fn collect_crate_callees(src_dir: &Path) -> Vec<(String, Vec<String>)> {
121    use std::collections::{HashMap, HashSet};
122    let mut sigs: HashMap<String, Vec<String>> = HashMap::new();
123    let mut ambiguous: HashSet<String> = HashSet::new();
124    let mut visited: HashSet<PathBuf> = HashSet::new();
125    collect_crate_callees_recursive(src_dir, &mut sigs, &mut ambiguous, &mut visited);
126    let mut out: Vec<(String, Vec<String>)> = sigs.into_iter().collect();
127    out.sort_by(|a, b| a.0.cmp(&b.0));
128    out
129}
130
131fn collect_crate_callees_recursive(
132    dir: &Path,
133    sigs: &mut std::collections::HashMap<String, Vec<String>>,
134    ambiguous: &mut std::collections::HashSet<String>,
135    visited: &mut std::collections::HashSet<PathBuf>,
136) {
137    if !dir.is_dir() {
138        return;
139    }
140    let Ok(read) = fs::read_dir(dir) else {
141        return;
142    };
143    for entry in read.flatten() {
144        let path = entry.path();
145        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
146        if !visited.insert(canonical) {
147            continue;
148        }
149        if path.is_dir() {
150            collect_crate_callees_recursive(&path, sigs, ambiguous, visited);
151        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
152            if let Ok(source) = fs::read_to_string(&path) {
153                // Pre-lower the source so syn can parse it. The crate-wide
154                // index is derived from the *lowered* signatures — but
155                // since fn signatures don't use named-arg call syntax,
156                // syn::parse_file on the raw source usually works. Try
157                // raw first; on failure, fall through to lowered.
158                let file = syn::parse_file(&source).ok().or_else(|| {
159                    trust_lower::lower(&source)
160                        .ok()
161                        .and_then(|lo| syn::parse_file(&lo.source).ok())
162                });
163                if let Some(file) = file {
164                    walk_items_for_sigs(&file.items, sigs, ambiguous);
165                }
166            }
167        }
168    }
169}
170
171fn walk_items_for_sigs(
172    items: &[syn::Item],
173    sigs: &mut std::collections::HashMap<String, Vec<String>>,
174    ambiguous: &mut std::collections::HashSet<String>,
175) {
176    for item in items {
177        match item {
178            syn::Item::Fn(f) => record_fn_sig(&f.sig, sigs, ambiguous),
179            syn::Item::Mod(m) => {
180                if let Some((_, inner)) = &m.content {
181                    walk_items_for_sigs(inner, sigs, ambiguous);
182                }
183            }
184            _ => {}
185        }
186    }
187}
188
189fn record_fn_sig(
190    sig: &syn::Signature,
191    sigs: &mut std::collections::HashMap<String, Vec<String>>,
192    ambiguous: &mut std::collections::HashSet<String>,
193) {
194    let name = sig.ident.to_string();
195    if ambiguous.contains(&name) {
196        return;
197    }
198    let mut params: Vec<String> = Vec::new();
199    for input in &sig.inputs {
200        match input {
201            syn::FnArg::Receiver(_) => {} // skip self
202            syn::FnArg::Typed(pat_type) => match &*pat_type.pat {
203                syn::Pat::Ident(pi) => params.push(pi.ident.to_string()),
204                _ => {
205                    // Non-ident pattern (destructure) — can't bind by name.
206                    sigs.remove(&name);
207                    ambiguous.insert(name);
208                    return;
209                }
210            },
211        }
212    }
213    match sigs.get(&name) {
214        Some(existing) if existing != &params => {
215            sigs.remove(&name);
216            ambiguous.insert(name);
217        }
218        Some(_) => {}
219        None => {
220            sigs.insert(name, params);
221        }
222    }
223}
224
225/// Find the input `.rs` file argument in a rustc/rustdoc arg list.
226///
227/// Cargo passes exactly one `.rs` crate-root per invocation. Flag args
228/// start with `-`, and a bare `-` means "read from stdin" (skip).
229pub fn find_input_rs(args: &[String]) -> Option<usize> {
230    args.iter().enumerate().find_map(|(i, a)| {
231        if a == "-" {
232            return None;
233        }
234        if a.ends_with(".rs") && !a.starts_with('-') {
235            Some(i)
236        } else {
237            None
238        }
239    })
240}
241
242/// Whether the crate currently being compiled was opted into strict mode at
243/// the *project* level (`[package.metadata.trust] strict = true`), rather than
244/// per-file with a `#![strict]` marker.
245///
246/// `cargo-trustc` passes the set of strict package names in
247/// `TRUST_STRICT_PACKAGES` (comma-separated). Cargo sets `CARGO_PKG_NAME` for
248/// every rustc invocation — including dependencies — so gating on membership
249/// scopes forced lowering to exactly the user's own opted-in package(s) and
250/// never touches third-party crates compiled in the same build.
251pub fn crate_is_force_strict() -> bool {
252    force_strict_for(
253        env::var("TRUST_STRICT_PACKAGES").ok().as_deref(),
254        env::var("CARGO_PKG_NAME").ok().as_deref(),
255    )
256}
257
258/// Pure membership check behind [`crate_is_force_strict`]: is `name` listed in
259/// the comma-separated `pkgs` set? An empty/absent name or list is never a
260/// match — this is what keeps dependencies (which carry their own
261/// `CARGO_PKG_NAME`, not in the user's set) out of forced lowering.
262fn force_strict_for(pkgs: Option<&str>, name: Option<&str>) -> bool {
263    let (Some(pkgs), Some(name)) = (pkgs, name) else {
264        return false;
265    };
266    let name = name.trim();
267    !name.is_empty() && pkgs.split(',').any(|p| p.trim() == name)
268}
269
270/// True if a file should be lowered: either it carries an explicit strict
271/// marker, or its crate was opted in at the project level.
272fn should_lower(source: &str) -> bool {
273    trust_lower::is_strict_source(source) || crate_is_force_strict()
274}
275
276/// If `input_path` is strict (per-file marker or project-level opt-in), lower
277/// the whole source tree into the cache and return the new root path +
278/// a `--remap-path-prefix` flag.
279///
280/// Returns `Ok(None)` for non-strict sources — the caller should pass the
281/// original args through to the underlying tool unchanged.
282pub fn prepare_strict_input(input_path: &Path) -> Result<Option<Prepared>> {
283    let source = match fs::read_to_string(input_path) {
284        Ok(s) => s,
285        Err(_) => return Ok(None),
286    };
287
288    if !should_lower(&source) {
289        return Ok(None);
290    }
291    let force_strict = crate_is_force_strict();
292
293    let file_name = input_path
294        .file_name()
295        .context("input path has no file name")?;
296
297    // RT-113: fold the nearest trust.toml into the cache key so editing the
298    // config (e.g. adding `warn = [...]`) re-triggers the gate even when the
299    // source is unchanged — otherwise a cache hit would skip re-linting and the
300    // new config would silently not apply.
301    let cache_key = source_cache_key(&source) ^ config_cache_salt(input_path);
302    let cache_root = env::temp_dir().join("trust-cache");
303    let cache_dir = cache_root.join(format!("{cache_key:016x}"));
304    let cached_file = cache_dir.join(file_name);
305
306    // RT-86: the cache directory's EXISTENCE is the validity marker, so it
307    // must appear atomically. A failed mirror used to leave a partial dir
308    // behind, and the old per-file `cached_file.exists()` check then treated
309    // it as complete on the next run — phantom passes/failures that flip
310    // depending on which run came first. Populate a private staging dir and
311    // rename it into place only after every file lowered clean.
312    if !cache_dir.exists() {
313        let staging = cache_root.join(format!(".staging-{cache_key:016x}-{}", std::process::id()));
314        let _ = fs::remove_dir_all(&staging);
315
316        let result = (|| -> Result<()> {
317            let src_dir = input_path
318                .parent()
319                .filter(|p| !p.as_os_str().is_empty())
320                .map(Path::to_path_buf)
321                .unwrap_or_else(|| PathBuf::from("."));
322
323            // RT-40: pre-scan the whole `src/` tree for `fn` definitions so
324            // cross-file named-arg call sites resolve. The wrapper is the
325            // first place that has a crate-wide view; individual `lower()`
326            // calls only see one file at a time.
327            let crate_extras = collect_crate_callees(&src_dir);
328
329            // RT-66: seed the registry with the public-fn signatures of
330            // dependencies, discovered from the `TRUST_SIGNATURE_PATH`
331            // manifests (`trust index <dep> -o …` produces them). This is
332            // what lets R0042 fire — and named args reorder — on a
333            // positional swap into a *third-party* crate. `merge` drops any
334            // name that conflicts between the crate and a dependency, so a
335            // shadowed name degrades to the positional fallback rather than
336            // a wrong reorder.
337            let dep_extras = trust_lower::sig_index::load_from_env();
338            let extras = trust_lower::sig_index::merge(&[crate_extras, dep_extras]);
339
340            let mut visited = std::collections::HashSet::new();
341            mirror_module_tree_with_extras(&src_dir, &staging, &mut visited, &extras)
342                .with_context(|| format!("mirroring src tree from {}", src_dir.display()))?;
343
344            // Defensive: if the src_dir traversal somehow didn't write the
345            // crate root (e.g. empty dir), do it directly.
346            if !staging.join(file_name).exists() {
347                let out =
348                    trust_lower::lower_with_extra_callees_forced(&source, &extras, force_strict)
349                        .with_context(|| format!("lowering {}", input_path.display()))?;
350                emit_diagnostics(&out, &source, input_path)?;
351                fs::create_dir_all(&staging)?;
352                fs::write(staging.join(file_name), &out.source)?;
353            }
354            Ok(())
355        })();
356
357        if let Err(e) = result {
358            let _ = fs::remove_dir_all(&staging);
359            return Err(e);
360        }
361
362        // Atomic publish. If another process won the race, its complete dir
363        // is just as good — discard ours.
364        if fs::rename(&staging, &cache_dir).is_err() {
365            let _ = fs::remove_dir_all(&staging);
366            if !cache_dir.exists() {
367                bail!(
368                    "could not publish lowering cache at {}",
369                    cache_dir.display()
370                );
371            }
372        }
373    }
374
375    let parent = input_path
376        .parent()
377        .filter(|p| !p.as_os_str().is_empty())
378        .map(Path::to_path_buf)
379        .unwrap_or_else(|| PathBuf::from("."));
380
381    Ok(Some(Prepared {
382        lowered_root: cached_file,
383        remap_flag: format!(
384            "--remap-path-prefix={}={}",
385            cache_dir.display(),
386            parent.display()
387        ),
388    }))
389}
390
391fn emit_diagnostics(
392    out: &trust_lower::LowerOutput,
393    original_source: &str,
394    path: &Path,
395) -> Result<()> {
396    emit_diagnostics_to(out, original_source, path, &mut std::io::stderr())
397}
398
399/// True when the caller asked for machine-readable diagnostics (RT-96).
400///
401/// `cargo trustc build --message-format json` sets this env var on the spawned
402/// cargo process; cargo passes its environment through to every rustc/rustdoc
403/// (and therefore wrapper) invocation. Users may also set
404/// `TRUST_MESSAGE_FORMAT=json` directly — same effect, no flag needed.
405fn message_format_is_json() -> bool {
406    env::var("TRUST_MESSAGE_FORMAT").is_ok_and(|v| v == "json")
407}
408
409/// Testable core of [`emit_diagnostics`]: collects the full `trust check`
410/// rule set for one file and writes it to `writer` — as human `[R0001]`
411/// lines by default, or as one `trust_diag::to_json` document (newline
412/// terminated) when `TRUST_MESSAGE_FORMAT=json` (RT-96). Either way, bails
413/// when any diagnostic is an error.
414fn emit_diagnostics_to(
415    out: &trust_lower::LowerOutput,
416    original_source: &str,
417    path: &Path,
418    writer: &mut impl std::io::Write,
419) -> Result<()> {
420    // RT-89: the wrapper enforces the same rule set as `trust check` — the
421    // lowering diagnostics (R0042 et al) collected in `out`, plus the
422    // AST-level strict lints (R0001 unwrap, R0003 as-cast, ...). The AST
423    // comes from the LOWERED source (plain Rust, always parses); the
424    // ORIGINAL source string is what the linter needs for comment-window
425    // rules (R0005/R0006 justifications) — prettyplease strips comments
426    // from the lowered output. Mirrors `run_pipeline` in the trust CLI.
427    let mut diagnostics = out.diagnostics.clone();
428    if out.strict_mode {
429        // lint_source, not source: the allow map comes from the
430        // `#[allow(trust::…)]` attributes, which are stripped from the
431        // rustc-facing `source`.
432        let file: syn::File = syn::parse_str(&out.lint_source)
433            .with_context(|| format!("re-parsing lowered source from {}", path.display()))?;
434        diagnostics.extend(trust_lints::lint_strict(&file, original_source, true).diagnostics);
435    }
436
437    // RT-113: honor a project `trust.toml` in the build gate, same as
438    // `trust check` — drop `allow`-listed codes and downgrade `warn`-listed
439    // ones to non-failing warnings. A malformed config fails the build loudly.
440    let config = trust_lints::TrustConfig::discover(path)
441        .with_context(|| format!("loading trust.toml for {}", path.display()))?;
442    config.apply(&mut diagnostics);
443
444    if message_format_is_json() {
445        // RT-96: one JSON document per file, same shape as
446        // `trust check --format json` (spans index the ORIGINAL source).
447        let name = path.display().to_string();
448        let doc = trust_diag::to_json(
449            &diagnostics,
450            trust_diag::NamedSource {
451                name: &name,
452                text: original_source,
453            },
454        );
455        write!(writer, "{doc}")?;
456        if !doc.ends_with('\n') {
457            writeln!(writer)?;
458        }
459    } else {
460        for diag in &diagnostics {
461            writeln!(
462                writer,
463                "[{}] {}: {}",
464                diag.rule,
465                if diag.is_error() { "error" } else { "warning" },
466                diag.message
467            )?;
468        }
469    }
470    if diagnostics.iter().any(|d| d.is_error()) {
471        bail!("trust check failed on {}", path.display());
472    }
473    Ok(())
474}
475
476/// Files reachable only through a `#[cfg(test)] mod x;` declaration (RT-88).
477///
478/// Project-level force-strict must not apply to these: a stock-buildable
479/// library's tests routinely call its own multi-arg fns positionally, and
480/// the R0042 fix — named-arg syntax — is exactly what stock `cargo test`
481/// cannot parse. Skipping cfg(test)-only files lets such crates opt their
482/// *shipping* code into whole-package strict (trust-diag, trust-std) without
483/// rewriting their test suites in a dialect stock rustc rejects. A file that
484/// carries its own `#![strict]` marker is still lowered — explicit wins.
485///
486/// Detection is token-level (Trust syntax doesn't parse with syn): a file
487/// declares `NAME` test-only via `#[cfg(test)] (pub)? mod NAME ;`, mapping
488/// to `NAME.rs` or `NAME/mod.rs` beside it — and test-only-ness is
489/// transitive through plain `mod` declarations inside test-only files.
490pub fn collect_test_only_files(src_dir: &Path) -> std::collections::HashSet<PathBuf> {
491    use std::collections::HashSet;
492    let mut all_files: Vec<PathBuf> = Vec::new();
493    collect_rs_files(src_dir, &mut all_files);
494
495    // (declaring file, declared name, is_cfg_test)
496    let mut decls: Vec<(PathBuf, String, bool)> = Vec::new();
497    for file in &all_files {
498        let Ok(source) = fs::read_to_string(file) else {
499            continue;
500        };
501        let Ok(tokens) = source.parse::<proc_macro2::TokenStream>() else {
502            continue;
503        };
504        for (name, is_test) in file_mod_declarations(&tokens) {
505            decls.push((file.clone(), name, is_test));
506        }
507    }
508
509    let resolve = |declaring: &Path, name: &str| -> Option<PathBuf> {
510        let dir = declaring.parent()?;
511        let flat = dir.join(format!("{name}.rs"));
512        if flat.is_file() {
513            return flat.canonicalize().ok();
514        }
515        let nested = dir.join(name).join("mod.rs");
516        if nested.is_file() {
517            return nested.canonicalize().ok();
518        }
519        None
520    };
521
522    let mut test_only: HashSet<PathBuf> = HashSet::new();
523    // Seed with direct #[cfg(test)] declarations, then close transitively
524    // over plain mod declarations made from already-test-only files.
525    loop {
526        let mut grew = false;
527        for (declaring, name, is_test) in &decls {
528            let from_test_file = declaring
529                .canonicalize()
530                .map(|c| test_only.contains(&c))
531                .unwrap_or(false);
532            if !is_test && !from_test_file {
533                continue;
534            }
535            if let Some(target) = resolve(declaring, name) {
536                grew |= test_only.insert(target);
537            }
538        }
539        if !grew {
540            break;
541        }
542    }
543    test_only
544}
545
546fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
547    let Ok(read) = fs::read_dir(dir) else {
548        return;
549    };
550    for entry in read.flatten() {
551        let path = entry.path();
552        if path.is_dir() {
553            collect_rs_files(&path, out);
554        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
555            out.push(path);
556        }
557    }
558}
559
560/// Does a `cfg(...)` argument list make the item test-only — i.e. is `test`
561/// present as a POSITIVE predicate? `test` and `all(unix, test)` qualify;
562/// `not(test)` and `all(unix, not(test))` do not (those select NON-test
563/// builds, so exempting them would skip lowering/linting in production
564/// compiles — PR #1 review finding). `not(...)` subtrees are never recursed
565/// into; `any(...)`/`all(...)` are.
566fn cfg_args_positively_test(tokens: &proc_macro2::TokenStream) -> bool {
567    use proc_macro2::{Delimiter, TokenTree};
568    let trees: Vec<TokenTree> = tokens.clone().into_iter().collect();
569    let mut i = 0;
570    while let Some(tree) = trees.get(i) {
571        match tree {
572            TokenTree::Ident(id) if *id == "not" => {
573                // Skip the negated group entirely.
574                if matches!(trees.get(i + 1), Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Parenthesis)
575                {
576                    i += 2;
577                    continue;
578                }
579                i += 1;
580            }
581            TokenTree::Ident(id) if *id == "any" || *id == "all" => {
582                if let Some(TokenTree::Group(g)) = trees.get(i + 1) {
583                    if g.delimiter() == Delimiter::Parenthesis
584                        && cfg_args_positively_test(&g.stream())
585                    {
586                        return true;
587                    }
588                    i += 2;
589                    continue;
590                }
591                i += 1;
592            }
593            // Bare `test` predicate — not the LHS of `name = "value"` (the
594            // RHS of those is a Literal, so an Ident named test here is the
595            // predicate form).
596            TokenTree::Ident(id) if *id == "test" => {
597                let followed_by_eq = matches!(
598                    trees.get(i + 1),
599                    Some(TokenTree::Punct(p)) if p.as_char() == '='
600                );
601                if !followed_by_eq {
602                    return true;
603                }
604                i += 1;
605            }
606            _ => i += 1,
607        }
608    }
609    false
610}
611
612/// Top-level `mod NAME ;` declarations in a token stream, with whether the
613/// directly-preceding attribute run contains a positively-`test` cfg.
614fn file_mod_declarations(tokens: &proc_macro2::TokenStream) -> Vec<(String, bool)> {
615    use proc_macro2::{Delimiter, TokenTree};
616    let trees: Vec<TokenTree> = tokens.clone().into_iter().collect();
617    let mut out = Vec::new();
618    let mut i = 0;
619    let mut pending_cfg_test = false;
620    while let Some(tree) = trees.get(i) {
621        match tree {
622            // Attribute: `#` `[ ... ]` — note whether it's cfg(test).
623            TokenTree::Punct(p) if p.as_char() == '#' => {
624                if let Some(TokenTree::Group(g)) = trees.get(i + 1) {
625                    if g.delimiter() == Delimiter::Bracket {
626                        let inner: Vec<TokenTree> = g.stream().into_iter().collect();
627                        if let [TokenTree::Ident(name), TokenTree::Group(args)] = inner.as_slice() {
628                            if *name == "cfg" {
629                                pending_cfg_test |= cfg_args_positively_test(&args.stream());
630                            }
631                        }
632                        i += 2;
633                        continue;
634                    }
635                }
636                i += 1;
637            }
638            // `pub` (and `pub(...)`) between attrs and `mod` — skip.
639            TokenTree::Ident(id) if *id == "pub" => {
640                i += 1;
641                if let Some(TokenTree::Group(g)) = trees.get(i) {
642                    if g.delimiter() == Delimiter::Parenthesis {
643                        i += 1;
644                    }
645                }
646            }
647            TokenTree::Ident(id) if *id == "mod" => {
648                if let (Some(TokenTree::Ident(name)), Some(TokenTree::Punct(semi))) =
649                    (trees.get(i + 1), trees.get(i + 2))
650                {
651                    if semi.as_char() == ';' {
652                        out.push((name.to_string(), pending_cfg_test));
653                    }
654                }
655                pending_cfg_test = false;
656                i += 1;
657            }
658            _ => {
659                pending_cfg_test = false;
660                i += 1;
661            }
662        }
663    }
664    out
665}
666
667/// Recursively mirror the source tree rooted at `src_dir` into `dest_dir`,
668/// lowering strict-marked `.rs` files and hard-linking/copying others.
669pub fn mirror_module_tree(
670    src_dir: &Path,
671    dest_dir: &Path,
672    already_done: &mut std::collections::HashSet<PathBuf>,
673) -> Result<()> {
674    mirror_module_tree_with_extras(src_dir, dest_dir, already_done, &[])
675}
676
677/// Variant of [`mirror_module_tree`] that threads a crate-wide list of
678/// `(fn_name, params)` entries into every per-file lowering call. Used by
679/// `prepare_strict_input` to resolve cross-file named-arg call sites
680/// (RT-40).
681pub fn mirror_module_tree_with_extras(
682    src_dir: &Path,
683    dest_dir: &Path,
684    already_done: &mut std::collections::HashSet<PathBuf>,
685    extras: &[(String, Vec<String>)],
686) -> Result<()> {
687    // RT-88: under project-level force-strict, cfg(test)-only files keep
688    // their plain-Rust form (see collect_test_only_files). Computed once per
689    // mirror at the root call.
690    let test_only = if crate_is_force_strict() {
691        collect_test_only_files(src_dir)
692    } else {
693        std::collections::HashSet::new()
694    };
695    mirror_inner(src_dir, dest_dir, already_done, extras, &test_only)
696}
697
698fn mirror_inner(
699    src_dir: &Path,
700    dest_dir: &Path,
701    already_done: &mut std::collections::HashSet<PathBuf>,
702    extras: &[(String, Vec<String>)],
703    test_only: &std::collections::HashSet<PathBuf>,
704) -> Result<()> {
705    if !src_dir.is_dir() {
706        return Ok(());
707    }
708    fs::create_dir_all(dest_dir).with_context(|| format!("creating {}", dest_dir.display()))?;
709
710    for entry in
711        fs::read_dir(src_dir).with_context(|| format!("reading dir {}", src_dir.display()))?
712    {
713        let entry = entry?;
714        let path = entry.path();
715        let dest = dest_dir.join(entry.file_name());
716
717        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
718        let is_test_only = test_only.contains(&canonical);
719        if !already_done.insert(canonical) {
720            continue;
721        }
722
723        if path.is_dir() {
724            mirror_inner(&path, &dest, already_done, extras, test_only)?;
725        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
726            let source =
727                fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
728            // Explicit #![strict] always lowers; force-strict lowers
729            // everything except cfg(test)-only files (RT-88).
730            let lower_this = trust_lower::is_strict_source(&source)
731                || (crate_is_force_strict() && !is_test_only);
732            if lower_this {
733                let out = trust_lower::lower_with_extra_callees_forced(
734                    &source,
735                    extras,
736                    crate_is_force_strict(),
737                )
738                .with_context(|| format!("lowering {}", path.display()))?;
739                emit_diagnostics(&out, &source, &path)?;
740                // Also lower any doc-test code blocks embedded in `///` /
741                // `//!` comments. rustdoc extracts these snippets verbatim
742                // and submits them to rustc; if they contain named-arg
743                // syntax they'd fail on stable. Best-effort: leave blocks
744                // we can't parse untouched (e.g. `ignore`/`text` fences,
745                // or partial snippets that don't parse standalone).
746                let rewritten = lower_doctests_in_source(&out.source);
747                let tmp = dest_dir.join(format!(
748                    ".{}.{}.tmp",
749                    entry.file_name().to_string_lossy(),
750                    std::process::id()
751                ));
752                fs::write(&tmp, &rewritten)?;
753                fs::rename(&tmp, &dest)?;
754            } else {
755                // RT-75: COPY, never hard-link. A hard link shares the inode
756                // with the source file, so any later write/truncate of the
757                // cached copy would destroy the user's original `.rs`.
758                fs::copy(&path, &dest).with_context(|| format!("copying {}", path.display()))?;
759            }
760        } else {
761            // RT-75: non-`.rs` sibling files — copy (best-effort), never
762            // hard-link, for the same inode-sharing reason as above.
763            let _ = fs::copy(&path, &dest);
764        }
765    }
766    Ok(())
767}
768
769/// Lower Trust syntax inside doc-test code blocks (`/// ```...```` ` and
770/// `//! ```...```` `) so `rustdoc --test` doesn't choke when rustc compiles
771/// each snippet on stable. Used by the mirror pass after the file itself
772/// has been lowered.
773///
774/// Strategy: walk the source line-by-line, find runs of doc-comment lines
775/// (`///` or `//!`), then within each run locate ```` ``` ```` fences. The
776/// fence info-string is treated as a doc-test if it's empty or starts with
777/// `rust` (mirroring rustdoc's own classification). Non-test fences
778/// (`text`, `ignore`, `compile_fail`, …) are left alone — rustdoc won't
779/// hand them to rustc anyway, and `compile_fail` tests intentionally don't
780/// compile, so re-lowering them could hide the intended failure.
781///
782/// For each test snippet we try two parse strategies:
783///   1. Lower the snippet as-is (it's already a valid Rust file).
784///   2. If that fails, wrap in `fn __doctest() { … }` and lower; on
785///      success, strip the wrapper.
786///
787/// If both fail (snippet doesn't parse standalone — e.g. it's only an
788/// expression, or has hidden `#`-prefixed lines), we leave the block
789/// unchanged. The doc-test will fail at rustc time with a clearer error
790/// than anything we could produce.
791pub fn lower_doctests_in_source(source: &str) -> String {
792    let mut out = String::with_capacity(source.len());
793    let lines: Vec<&str> = source.lines().collect();
794    let mut i = 0;
795    while let Some(line) = lines.get(i) {
796        let (Some(prefix), Some(_)) = (doc_prefix(line), doc_body(line)) else {
797            out.push_str(line);
798            out.push('\n');
799            i += 1;
800            continue;
801        };
802        // Collect this doc-comment block (consecutive lines with the same prefix).
803        let block_start = i;
804        while lines.get(i).is_some_and(|l| doc_prefix(l) == Some(prefix)) {
805            i += 1;
806        }
807        let block_end = i;
808        let block = rewrite_doc_block(&lines[block_start..block_end], prefix);
809        out.push_str(&block);
810        // `rewrite_doc_block` always ends with a newline-per-line layout.
811    }
812    out
813}
814
815fn doc_prefix(line: &str) -> Option<&'static str> {
816    let trimmed = line.trim_start();
817    if trimmed.starts_with("///") {
818        Some("///")
819    } else if trimmed.starts_with("//!") {
820        Some("//!")
821    } else {
822        None
823    }
824}
825
826fn doc_body(line: &str) -> Option<&str> {
827    let trimmed = line.trim_start();
828    let body = trimmed
829        .strip_prefix("///")
830        .or_else(|| trimmed.strip_prefix("//!"))?;
831    Some(body.strip_prefix(' ').unwrap_or(body))
832}
833
834/// Rewrite a contiguous doc-comment block, transforming code-fenced
835/// doc-test snippets through `trust_lower::lower`.
836fn rewrite_doc_block(lines: &[&str], prefix: &str) -> String {
837    // Extract the indent of the first line so we can reproduce it.
838    let first = lines[0];
839    let indent_len = first.len().saturating_sub(first.trim_start().len());
840    let indent = &first[..indent_len];
841
842    // Walk lines; when we hit a fence inside a doc-test block, buffer
843    // the code lines, lower the buffer, then splice the lowered text
844    // back as new doc-comment lines.
845    let mut out = String::new();
846    let mut in_block = false;
847    let mut is_test_block = false;
848    let mut code_buf = String::new();
849    let mut block_indent_after_prefix = String::new();
850
851    for line in lines {
852        let body = doc_body(line).unwrap_or("");
853        let body_trim = body.trim_start();
854
855        if body_trim.starts_with("```") {
856            if !in_block {
857                // Opening fence. Decide if this is a doc-test fence.
858                let info = body_trim.trim_start_matches('`').trim();
859                is_test_block = info.is_empty()
860                    || info == "rust"
861                    || info.starts_with("rust,")
862                    || info.starts_with("rust ");
863                in_block = true;
864                code_buf.clear();
865                block_indent_after_prefix.clear();
866                // Capture the indentation that lives *between* `///` and
867                // the visible body, so we can reproduce it on output.
868                if let Some(stripped) = line.trim_start().strip_prefix(prefix) {
869                    let after = stripped;
870                    let extra_indent_len = after.len().saturating_sub(after.trim_start().len());
871                    block_indent_after_prefix = after[..extra_indent_len].to_string();
872                }
873                out.push_str(line);
874                out.push('\n');
875                continue;
876            }
877            // Closing fence: flush the buffered code (lowered if possible).
878            let lowered = if is_test_block {
879                try_lower_doctest(&code_buf).unwrap_or_else(|| code_buf.clone())
880            } else {
881                code_buf.clone()
882            };
883            for code_line in lowered.lines() {
884                out.push_str(indent);
885                out.push_str(prefix);
886                if !code_line.is_empty() {
887                    if block_indent_after_prefix.is_empty() {
888                        out.push(' ');
889                    } else {
890                        out.push_str(&block_indent_after_prefix);
891                    }
892                }
893                out.push_str(code_line);
894                out.push('\n');
895            }
896            out.push_str(line);
897            out.push('\n');
898            in_block = false;
899            code_buf.clear();
900            continue;
901        }
902
903        if in_block {
904            // Accumulate the raw body (minus the doc prefix + one space).
905            code_buf.push_str(body);
906            code_buf.push('\n');
907        } else {
908            out.push_str(line);
909            out.push('\n');
910        }
911    }
912
913    // Unclosed fence — emit the buffer verbatim to avoid losing content.
914    if in_block {
915        for code_line in code_buf.lines() {
916            out.push_str(indent);
917            out.push_str(prefix);
918            out.push(' ');
919            out.push_str(code_line);
920            out.push('\n');
921        }
922    }
923    out
924}
925
926/// Try to lower a doc-test snippet. Returns `Some(lowered)` if the
927/// rewriter produced new source; `None` if the snippet doesn't parse
928/// standalone (leave unchanged in that case).
929fn try_lower_doctest(snippet: &str) -> Option<String> {
930    // Strategy 1: snippet is a full Rust file (contains `fn main`, items, etc.).
931    if let Ok(out) = trust_lower::lower(snippet) {
932        if !out.diagnostics.iter().any(|d| d.is_error()) {
933            return Some(strip_hidden_doctest_prefix(out.source));
934        }
935    }
936    // Strategy 2: wrap as `fn __d() { … }` (snippet is a stmt sequence).
937    let wrapped = format!("fn __trust_doctest() {{\n{snippet}\n}}\n");
938    let out = trust_lower::lower(&wrapped).ok()?;
939    if out.diagnostics.iter().any(|d| d.is_error()) {
940        return None;
941    }
942    // Strip the wrapper. prettyplease emits a stable shape:
943    //     fn __trust_doctest() {
944    //         <body>
945    //     }
946    let unwrapped = unwrap_doctest_fn(&out.source)?;
947    Some(unwrapped)
948}
949
950fn unwrap_doctest_fn(source: &str) -> Option<String> {
951    let start = source.find("fn __trust_doctest()")?;
952    let open = source[start..].find('{')? + start;
953    // Find the matching close brace.
954    let bytes = source.as_bytes();
955    let mut depth = 0i32;
956    let mut close = None;
957    for (i, &b) in bytes.iter().enumerate().skip(open) {
958        match b {
959            b'{' => depth += 1,
960            b'}' => {
961                depth -= 1;
962                if depth == 0 {
963                    close = Some(i);
964                    break;
965                }
966            }
967            _ => {}
968        }
969    }
970    let close = close?;
971    let body = &source[open + 1..close];
972    // Strip leading/trailing blank lines and dedent four-space indent
973    // (prettyplease default).
974    let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
975    while lines.first().is_some_and(|l| l.trim().is_empty()) {
976        lines.remove(0);
977    }
978    while lines.last().is_some_and(|l| l.trim().is_empty()) {
979        lines.pop();
980    }
981    let dedent = lines
982        .iter()
983        .filter(|l| !l.trim().is_empty())
984        .map(|l| l.len().saturating_sub(l.trim_start().len()))
985        .min()
986        .unwrap_or(0);
987    let out: String = lines
988        .iter()
989        .map(|l| {
990            if l.len() >= dedent {
991                format!("{}\n", &l[dedent..])
992            } else {
993                "\n".to_string()
994            }
995        })
996        .collect();
997    Some(out)
998}
999
1000/// rustdoc treats lines beginning with `# ` (after the doc-comment prefix)
1001/// as hidden setup. Our lowering loses that distinction because we feed
1002/// the raw body to syn. After lowering, restore the `# ` markers wouldn't
1003/// be possible — so for now we just pass through (Rust file strategy
1004/// already drops `#`-prefixed lines silently if they aren't syntax).
1005fn strip_hidden_doctest_prefix(s: String) -> String {
1006    s
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    /// Serialises tests that read or write `TRUST_MESSAGE_FORMAT` — the
1014    /// process env is shared across parallel test threads.
1015    static MESSAGE_FORMAT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1016
1017    /// Scoped env guard: sets (or clears) `TRUST_MESSAGE_FORMAT` and restores
1018    /// the previous value on drop, holding [`MESSAGE_FORMAT_LOCK`] throughout
1019    /// so the env mutation can't leak into a concurrently-running test.
1020    struct MessageFormatGuard<'a> {
1021        prev: Option<String>,
1022        _lock: std::sync::MutexGuard<'a, ()>,
1023    }
1024
1025    impl MessageFormatGuard<'_> {
1026        fn set(value: Option<&str>) -> Self {
1027            let lock = MESSAGE_FORMAT_LOCK
1028                .lock()
1029                .unwrap_or_else(|poisoned| poisoned.into_inner());
1030            let prev = env::var("TRUST_MESSAGE_FORMAT").ok();
1031            match value {
1032                Some(v) => env::set_var("TRUST_MESSAGE_FORMAT", v),
1033                None => env::remove_var("TRUST_MESSAGE_FORMAT"),
1034            }
1035            MessageFormatGuard { prev, _lock: lock }
1036        }
1037    }
1038
1039    impl Drop for MessageFormatGuard<'_> {
1040        fn drop(&mut self) {
1041            match &self.prev {
1042                Some(prev) => env::set_var("TRUST_MESSAGE_FORMAT", prev),
1043                None => env::remove_var("TRUST_MESSAGE_FORMAT"),
1044            }
1045        }
1046    }
1047
1048    /// RT-96: with `TRUST_MESSAGE_FORMAT=json`, the wrapper emits one
1049    /// machine-parseable JSON document per file (same shape as
1050    /// `trust check --format json`) instead of human `[R0001]` lines — and
1051    /// still bails because the diagnostic is an error.
1052    #[test]
1053    fn json_message_format_emits_parseable_document() {
1054        let _guard = MessageFormatGuard::set(Some("json"));
1055
1056        let source =
1057            "#![strict]\nfn main() { let v: Option<i32> = Some(1); let _ = v.unwrap(); }\n";
1058        let out = trust_lower::lower(source).expect("lowering strict source");
1059        let mut buf: Vec<u8> = Vec::new();
1060        let result = emit_diagnostics_to(&out, source, Path::new("src/main.rs"), &mut buf);
1061        assert!(result.is_err(), "R0001 is an error — must still bail");
1062
1063        let text = String::from_utf8(buf).expect("utf8 output");
1064        let doc: serde_json::Value =
1065            serde_json::from_str(text.trim()).expect("output must be valid JSON");
1066        assert_eq!(doc["file"], "src/main.rs");
1067        let rules: Vec<&str> = doc["diagnostics"]
1068            .as_array()
1069            .expect("diagnostics array")
1070            .iter()
1071            .filter_map(|d| d["rule"].as_str())
1072            .collect();
1073        assert!(rules.contains(&"R0001"), "expected R0001 in {rules:?}");
1074    }
1075
1076    /// Without the env var, output stays in today's human form.
1077    #[test]
1078    fn default_message_format_is_human_lines() {
1079        let _guard = MessageFormatGuard::set(None);
1080        let source =
1081            "#![strict]\nfn main() { let v: Option<i32> = Some(1); let _ = v.unwrap(); }\n";
1082        let out = trust_lower::lower(source).expect("lowering strict source");
1083        let mut buf: Vec<u8> = Vec::new();
1084        let result = emit_diagnostics_to(&out, source, Path::new("src/main.rs"), &mut buf);
1085        assert!(result.is_err());
1086        let text = String::from_utf8(buf).expect("utf8 output");
1087        assert!(
1088            text.contains("[R0001] error:"),
1089            "expected human line, got: {text}"
1090        );
1091    }
1092
1093    /// RT-88: files reachable only via `#[cfg(test)] mod x;` are exempt from
1094    /// force-strict — including transitively through plain `mod` decls in
1095    /// test-only files. Explicitly-marked or normally-declared files are not.
1096    #[test]
1097    fn cfg_test_mod_files_are_detected_transitively() {
1098        let base = std::env::temp_dir().join(format!("trust-rt88-{}", std::process::id()));
1099        let src = base.join("src");
1100        let _ = fs::remove_dir_all(&base);
1101        fs::create_dir_all(&src).unwrap();
1102        fs::write(
1103            src.join("main.rs"),
1104            "mod shipping;\n#[cfg(test)]\nmod tests;\nfn main() {}\n",
1105        )
1106        .unwrap();
1107        fs::write(src.join("shipping.rs"), "pub fn ship() {}\n").unwrap();
1108        fs::write(src.join("tests.rs"), "mod helpers;\nfn t() {}\n").unwrap();
1109        fs::write(src.join("helpers.rs"), "pub fn helper() {}\n").unwrap();
1110
1111        let test_only = collect_test_only_files(&src);
1112        let has = |name: &str| {
1113            test_only
1114                .iter()
1115                .any(|p| p.file_name().and_then(|f| f.to_str()) == Some(name))
1116        };
1117        assert!(has("tests.rs"), "directly cfg(test)-declared file");
1118        assert!(has("helpers.rs"), "transitively reached through tests.rs");
1119        assert!(!has("shipping.rs"), "normal mod stays enforced");
1120        assert!(!has("main.rs"), "the crate root is never test-only");
1121
1122        let _ = fs::remove_dir_all(&base);
1123    }
1124
1125    /// PR #1 review regression: `#[cfg(not(test))]` (and other negated test
1126    /// predicates) select PRODUCTION builds and must never be exempted from
1127    /// force-strict; positive `test` predicates (bare or inside any/all) are.
1128    #[test]
1129    fn negated_test_cfgs_are_not_test_only() {
1130        let base = std::env::temp_dir().join(format!("trust-pr1-{}", std::process::id()));
1131        let src = base.join("src");
1132        let _ = fs::remove_dir_all(&base);
1133        fs::create_dir_all(&src).unwrap();
1134        fs::write(
1135            src.join("main.rs"),
1136            "#[cfg(not(test))]\nmod prod;\n\
1137             #[cfg(all(unix, not(test)))]\nmod prod_unix;\n\
1138             #[cfg(all(unix, test))]\nmod unix_tests;\n\
1139             #[cfg(test)]\nmod tests;\n\
1140             #[cfg(feature = \"test\")]\nmod feature_named_test;\n\
1141             fn main() {}\n",
1142        )
1143        .unwrap();
1144        for name in [
1145            "prod.rs",
1146            "prod_unix.rs",
1147            "unix_tests.rs",
1148            "tests.rs",
1149            "feature_named_test.rs",
1150        ] {
1151            fs::write(src.join(name), "pub fn x() {}\n").unwrap();
1152        }
1153
1154        let test_only = collect_test_only_files(&src);
1155        let has = |name: &str| {
1156            test_only
1157                .iter()
1158                .any(|p| p.file_name().and_then(|f| f.to_str()) == Some(name))
1159        };
1160        assert!(!has("prod.rs"), "cfg(not(test)) is a production module");
1161        assert!(!has("prod_unix.rs"), "all(unix, not(test)) is production");
1162        assert!(has("unix_tests.rs"), "all(unix, test) is test-only");
1163        assert!(has("tests.rs"), "plain cfg(test) is test-only");
1164        assert!(
1165            !has("feature_named_test.rs"),
1166            "feature = \"test\" is a feature gate, not the test predicate"
1167        );
1168
1169        let _ = fs::remove_dir_all(&base);
1170    }
1171
1172    /// RT-81: project-level strict applies only to packages the user opted in,
1173    /// never to dependencies compiled by the same wrapper.
1174    #[test]
1175    fn force_strict_is_scoped_by_package_name() {
1176        // The user's own crate is in the set → forced strict.
1177        assert!(force_strict_for(Some("my-app"), Some("my-app")));
1178        // A dependency built in the same `cargo trustc build` carries its own
1179        // CARGO_PKG_NAME, which is NOT in the set → never force-lowered.
1180        assert!(!force_strict_for(Some("my-app"), Some("serde")));
1181        // Multi-package set, with whitespace.
1182        assert!(force_strict_for(Some("a, b ,c"), Some("b")));
1183        // Absent set or name is never a match.
1184        assert!(!force_strict_for(None, Some("my-app")));
1185        assert!(!force_strict_for(Some("my-app"), None));
1186        // Empty name must not match an empty element from a trailing comma.
1187        assert!(!force_strict_for(Some("a,"), Some("")));
1188    }
1189
1190    /// RT-75 regression: the cache mirror must COPY non-strict files, not
1191    /// hard-link them. A hard link shares the inode, so clobbering the cached
1192    /// copy would truncate the user's original source. This test mirrors a
1193    /// plain file, clobbers the cached copy, and asserts the source survives.
1194    #[test]
1195    fn mirror_copies_rather_than_hardlinks_source() {
1196        let base = std::env::temp_dir().join(format!("trust-rt75-{}", std::process::id()));
1197        let src = base.join("src");
1198        let dest = base.join("cache");
1199        let _ = fs::remove_dir_all(&base);
1200        fs::create_dir_all(&src).expect("create src");
1201        let src_file = src.join("plain.rs");
1202        fs::write(&src_file, "pub fn keep() {}\n").expect("write src");
1203
1204        let mut visited = std::collections::HashSet::new();
1205        mirror_module_tree(&src, &dest, &mut visited).expect("mirror");
1206
1207        // Clobber the cached copy to zero length.
1208        fs::write(dest.join("plain.rs"), "").expect("clobber cache");
1209
1210        // The original must be untouched — proving a copy, not a hard link.
1211        let after = fs::read_to_string(&src_file).expect("read src after");
1212        assert_eq!(
1213            after, "pub fn keep() {}\n",
1214            "source file was corrupted — cache shares an inode with it"
1215        );
1216        let _ = fs::remove_dir_all(&base);
1217    }
1218}