Skip to main content

zccache_cli/
symbols.rs

1//! Download and install matching debug symbols for the running zccache build.
2//!
3//! Motivation (zccache#276): when zccache.exe / zccache-daemon.exe faults on
4//! Windows, the embedded CodeView record points at compile-time paths in
5//! `target/.../release/deps/` that don't exist on the user's machine. Without
6//! the matching `.pdb` files, cdb/WinDbg cannot resolve function names and
7//! the stack trace is only RVAs. The release build now ships a separate
8//! `<root>-debug.zip` (or `.tar.gz`) archive with the per-binary sidecars;
9//! this module gives users a one-shot way to download the archive that
10//! matches their installed binary and drop the sidecars next to the exe so
11//! `dbghelp` finds them via its same-directory fallback.
12//!
13//! The build's version and target triple are embedded at compile time
14//! (`CARGO_PKG_VERSION` and `ZCCACHE_BUILD_TARGET` from `build.rs`) so the
15//! defaults always match the running binary.
16
17use std::env;
18use std::fs::{self, File, OpenOptions};
19use std::io;
20use std::path::{Path, PathBuf};
21
22const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
23const BUILD_TARGET: &str = env!("ZCCACHE_BUILD_TARGET");
24const RELEASE_BASE_URL: &str = "https://github.com/zackees/zccache/releases/download";
25const LOCK_FILENAME: &str = ".zccache-symbols.lock";
26
27/// Env var that, when set to a non-empty value, makes `zccache.exe` install
28/// missing debug symbols next to itself on startup. Idempotent — installs are
29/// skipped when the sidecars are already present.
30pub const AUTO_INSTALL_ENV: &str = "ZCCACHE_AUTO_INSTALL_SYMBOLS";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LockBehavior {
34    /// Block until the install lock is acquired. Used for the explicit
35    /// `zccache symbols install` subcommand.
36    #[default]
37    Wait,
38    /// Try once; if another process holds the lock, return without doing
39    /// any work. Used for the auto-install hot path so compile-loop wrappers
40    /// don't all pile up on a single download.
41    SkipIfBusy,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct InstallOptions {
46    /// Version to fetch. Defaults to the running binary's version.
47    pub version: Option<String>,
48    /// Target triple to fetch. Defaults to the running binary's build target.
49    pub target: Option<String>,
50    /// Directory to drop sidecars into. Defaults to the directory containing
51    /// the running zccache executable.
52    pub prefix: Option<PathBuf>,
53    /// Re-download even if matching sidecars are already present.
54    pub force: bool,
55    /// What to do when another zccache process is already mid-install.
56    pub lock_behavior: LockBehavior,
57}
58
59#[derive(Debug)]
60pub struct InstallReport {
61    pub prefix: PathBuf,
62    pub installed: Vec<PathBuf>,
63    pub skipped_already_present: bool,
64    /// Set when `LockBehavior::SkipIfBusy` and another process is currently
65    /// holding the install lock. Caller can choose to retry later.
66    pub skipped_lock_busy: bool,
67    pub url: String,
68    /// Set when the archive came from the on-disk cache under
69    /// `<cache_dir>/symbols/` rather than the network.
70    pub cache_hit: bool,
71}
72
73#[derive(Debug, thiserror::Error)]
74pub enum SymbolsError {
75    #[error("unable to locate the running zccache binary: {0}")]
76    LocateExe(#[source] io::Error),
77    #[error("network error fetching {url}: {source}")]
78    Fetch {
79        url: String,
80        #[source]
81        source: reqwest::Error,
82    },
83    #[error("release asset returned HTTP {status} for {url}")]
84    HttpStatus { url: String, status: u16 },
85    #[error("io error writing {path}: {source}")]
86    Io {
87        path: PathBuf,
88        #[source]
89        source: io::Error,
90    },
91    #[error("zip error: {0}")]
92    Zip(#[from] zip::result::ZipError),
93    #[error("archive contained no debug sidecars (expected .pdb/.dwp/.dSYM entries)")]
94    EmptyArchive,
95    #[error("tokio runtime error: {0}")]
96    Runtime(#[source] io::Error),
97}
98
99#[derive(Debug, Clone, Copy)]
100enum ArchiveKind {
101    /// Windows `-debug.zip` containing `.pdb` files.
102    WindowsPdb,
103    /// macOS `-debug.tar.gz` containing `.dSYM` bundles.
104    MacOsDsym,
105    /// Linux `-debug.tar.gz` containing `.dwp` files.
106    LinuxDwp,
107}
108
109impl ArchiveKind {
110    fn for_target(target: &str) -> Self {
111        if target.contains("pc-windows") {
112            Self::WindowsPdb
113        } else if target.contains("apple-darwin") || target.contains("apple-ios") {
114            Self::MacOsDsym
115        } else {
116            Self::LinuxDwp
117        }
118    }
119
120    fn file_extension(self) -> &'static str {
121        match self {
122            Self::WindowsPdb => "zip",
123            Self::MacOsDsym | Self::LinuxDwp => "tar.gz",
124        }
125    }
126
127    /// Sidecar filenames expected next to the binaries after extraction.
128    /// Matches `.github/actions/build-target/action.yml` — the producer
129    /// side. Keep in sync.
130    fn expected_sidecars(self) -> &'static [&'static str] {
131        match self {
132            // rustc's MSVC linker writes PDBs using the underscored crate
133            // name (zccache#276): `zccache-daemon.exe` -> `zccache_daemon.pdb`.
134            Self::WindowsPdb => &["zccache.pdb", "zccache_daemon.pdb", "zccache_fp.pdb"],
135            Self::MacOsDsym => &["zccache.dSYM", "zccache-daemon.dSYM", "zccache-fp.dSYM"],
136            Self::LinuxDwp => &["zccache.dwp", "zccache-daemon.dwp", "zccache-fp.dwp"],
137        }
138    }
139}
140
141fn resolved_prefix(opts_prefix: Option<&Path>) -> Result<PathBuf, SymbolsError> {
142    if let Some(p) = opts_prefix {
143        return Ok(p.to_path_buf());
144    }
145    let exe = env::current_exe().map_err(SymbolsError::LocateExe)?;
146    Ok(exe
147        .parent()
148        .map(Path::to_path_buf)
149        .unwrap_or_else(|| PathBuf::from(".")))
150}
151
152fn build_url(version: &str, target: &str, kind: ArchiveKind) -> String {
153    // GitHub release tags omit the `v` (e.g. `1.6.0`) but asset filenames
154    // include it (e.g. `zccache-v1.6.0-...`). Build both sides correctly.
155    let tag = version;
156    let ext = kind.file_extension();
157    format!(
158        "{base}/{tag}/zccache-v{version}-{target}-debug.{ext}",
159        base = RELEASE_BASE_URL,
160    )
161}
162
163fn all_sidecars_present(prefix: &Path, kind: ArchiveKind) -> bool {
164    kind.expected_sidecars()
165        .iter()
166        .all(|name| prefix.join(name).exists())
167}
168
169/// Synchronous entry point. Wraps the async download in a private tokio
170/// runtime so callers don't need an executor handy.
171pub fn install(opts: InstallOptions) -> Result<InstallReport, SymbolsError> {
172    let runtime = tokio::runtime::Builder::new_current_thread()
173        .enable_all()
174        .build()
175        .map_err(SymbolsError::Runtime)?;
176    runtime.block_on(install_async(opts))
177}
178
179pub async fn install_async(opts: InstallOptions) -> Result<InstallReport, SymbolsError> {
180    let version = opts
181        .version
182        .clone()
183        .unwrap_or_else(|| PKG_VERSION.to_string());
184    let target = opts
185        .target
186        .clone()
187        .unwrap_or_else(|| BUILD_TARGET.to_string());
188    let prefix = resolved_prefix(opts.prefix.as_deref())?;
189    let kind = ArchiveKind::for_target(&target);
190    let url = build_url(&version, &target, kind);
191
192    // Lock-free fast path: avoids creating a lockfile when symbols are
193    // already installed (the common steady-state).
194    if !opts.force && all_sidecars_present(&prefix, kind) {
195        return Ok(InstallReport {
196            prefix,
197            installed: Vec::new(),
198            skipped_already_present: true,
199            skipped_lock_busy: false,
200            url,
201            cache_hit: false,
202        });
203    }
204
205    fs::create_dir_all(&prefix).map_err(|e| SymbolsError::Io {
206        path: prefix.clone(),
207        source: e,
208    })?;
209
210    // Cross-process lock so two concurrent zccache invocations don't both
211    // download the same archive and race on extraction. The lock is an OS
212    // advisory lock on the lockfile handle (fs2 -> LockFileEx on Windows,
213    // fcntl on Unix); the kernel releases it when the File handle drops,
214    // when the process exits cleanly, when it panics, or when it's killed
215    // with SIGKILL / TerminateProcess. There is no stale-lockfile cleanup
216    // needed.
217    let lockfile_path = prefix.join(LOCK_FILENAME);
218    let lockfile = open_lockfile(&lockfile_path)?;
219    if !acquire_exclusive(&lockfile, opts.lock_behavior)? {
220        return Ok(InstallReport {
221            prefix,
222            installed: Vec::new(),
223            skipped_already_present: false,
224            skipped_lock_busy: true,
225            url,
226            cache_hit: false,
227        });
228    }
229
230    // Re-check under the lock: another process may have completed the
231    // install between our fast-path check and acquiring the lock.
232    if !opts.force && all_sidecars_present(&prefix, kind) {
233        return Ok(InstallReport {
234            prefix,
235            installed: Vec::new(),
236            skipped_already_present: true,
237            skipped_lock_busy: false,
238            url,
239            cache_hit: false,
240        });
241    }
242
243    let (bytes, cache_hit) = fetch_archive(&url, &version, &target, kind, opts.force).await?;
244
245    let installed = match kind {
246        ArchiveKind::WindowsPdb => extract_zip(&bytes, &prefix)?,
247        ArchiveKind::MacOsDsym | ArchiveKind::LinuxDwp => extract_targz(&bytes, &prefix)?,
248    };
249
250    if installed.is_empty() {
251        return Err(SymbolsError::EmptyArchive);
252    }
253
254    // Lock released on `lockfile` drop here.
255    drop(lockfile);
256
257    Ok(InstallReport {
258        prefix,
259        installed,
260        skipped_already_present: false,
261        skipped_lock_busy: false,
262        url,
263        cache_hit,
264    })
265}
266
267/// Returns the bytes of the matching debug archive plus a flag indicating
268/// whether they came from the on-disk archive cache rather than the network.
269///
270/// Cache layout: `<zccache cache dir>/symbols/<asset-filename>`. The asset
271/// filename already encodes version + target, so two callers asking for the
272/// same archive land on the same path. Writes use a same-directory tempfile
273/// + atomic rename, so a kill during download can't leave a partial file
274/// that looks like a cache hit to the next caller.
275async fn fetch_archive(
276    url: &str,
277    version: &str,
278    target: &str,
279    kind: ArchiveKind,
280    force: bool,
281) -> Result<(Vec<u8>, bool), SymbolsError> {
282    let cache_path = archive_cache_path(version, target, kind);
283
284    if !force {
285        if let Ok(bytes) = fs::read(&cache_path) {
286            if !bytes.is_empty() {
287                return Ok((bytes, true));
288            }
289        }
290    }
291
292    let client = reqwest::Client::builder()
293        .user_agent(concat!("zccache/", env!("CARGO_PKG_VERSION")))
294        .build()
295        .map_err(|e| SymbolsError::Fetch {
296            url: url.to_string(),
297            source: e,
298        })?;
299    let response = client
300        .get(url)
301        .send()
302        .await
303        .map_err(|e| SymbolsError::Fetch {
304            url: url.to_string(),
305            source: e,
306        })?;
307    if !response.status().is_success() {
308        return Err(SymbolsError::HttpStatus {
309            url: url.to_string(),
310            status: response.status().as_u16(),
311        });
312    }
313    let bytes = response.bytes().await.map_err(|e| SymbolsError::Fetch {
314        url: url.to_string(),
315        source: e,
316    })?;
317
318    // Best-effort cache write: a permission or disk-full error here should
319    // not fail the install — the bytes are already in memory and the caller
320    // can extract them.
321    if let Some(parent) = cache_path.parent() {
322        if fs::create_dir_all(parent).is_ok() {
323            let _ = write_atomically(&cache_path, &mut io::Cursor::new(bytes.as_ref()));
324        }
325    }
326
327    Ok((bytes.to_vec(), false))
328}
329
330fn archive_cache_path(version: &str, target: &str, kind: ArchiveKind) -> PathBuf {
331    let filename = format!(
332        "zccache-v{version}-{target}-debug.{ext}",
333        ext = kind.file_extension(),
334    );
335    PathBuf::from(zccache_core::config::symbols_cache_dir().into_path_buf()).join(filename)
336}
337
338fn open_lockfile(path: &Path) -> Result<File, SymbolsError> {
339    OpenOptions::new()
340        .read(true)
341        .write(true)
342        .create(true)
343        .truncate(false)
344        .open(path)
345        .map_err(|e| SymbolsError::Io {
346            path: path.to_path_buf(),
347            source: e,
348        })
349}
350
351/// Acquire the install lock. Returns `Ok(true)` on success, `Ok(false)` only
352/// when `SkipIfBusy` and another holder is present. Other errors propagate
353/// as `SymbolsError::Io` so a permission problem surfaces clearly.
354fn acquire_exclusive(file: &File, behavior: LockBehavior) -> Result<bool, SymbolsError> {
355    // fs2 trait methods are called via UFCS to avoid the ambiguity with
356    // `std::fs::File::try_lock_exclusive` that landed in Rust 1.89.
357    match behavior {
358        LockBehavior::SkipIfBusy => match fs2::FileExt::try_lock_exclusive(file) {
359            Ok(()) => Ok(true),
360            Err(err) if is_would_block(&err) => Ok(false),
361            Err(err) => Err(SymbolsError::Io {
362                path: PathBuf::from(LOCK_FILENAME),
363                source: err,
364            }),
365        },
366        LockBehavior::Wait => fs2::FileExt::lock_exclusive(file)
367            .map(|()| true)
368            .map_err(|err| SymbolsError::Io {
369                path: PathBuf::from(LOCK_FILENAME),
370                source: err,
371            }),
372    }
373}
374
375fn is_would_block(err: &io::Error) -> bool {
376    if matches!(
377        err.kind(),
378        io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
379    ) {
380        return true;
381    }
382    // Windows: `LockFileEx` with `LOCKFILE_FAIL_IMMEDIATELY` returns
383    // `ERROR_LOCK_VIOLATION (33)`, which std currently surfaces as
384    // `ErrorKind::Uncategorized`. Treat it as "would block".
385    #[cfg(windows)]
386    {
387        if matches!(err.raw_os_error(), Some(33)) {
388            return true;
389        }
390    }
391    false
392}
393
394/// Extract `.pdb` files from a zip into `prefix`. Strips the archive's
395/// top-level directory (`zccache-vX.Y.Z-<target>-debug/`) so files land
396/// directly next to the binaries. Each file is written via tempfile + rename
397/// so an interrupted install can't leave a partial PDB that subsequent
398/// `all_sidecars_present` checks would treat as complete.
399fn extract_zip(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
400    let cursor = io::Cursor::new(bytes);
401    let mut archive = zip::ZipArchive::new(cursor)?;
402    let mut installed = Vec::new();
403    for i in 0..archive.len() {
404        let mut entry = archive.by_index(i)?;
405        if entry.is_dir() {
406            continue;
407        }
408        let raw_name = match entry.enclosed_name() {
409            Some(p) => p.to_path_buf(),
410            None => continue,
411        };
412        // Keep only the trailing path component for PDB sidecars; debuggers
413        // search by basename in the binary's directory.
414        let leaf = match raw_name.file_name() {
415            Some(n) => Path::new(n).to_path_buf(),
416            None => continue,
417        };
418        if !is_debug_sidecar(&leaf) {
419            continue;
420        }
421        let dest = prefix.join(&leaf);
422        write_atomically(&dest, &mut entry)?;
423        installed.push(dest);
424    }
425    Ok(installed)
426}
427
428/// Write a single file via a same-directory tempfile + rename. On Windows
429/// `NamedTempFile::persist` uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`
430/// which is atomic for any concurrent reader.
431fn write_atomically(dest: &Path, src: &mut dyn io::Read) -> Result<(), SymbolsError> {
432    let parent = dest.parent().unwrap_or(Path::new("."));
433    let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| SymbolsError::Io {
434        path: parent.to_path_buf(),
435        source: e,
436    })?;
437    io::copy(src, tmp.as_file_mut()).map_err(|e| SymbolsError::Io {
438        path: tmp.path().to_path_buf(),
439        source: e,
440    })?;
441    tmp.persist(dest).map_err(|e| SymbolsError::Io {
442        path: dest.to_path_buf(),
443        source: e.error,
444    })?;
445    Ok(())
446}
447
448/// Extract `.dwp` files or `.dSYM` bundles from a gzip-compressed tarball.
449fn extract_targz(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
450    let cursor = io::Cursor::new(bytes);
451    let decoder = flate2::read::GzDecoder::new(cursor);
452    let mut archive = tar::Archive::new(decoder);
453    let mut installed = Vec::new();
454    for entry in archive.entries().map_err(|e| SymbolsError::Io {
455        path: prefix.to_path_buf(),
456        source: e,
457    })? {
458        let mut entry = entry.map_err(|e| SymbolsError::Io {
459            path: prefix.to_path_buf(),
460            source: e,
461        })?;
462        let raw_path = match entry.path() {
463            Ok(p) => p.into_owned(),
464            Err(_) => continue,
465        };
466        let components: Vec<_> = raw_path.components().collect();
467        // The archive layout is `<root>/<sidecar...>`. Strip the top-level
468        // wrapper directory so contents land directly under `prefix`.
469        if components.len() < 2 {
470            continue;
471        }
472        let inner: PathBuf = components[1..]
473            .iter()
474            .map(|c| c.as_os_str())
475            .collect::<PathBuf>();
476        // Filter: top-level sidecar entry must look like a debug file or
477        // dSYM bundle root. Children of dSYM bundles are copied verbatim
478        // once the bundle root is allowed through.
479        let first_inner = match inner.components().next() {
480            Some(c) => Path::new(c.as_os_str()).to_path_buf(),
481            None => continue,
482        };
483        if !is_debug_sidecar(&first_inner) {
484            continue;
485        }
486        let dest = prefix.join(&inner);
487        if entry.header().entry_type().is_dir() {
488            fs::create_dir_all(&dest).map_err(|e| SymbolsError::Io {
489                path: dest.clone(),
490                source: e,
491            })?;
492            continue;
493        }
494        if let Some(parent) = dest.parent() {
495            fs::create_dir_all(parent).map_err(|e| SymbolsError::Io {
496                path: parent.to_path_buf(),
497                source: e,
498            })?;
499        }
500        entry.unpack(&dest).map_err(|e| SymbolsError::Io {
501            path: dest.clone(),
502            source: e,
503        })?;
504        // Record the bundle root once, not every file inside it.
505        if inner.components().count() == 1 {
506            installed.push(dest);
507        }
508    }
509    Ok(installed)
510}
511
512fn is_debug_sidecar(leaf: &Path) -> bool {
513    matches!(
514        leaf.extension().and_then(|s| s.to_str()),
515        Some("pdb" | "dwp" | "dSYM")
516    )
517}
518
519/// Called from `main()` when the auto-install env var is set. Best-effort:
520/// any failure is reported to stderr so a transient network blip on
521/// startup doesn't break the user's actual command. The fast-path (already
522/// installed) is silent so the env var can stay set permanently without
523/// adding noise to every invocation.
524///
525/// Concurrency model for compile-loop wrappers: this uses
526/// `LockBehavior::SkipIfBusy` so when many zccache invocations start in
527/// parallel only the first one downloads; the rest see the lock and return
528/// immediately. Once the winner finishes, the next invocation takes the
529/// silent fast path.
530pub fn maybe_auto_install() {
531    if env::var_os(AUTO_INSTALL_ENV).is_none_or(|v| v.is_empty()) {
532        return;
533    }
534    // Fast-path sidecar-presence check before reporting any activity, so a
535    // permanently-set env var stays quiet once symbols are installed.
536    let kind = ArchiveKind::for_target(BUILD_TARGET);
537    if let Ok(prefix) = resolved_prefix(None) {
538        if all_sidecars_present(&prefix, kind) {
539            return;
540        }
541    }
542    let opts = InstallOptions {
543        lock_behavior: LockBehavior::SkipIfBusy,
544        ..InstallOptions::default()
545    };
546    match install(opts) {
547        Ok(report) if report.skipped_lock_busy => {
548            // Another zccache is already installing — don't block this one's
549            // actual command. The next invocation will see the result.
550            eprintln!(
551                "zccache: another process is installing debug sidecars in {}, skipping",
552                report.prefix.display()
553            );
554        }
555        Ok(report) if report.skipped_already_present => {
556            // Race: another process completed between fast-path check and
557            // post-lock re-check. Nothing more to do, stay quiet.
558        }
559        Ok(report) => {
560            eprintln!(
561                "zccache: installed {} debug sidecar(s) into {}",
562                report.installed.len(),
563                report.prefix.display()
564            );
565        }
566        Err(err) => {
567            eprintln!("zccache: debug symbol auto-install failed: {err}");
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn archive_kind_for_target() {
578        assert!(matches!(
579            ArchiveKind::for_target("x86_64-pc-windows-msvc"),
580            ArchiveKind::WindowsPdb
581        ));
582        assert!(matches!(
583            ArchiveKind::for_target("aarch64-pc-windows-msvc"),
584            ArchiveKind::WindowsPdb
585        ));
586        assert!(matches!(
587            ArchiveKind::for_target("x86_64-apple-darwin"),
588            ArchiveKind::MacOsDsym
589        ));
590        assert!(matches!(
591            ArchiveKind::for_target("aarch64-unknown-linux-musl"),
592            ArchiveKind::LinuxDwp
593        ));
594        assert!(matches!(
595            ArchiveKind::for_target("x86_64-unknown-linux-gnu"),
596            ArchiveKind::LinuxDwp
597        ));
598    }
599
600    #[test]
601    fn build_url_windows_uses_zip_and_v_prefix() {
602        let url = build_url("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
603        assert_eq!(
604            url,
605            "https://github.com/zackees/zccache/releases/download/1.6.0/zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip"
606        );
607    }
608
609    #[test]
610    fn build_url_linux_uses_tar_gz() {
611        let url = build_url("1.6.0", "x86_64-unknown-linux-musl", ArchiveKind::LinuxDwp);
612        assert!(url.ends_with(".tar.gz"));
613        assert!(url.contains("zccache-v1.6.0-x86_64-unknown-linux-musl-debug"));
614    }
615
616    #[test]
617    fn expected_sidecars_use_underscored_pdb_names_on_windows() {
618        // Regression guard for zccache#276: rustc's MSVC PDB filename uses
619        // the underscored crate name, not the [[bin]] name.
620        let names = ArchiveKind::WindowsPdb.expected_sidecars();
621        assert!(names.contains(&"zccache.pdb"));
622        assert!(names.contains(&"zccache_daemon.pdb"));
623        assert!(names.contains(&"zccache_fp.pdb"));
624    }
625
626    #[test]
627    fn is_debug_sidecar_recognizes_extensions() {
628        assert!(is_debug_sidecar(Path::new("zccache.pdb")));
629        assert!(is_debug_sidecar(Path::new("zccache-daemon.dwp")));
630        assert!(is_debug_sidecar(Path::new("zccache-fp.dSYM")));
631        assert!(!is_debug_sidecar(Path::new("zccache.exe")));
632        assert!(!is_debug_sidecar(Path::new("README.md")));
633    }
634
635    #[test]
636    fn skips_install_when_sidecars_already_present() {
637        let dir = tempfile::tempdir().expect("tempdir");
638        for name in ArchiveKind::WindowsPdb.expected_sidecars() {
639            fs::write(dir.path().join(name), b"stub").unwrap();
640        }
641        assert!(all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
642    }
643
644    #[test]
645    fn detects_missing_sidecar() {
646        let dir = tempfile::tempdir().expect("tempdir");
647        fs::write(dir.path().join("zccache.pdb"), b"stub").unwrap();
648        // missing daemon + fp
649        assert!(!all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
650    }
651
652    /// A second `try_lock_exclusive` while the first holder is alive must
653    /// fail — the regression we'd be guarding against is a stale-flag
654    /// implementation that lets two installers run concurrently.
655    #[test]
656    fn lockfile_blocks_second_try_lock() {
657        let dir = tempfile::tempdir().expect("tempdir");
658        let lock = dir.path().join(LOCK_FILENAME);
659        let first = open_lockfile(&lock).expect("open lock 1");
660        assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
661
662        let second = open_lockfile(&lock).expect("open lock 2");
663        assert!(
664            !acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
665            "second process should have been told the lock is busy"
666        );
667    }
668
669    /// When the holder drops the file handle (or the process dies — same
670    /// kernel-level behavior), the lock must become available again
671    /// without any cleanup step.
672    #[test]
673    fn lockfile_released_on_handle_drop() {
674        let dir = tempfile::tempdir().expect("tempdir");
675        let lock = dir.path().join(LOCK_FILENAME);
676
677        {
678            let first = open_lockfile(&lock).expect("open lock 1");
679            assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
680            // handle drops here — kernel releases the advisory lock.
681        }
682
683        let second = open_lockfile(&lock).expect("open lock 2");
684        assert!(
685            acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
686            "lock should be free after first holder drops the handle"
687        );
688    }
689
690    /// Atomic-write helper must materialize the destination file only after
691    /// the source is fully copied. We assert via post-state, not timing —
692    /// just confirm the destination has the right contents.
693    #[test]
694    fn write_atomically_persists_full_contents() {
695        let dir = tempfile::tempdir().expect("tempdir");
696        let dest = dir.path().join("zccache.pdb");
697        let mut src: &[u8] = b"PDB-payload";
698        write_atomically(&dest, &mut src).unwrap();
699        assert_eq!(fs::read(&dest).unwrap(), b"PDB-payload");
700    }
701
702    /// The non-blocking path used by auto-install must not return
703    /// `Ok(true)` when the lock is contended. Couples to
704    /// `is_would_block` correctly mapping the platform error code.
705    #[test]
706    fn skip_if_busy_classifies_contended_lock_as_skip() {
707        let dir = tempfile::tempdir().expect("tempdir");
708        let lock = dir.path().join(LOCK_FILENAME);
709        let holder = open_lockfile(&lock).unwrap();
710        assert!(acquire_exclusive(&holder, LockBehavior::SkipIfBusy).unwrap());
711
712        let challenger = open_lockfile(&lock).unwrap();
713        let got = acquire_exclusive(&challenger, LockBehavior::SkipIfBusy).unwrap();
714        assert!(!got, "challenger must see SkipIfBusy -> Ok(false)");
715    }
716
717    /// The archive cache lives under the configured `default_cache_dir`
718    /// (overridable via `ZCCACHE_CACHE_DIR`), never `$TMPDIR`. This is the
719    /// invariant that the `ban_unrooted_tempdir` dylint enforces from the
720    /// other direction.
721    #[test]
722    fn archive_cache_path_is_under_zccache_cache_dir() {
723        let path = archive_cache_path("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
724        let expected_leaf = "zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip";
725        assert_eq!(
726            path.file_name().and_then(|s| s.to_str()),
727            Some(expected_leaf)
728        );
729
730        let parent = path
731            .parent()
732            .and_then(|p| p.file_name())
733            .and_then(|s| s.to_str());
734        assert_eq!(parent, Some("symbols"));
735
736        let expected_root = zccache_core::config::default_cache_dir();
737        assert!(
738            path.starts_with(expected_root.as_path()),
739            "cache path {} should be under default_cache_dir {}",
740            path.display(),
741            expected_root.as_path().display(),
742        );
743    }
744}