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 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
353fn acquire_exclusive(file: &File, behavior: LockBehavior) -> Result<bool, SymbolsError> {
357 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 #[cfg(windows)]
388 {
389 if matches!(err.raw_os_error(), Some(33)) {
390 return true;
391 }
392 }
393 false
394}
395
396fn 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 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
430fn 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
450fn 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 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 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 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
521pub fn maybe_auto_install() {
533 if env::var_os(AUTO_INSTALL_ENV).is_none_or(|v| v.is_empty()) {
534 return;
535 }
536 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 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 }
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 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 assert!(!all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
652 }
653
654 #[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 #[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 }
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 #[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 #[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 #[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}