1use 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
27pub const AUTO_INSTALL_ENV: &str = "ZCCACHE_AUTO_INSTALL_SYMBOLS";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LockBehavior {
34 #[default]
37 Wait,
38 SkipIfBusy,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct InstallOptions {
46 pub version: Option<String>,
48 pub target: Option<String>,
50 pub prefix: Option<PathBuf>,
53 pub force: bool,
55 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 pub skipped_lock_busy: bool,
67 pub url: String,
68 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 WindowsPdb,
103 MacOsDsym,
105 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 fn expected_sidecars(self) -> &'static [&'static str] {
131 match self {
132 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 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
169pub 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 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 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 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 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
267async 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 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
351fn acquire_exclusive(file: &File, behavior: LockBehavior) -> Result<bool, SymbolsError> {
355 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 #[cfg(windows)]
386 {
387 if matches!(err.raw_os_error(), Some(33)) {
388 return true;
389 }
390 }
391 false
392}
393
394fn 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 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
428fn 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
448fn 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 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 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 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
519pub fn maybe_auto_install() {
531 if env::var_os(AUTO_INSTALL_ENV).is_none_or(|v| v.is_empty()) {
532 return;
533 }
534 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 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 }
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 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 assert!(!all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
650 }
651
652 #[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 #[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 }
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 #[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 #[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 #[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}