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/// plus 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    zccache_core::config::symbols_cache_dir()
336        .into_path_buf()
337        .join(filename)
338}
339
340fn open_lockfile(path: &Path) -> Result<File, SymbolsError> {
341    OpenOptions::new()
342        .read(true)
343        .write(true)
344        .create(true)
345        .truncate(false)
346        .open(path)
347        .map_err(|e| SymbolsError::Io {
348            path: path.to_path_buf(),
349            source: e,
350        })
351}
352
353/// Acquire the install lock. Returns `Ok(true)` on success, `Ok(false)` only
354/// when `SkipIfBusy` and another holder is present. Other errors propagate
355/// as `SymbolsError::Io` so a permission problem surfaces clearly.
356fn acquire_exclusive(file: &File, behavior: LockBehavior) -> Result<bool, SymbolsError> {
357    // fs2 trait methods are called via UFCS to avoid the ambiguity with
358    // `std::fs::File::try_lock_exclusive` that landed in Rust 1.89.
359    match behavior {
360        LockBehavior::SkipIfBusy => match fs2::FileExt::try_lock_exclusive(file) {
361            Ok(()) => Ok(true),
362            Err(err) if is_would_block(&err) => Ok(false),
363            Err(err) => Err(SymbolsError::Io {
364                path: PathBuf::from(LOCK_FILENAME),
365                source: err,
366            }),
367        },
368        LockBehavior::Wait => fs2::FileExt::lock_exclusive(file)
369            .map(|()| true)
370            .map_err(|err| SymbolsError::Io {
371                path: PathBuf::from(LOCK_FILENAME),
372                source: err,
373            }),
374    }
375}
376
377fn is_would_block(err: &io::Error) -> bool {
378    if matches!(
379        err.kind(),
380        io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
381    ) {
382        return true;
383    }
384    // Windows: `LockFileEx` with `LOCKFILE_FAIL_IMMEDIATELY` returns
385    // `ERROR_LOCK_VIOLATION (33)`, which std currently surfaces as
386    // `ErrorKind::Uncategorized`. Treat it as "would block".
387    #[cfg(windows)]
388    {
389        if matches!(err.raw_os_error(), Some(33)) {
390            return true;
391        }
392    }
393    false
394}
395
396/// Extract `.pdb` files from a zip into `prefix`. Strips the archive's
397/// top-level directory (`zccache-vX.Y.Z-<target>-debug/`) so files land
398/// directly next to the binaries. Each file is written via tempfile + rename
399/// so an interrupted install can't leave a partial PDB that subsequent
400/// `all_sidecars_present` checks would treat as complete.
401fn extract_zip(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
402    let cursor = io::Cursor::new(bytes);
403    let mut archive = zip::ZipArchive::new(cursor)?;
404    let mut installed = Vec::new();
405    for i in 0..archive.len() {
406        let mut entry = archive.by_index(i)?;
407        if entry.is_dir() {
408            continue;
409        }
410        let raw_name = match entry.enclosed_name() {
411            Some(p) => p.to_path_buf(),
412            None => continue,
413        };
414        // Keep only the trailing path component for PDB sidecars; debuggers
415        // search by basename in the binary's directory.
416        let leaf = match raw_name.file_name() {
417            Some(n) => Path::new(n).to_path_buf(),
418            None => continue,
419        };
420        if !is_debug_sidecar(&leaf) {
421            continue;
422        }
423        let dest = prefix.join(&leaf);
424        write_atomically(&dest, &mut entry)?;
425        installed.push(dest);
426    }
427    Ok(installed)
428}
429
430/// Write a single file via a same-directory tempfile + rename. On Windows
431/// `NamedTempFile::persist` uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`
432/// which is atomic for any concurrent reader.
433fn write_atomically(dest: &Path, src: &mut dyn io::Read) -> Result<(), SymbolsError> {
434    let parent = dest.parent().unwrap_or(Path::new("."));
435    let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| SymbolsError::Io {
436        path: parent.to_path_buf(),
437        source: e,
438    })?;
439    io::copy(src, tmp.as_file_mut()).map_err(|e| SymbolsError::Io {
440        path: tmp.path().to_path_buf(),
441        source: e,
442    })?;
443    tmp.persist(dest).map_err(|e| SymbolsError::Io {
444        path: dest.to_path_buf(),
445        source: e.error,
446    })?;
447    Ok(())
448}
449
450/// Extract `.dwp` files or `.dSYM` bundles from a gzip-compressed tarball.
451fn extract_targz(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
452    let cursor = io::Cursor::new(bytes);
453    let decoder = flate2::read::GzDecoder::new(cursor);
454    let mut archive = tar::Archive::new(decoder);
455    let mut installed = Vec::new();
456    for entry in archive.entries().map_err(|e| SymbolsError::Io {
457        path: prefix.to_path_buf(),
458        source: e,
459    })? {
460        let mut entry = entry.map_err(|e| SymbolsError::Io {
461            path: prefix.to_path_buf(),
462            source: e,
463        })?;
464        let raw_path = match entry.path() {
465            Ok(p) => p.into_owned(),
466            Err(_) => continue,
467        };
468        let components: Vec<_> = raw_path.components().collect();
469        // The archive layout is `<root>/<sidecar...>`. Strip the top-level
470        // wrapper directory so contents land directly under `prefix`.
471        if components.len() < 2 {
472            continue;
473        }
474        let inner: PathBuf = components[1..]
475            .iter()
476            .map(|c| c.as_os_str())
477            .collect::<PathBuf>();
478        // Filter: top-level sidecar entry must look like a debug file or
479        // dSYM bundle root. Children of dSYM bundles are copied verbatim
480        // once the bundle root is allowed through.
481        let first_inner = match inner.components().next() {
482            Some(c) => Path::new(c.as_os_str()).to_path_buf(),
483            None => continue,
484        };
485        if !is_debug_sidecar(&first_inner) {
486            continue;
487        }
488        let dest = prefix.join(&inner);
489        if entry.header().entry_type().is_dir() {
490            fs::create_dir_all(&dest).map_err(|e| SymbolsError::Io {
491                path: dest.clone(),
492                source: e,
493            })?;
494            continue;
495        }
496        if let Some(parent) = dest.parent() {
497            fs::create_dir_all(parent).map_err(|e| SymbolsError::Io {
498                path: parent.to_path_buf(),
499                source: e,
500            })?;
501        }
502        entry.unpack(&dest).map_err(|e| SymbolsError::Io {
503            path: dest.clone(),
504            source: e,
505        })?;
506        // Record the bundle root once, not every file inside it.
507        if inner.components().count() == 1 {
508            installed.push(dest);
509        }
510    }
511    Ok(installed)
512}
513
514fn is_debug_sidecar(leaf: &Path) -> bool {
515    matches!(
516        leaf.extension().and_then(|s| s.to_str()),
517        Some("pdb" | "dwp" | "dSYM")
518    )
519}
520
521/// Called from `main()` when the auto-install env var is set. Best-effort:
522/// any failure is reported to stderr so a transient network blip on
523/// startup doesn't break the user's actual command. The fast-path (already
524/// installed) is silent so the env var can stay set permanently without
525/// adding noise to every invocation.
526///
527/// Concurrency model for compile-loop wrappers: this uses
528/// `LockBehavior::SkipIfBusy` so when many zccache invocations start in
529/// parallel only the first one downloads; the rest see the lock and return
530/// immediately. Once the winner finishes, the next invocation takes the
531/// silent fast path.
532pub fn maybe_auto_install() {
533    if env::var_os(AUTO_INSTALL_ENV).is_none_or(|v| v.is_empty()) {
534        return;
535    }
536    // Fast-path sidecar-presence check before reporting any activity, so a
537    // permanently-set env var stays quiet once symbols are installed.
538    let kind = ArchiveKind::for_target(BUILD_TARGET);
539    if let Ok(prefix) = resolved_prefix(None) {
540        if all_sidecars_present(&prefix, kind) {
541            return;
542        }
543    }
544    let opts = InstallOptions {
545        lock_behavior: LockBehavior::SkipIfBusy,
546        ..InstallOptions::default()
547    };
548    match install(opts) {
549        Ok(report) if report.skipped_lock_busy => {
550            // Another zccache is already installing — don't block this one's
551            // actual command. The next invocation will see the result.
552            eprintln!(
553                "zccache: another process is installing debug sidecars in {}, skipping",
554                report.prefix.display()
555            );
556        }
557        Ok(report) if report.skipped_already_present => {
558            // Race: another process completed between fast-path check and
559            // post-lock re-check. Nothing more to do, stay quiet.
560        }
561        Ok(report) => {
562            eprintln!(
563                "zccache: installed {} debug sidecar(s) into {}",
564                report.installed.len(),
565                report.prefix.display()
566            );
567        }
568        Err(err) => {
569            eprintln!("zccache: debug symbol auto-install failed: {err}");
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn archive_kind_for_target() {
580        assert!(matches!(
581            ArchiveKind::for_target("x86_64-pc-windows-msvc"),
582            ArchiveKind::WindowsPdb
583        ));
584        assert!(matches!(
585            ArchiveKind::for_target("aarch64-pc-windows-msvc"),
586            ArchiveKind::WindowsPdb
587        ));
588        assert!(matches!(
589            ArchiveKind::for_target("x86_64-apple-darwin"),
590            ArchiveKind::MacOsDsym
591        ));
592        assert!(matches!(
593            ArchiveKind::for_target("aarch64-unknown-linux-musl"),
594            ArchiveKind::LinuxDwp
595        ));
596        assert!(matches!(
597            ArchiveKind::for_target("x86_64-unknown-linux-gnu"),
598            ArchiveKind::LinuxDwp
599        ));
600    }
601
602    #[test]
603    fn build_url_windows_uses_zip_and_v_prefix() {
604        let url = build_url("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
605        assert_eq!(
606            url,
607            "https://github.com/zackees/zccache/releases/download/1.6.0/zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip"
608        );
609    }
610
611    #[test]
612    fn build_url_linux_uses_tar_gz() {
613        let url = build_url("1.6.0", "x86_64-unknown-linux-musl", ArchiveKind::LinuxDwp);
614        assert!(url.ends_with(".tar.gz"));
615        assert!(url.contains("zccache-v1.6.0-x86_64-unknown-linux-musl-debug"));
616    }
617
618    #[test]
619    fn expected_sidecars_use_underscored_pdb_names_on_windows() {
620        // Regression guard for zccache#276: rustc's MSVC PDB filename uses
621        // the underscored crate name, not the [[bin]] name.
622        let names = ArchiveKind::WindowsPdb.expected_sidecars();
623        assert!(names.contains(&"zccache.pdb"));
624        assert!(names.contains(&"zccache_daemon.pdb"));
625        assert!(names.contains(&"zccache_fp.pdb"));
626    }
627
628    #[test]
629    fn is_debug_sidecar_recognizes_extensions() {
630        assert!(is_debug_sidecar(Path::new("zccache.pdb")));
631        assert!(is_debug_sidecar(Path::new("zccache-daemon.dwp")));
632        assert!(is_debug_sidecar(Path::new("zccache-fp.dSYM")));
633        assert!(!is_debug_sidecar(Path::new("zccache.exe")));
634        assert!(!is_debug_sidecar(Path::new("README.md")));
635    }
636
637    #[test]
638    fn skips_install_when_sidecars_already_present() {
639        let dir = tempfile::tempdir().expect("tempdir");
640        for name in ArchiveKind::WindowsPdb.expected_sidecars() {
641            fs::write(dir.path().join(name), b"stub").unwrap();
642        }
643        assert!(all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
644    }
645
646    #[test]
647    fn detects_missing_sidecar() {
648        let dir = tempfile::tempdir().expect("tempdir");
649        fs::write(dir.path().join("zccache.pdb"), b"stub").unwrap();
650        // missing daemon + fp
651        assert!(!all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
652    }
653
654    /// A second `try_lock_exclusive` while the first holder is alive must
655    /// fail — the regression we'd be guarding against is a stale-flag
656    /// implementation that lets two installers run concurrently.
657    #[test]
658    fn lockfile_blocks_second_try_lock() {
659        let dir = tempfile::tempdir().expect("tempdir");
660        let lock = dir.path().join(LOCK_FILENAME);
661        let first = open_lockfile(&lock).expect("open lock 1");
662        assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
663
664        let second = open_lockfile(&lock).expect("open lock 2");
665        assert!(
666            !acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
667            "second process should have been told the lock is busy"
668        );
669    }
670
671    /// When the holder drops the file handle (or the process dies — same
672    /// kernel-level behavior), the lock must become available again
673    /// without any cleanup step.
674    #[test]
675    fn lockfile_released_on_handle_drop() {
676        let dir = tempfile::tempdir().expect("tempdir");
677        let lock = dir.path().join(LOCK_FILENAME);
678
679        {
680            let first = open_lockfile(&lock).expect("open lock 1");
681            assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
682            // handle drops here — kernel releases the advisory lock.
683        }
684
685        let second = open_lockfile(&lock).expect("open lock 2");
686        assert!(
687            acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
688            "lock should be free after first holder drops the handle"
689        );
690    }
691
692    /// Atomic-write helper must materialize the destination file only after
693    /// the source is fully copied. We assert via post-state, not timing —
694    /// just confirm the destination has the right contents.
695    #[test]
696    fn write_atomically_persists_full_contents() {
697        let dir = tempfile::tempdir().expect("tempdir");
698        let dest = dir.path().join("zccache.pdb");
699        let mut src: &[u8] = b"PDB-payload";
700        write_atomically(&dest, &mut src).unwrap();
701        assert_eq!(fs::read(&dest).unwrap(), b"PDB-payload");
702    }
703
704    /// The non-blocking path used by auto-install must not return
705    /// `Ok(true)` when the lock is contended. Couples to
706    /// `is_would_block` correctly mapping the platform error code.
707    #[test]
708    fn skip_if_busy_classifies_contended_lock_as_skip() {
709        let dir = tempfile::tempdir().expect("tempdir");
710        let lock = dir.path().join(LOCK_FILENAME);
711        let holder = open_lockfile(&lock).unwrap();
712        assert!(acquire_exclusive(&holder, LockBehavior::SkipIfBusy).unwrap());
713
714        let challenger = open_lockfile(&lock).unwrap();
715        let got = acquire_exclusive(&challenger, LockBehavior::SkipIfBusy).unwrap();
716        assert!(!got, "challenger must see SkipIfBusy -> Ok(false)");
717    }
718
719    /// The archive cache lives under the configured `default_cache_dir`
720    /// (overridable via `ZCCACHE_CACHE_DIR`), never `$TMPDIR`. This is the
721    /// invariant that the `ban_unrooted_tempdir` dylint enforces from the
722    /// other direction.
723    #[test]
724    fn archive_cache_path_is_under_zccache_cache_dir() {
725        let path = archive_cache_path("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
726        let expected_leaf = "zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip";
727        assert_eq!(
728            path.file_name().and_then(|s| s.to_str()),
729            Some(expected_leaf)
730        );
731
732        let parent = path
733            .parent()
734            .and_then(|p| p.file_name())
735            .and_then(|s| s.to_str());
736        assert_eq!(parent, Some("symbols"));
737
738        let expected_root = zccache_core::config::default_cache_dir();
739        assert!(
740            path.starts_with(expected_root.as_path()),
741            "cache path {} should be under default_cache_dir {}",
742            path.display(),
743            expected_root.as_path().display(),
744        );
745    }
746}