use std::env;
use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: &str = env!("ZCCACHE_BUILD_TARGET");
const RELEASE_BASE_URL: &str = "https://github.com/zackees/zccache/releases/download";
const LOCK_FILENAME: &str = ".zccache-symbols.lock";
pub const AUTO_INSTALL_ENV: &str = "ZCCACHE_AUTO_INSTALL_SYMBOLS";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LockBehavior {
#[default]
Wait,
SkipIfBusy,
}
#[derive(Debug, Clone, Default)]
pub struct InstallOptions {
pub version: Option<String>,
pub target: Option<String>,
pub prefix: Option<PathBuf>,
pub force: bool,
pub lock_behavior: LockBehavior,
}
#[derive(Debug)]
pub struct InstallReport {
pub prefix: PathBuf,
pub installed: Vec<PathBuf>,
pub skipped_already_present: bool,
pub skipped_lock_busy: bool,
pub url: String,
pub cache_hit: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum SymbolsError {
#[error("unable to locate the running zccache binary: {0}")]
LocateExe(#[source] io::Error),
#[error("network error fetching {url}: {source}")]
Fetch {
url: String,
#[source]
source: reqwest::Error,
},
#[error("release asset returned HTTP {status} for {url}")]
HttpStatus { url: String, status: u16 },
#[error("io error writing {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("archive contained no debug sidecars (expected .pdb/.dwp/.dSYM entries)")]
EmptyArchive,
#[error("tokio runtime error: {0}")]
Runtime(#[source] io::Error),
}
#[derive(Debug, Clone, Copy)]
enum ArchiveKind {
WindowsPdb,
MacOsDsym,
LinuxDwp,
}
impl ArchiveKind {
fn for_target(target: &str) -> Self {
if target.contains("pc-windows") {
Self::WindowsPdb
} else if target.contains("apple-darwin") || target.contains("apple-ios") {
Self::MacOsDsym
} else {
Self::LinuxDwp
}
}
fn file_extension(self) -> &'static str {
match self {
Self::WindowsPdb => "zip",
Self::MacOsDsym | Self::LinuxDwp => "tar.gz",
}
}
fn expected_sidecars(self) -> &'static [&'static str] {
match self {
Self::WindowsPdb => &["zccache.pdb", "zccache_daemon.pdb", "zccache_fp.pdb"],
Self::MacOsDsym => &["zccache.dSYM", "zccache-daemon.dSYM", "zccache-fp.dSYM"],
Self::LinuxDwp => &["zccache.dwp", "zccache-daemon.dwp", "zccache-fp.dwp"],
}
}
}
fn resolved_prefix(opts_prefix: Option<&Path>) -> Result<PathBuf, SymbolsError> {
if let Some(p) = opts_prefix {
return Ok(p.to_path_buf());
}
let exe = env::current_exe().map_err(SymbolsError::LocateExe)?;
Ok(exe
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(".")))
}
fn build_url(version: &str, target: &str, kind: ArchiveKind) -> String {
let tag = version;
let ext = kind.file_extension();
format!(
"{base}/{tag}/zccache-v{version}-{target}-debug.{ext}",
base = RELEASE_BASE_URL,
)
}
fn all_sidecars_present(prefix: &Path, kind: ArchiveKind) -> bool {
kind.expected_sidecars()
.iter()
.all(|name| prefix.join(name).exists())
}
pub fn install(opts: InstallOptions) -> Result<InstallReport, SymbolsError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(SymbolsError::Runtime)?;
runtime.block_on(install_async(opts))
}
pub async fn install_async(opts: InstallOptions) -> Result<InstallReport, SymbolsError> {
let version = opts
.version
.clone()
.unwrap_or_else(|| PKG_VERSION.to_string());
let target = opts
.target
.clone()
.unwrap_or_else(|| BUILD_TARGET.to_string());
let prefix = resolved_prefix(opts.prefix.as_deref())?;
let kind = ArchiveKind::for_target(&target);
let url = build_url(&version, &target, kind);
if !opts.force && all_sidecars_present(&prefix, kind) {
return Ok(InstallReport {
prefix,
installed: Vec::new(),
skipped_already_present: true,
skipped_lock_busy: false,
url,
cache_hit: false,
});
}
fs::create_dir_all(&prefix).map_err(|e| SymbolsError::Io {
path: prefix.clone(),
source: e,
})?;
let lockfile_path = prefix.join(LOCK_FILENAME);
let lockfile = open_lockfile(&lockfile_path)?;
if !acquire_exclusive(&lockfile, opts.lock_behavior)? {
return Ok(InstallReport {
prefix,
installed: Vec::new(),
skipped_already_present: false,
skipped_lock_busy: true,
url,
cache_hit: false,
});
}
if !opts.force && all_sidecars_present(&prefix, kind) {
return Ok(InstallReport {
prefix,
installed: Vec::new(),
skipped_already_present: true,
skipped_lock_busy: false,
url,
cache_hit: false,
});
}
let (bytes, cache_hit) = fetch_archive(&url, &version, &target, kind, opts.force).await?;
let installed = match kind {
ArchiveKind::WindowsPdb => extract_zip(&bytes, &prefix)?,
ArchiveKind::MacOsDsym | ArchiveKind::LinuxDwp => extract_targz(&bytes, &prefix)?,
};
if installed.is_empty() {
return Err(SymbolsError::EmptyArchive);
}
drop(lockfile);
Ok(InstallReport {
prefix,
installed,
skipped_already_present: false,
skipped_lock_busy: false,
url,
cache_hit,
})
}
async fn fetch_archive(
url: &str,
version: &str,
target: &str,
kind: ArchiveKind,
force: bool,
) -> Result<(Vec<u8>, bool), SymbolsError> {
let cache_path = archive_cache_path(version, target, kind);
if !force {
if let Ok(bytes) = fs::read(&cache_path) {
if !bytes.is_empty() {
return Ok((bytes, true));
}
}
}
let client = reqwest::Client::builder()
.user_agent(concat!("zccache/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| SymbolsError::Fetch {
url: url.to_string(),
source: e,
})?;
let response = client
.get(url)
.send()
.await
.map_err(|e| SymbolsError::Fetch {
url: url.to_string(),
source: e,
})?;
if !response.status().is_success() {
return Err(SymbolsError::HttpStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
let bytes = response.bytes().await.map_err(|e| SymbolsError::Fetch {
url: url.to_string(),
source: e,
})?;
if let Some(parent) = cache_path.parent() {
if fs::create_dir_all(parent).is_ok() {
let _ = write_atomically(&cache_path, &mut io::Cursor::new(bytes.as_ref()));
}
}
Ok((bytes.to_vec(), false))
}
fn archive_cache_path(version: &str, target: &str, kind: ArchiveKind) -> PathBuf {
let filename = format!(
"zccache-v{version}-{target}-debug.{ext}",
ext = kind.file_extension(),
);
zccache_core::config::symbols_cache_dir()
.into_path_buf()
.join(filename)
}
fn open_lockfile(path: &Path) -> Result<File, SymbolsError> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| SymbolsError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn acquire_exclusive(file: &File, behavior: LockBehavior) -> Result<bool, SymbolsError> {
match behavior {
LockBehavior::SkipIfBusy => match fs2::FileExt::try_lock_exclusive(file) {
Ok(()) => Ok(true),
Err(err) if is_would_block(&err) => Ok(false),
Err(err) => Err(SymbolsError::Io {
path: PathBuf::from(LOCK_FILENAME),
source: err,
}),
},
LockBehavior::Wait => fs2::FileExt::lock_exclusive(file)
.map(|()| true)
.map_err(|err| SymbolsError::Io {
path: PathBuf::from(LOCK_FILENAME),
source: err,
}),
}
}
fn is_would_block(err: &io::Error) -> bool {
if matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
) {
return true;
}
#[cfg(windows)]
{
if matches!(err.raw_os_error(), Some(33)) {
return true;
}
}
false
}
fn extract_zip(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
let cursor = io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
let mut installed = Vec::new();
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
if entry.is_dir() {
continue;
}
let raw_name = match entry.enclosed_name() {
Some(p) => p.to_path_buf(),
None => continue,
};
let leaf = match raw_name.file_name() {
Some(n) => Path::new(n).to_path_buf(),
None => continue,
};
if !is_debug_sidecar(&leaf) {
continue;
}
let dest = prefix.join(&leaf);
write_atomically(&dest, &mut entry)?;
installed.push(dest);
}
Ok(installed)
}
fn write_atomically(dest: &Path, src: &mut dyn io::Read) -> Result<(), SymbolsError> {
let parent = dest.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| SymbolsError::Io {
path: parent.to_path_buf(),
source: e,
})?;
io::copy(src, tmp.as_file_mut()).map_err(|e| SymbolsError::Io {
path: tmp.path().to_path_buf(),
source: e,
})?;
tmp.persist(dest).map_err(|e| SymbolsError::Io {
path: dest.to_path_buf(),
source: e.error,
})?;
Ok(())
}
fn extract_targz(bytes: &[u8], prefix: &Path) -> Result<Vec<PathBuf>, SymbolsError> {
let cursor = io::Cursor::new(bytes);
let decoder = flate2::read::GzDecoder::new(cursor);
let mut archive = tar::Archive::new(decoder);
let mut installed = Vec::new();
for entry in archive.entries().map_err(|e| SymbolsError::Io {
path: prefix.to_path_buf(),
source: e,
})? {
let mut entry = entry.map_err(|e| SymbolsError::Io {
path: prefix.to_path_buf(),
source: e,
})?;
let raw_path = match entry.path() {
Ok(p) => p.into_owned(),
Err(_) => continue,
};
let components: Vec<_> = raw_path.components().collect();
if components.len() < 2 {
continue;
}
let inner: PathBuf = components[1..]
.iter()
.map(|c| c.as_os_str())
.collect::<PathBuf>();
let first_inner = match inner.components().next() {
Some(c) => Path::new(c.as_os_str()).to_path_buf(),
None => continue,
};
if !is_debug_sidecar(&first_inner) {
continue;
}
let dest = prefix.join(&inner);
if entry.header().entry_type().is_dir() {
fs::create_dir_all(&dest).map_err(|e| SymbolsError::Io {
path: dest.clone(),
source: e,
})?;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| SymbolsError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
entry.unpack(&dest).map_err(|e| SymbolsError::Io {
path: dest.clone(),
source: e,
})?;
if inner.components().count() == 1 {
installed.push(dest);
}
}
Ok(installed)
}
fn is_debug_sidecar(leaf: &Path) -> bool {
matches!(
leaf.extension().and_then(|s| s.to_str()),
Some("pdb" | "dwp" | "dSYM")
)
}
pub fn maybe_auto_install() {
if env::var_os(AUTO_INSTALL_ENV).is_none_or(|v| v.is_empty()) {
return;
}
let kind = ArchiveKind::for_target(BUILD_TARGET);
if let Ok(prefix) = resolved_prefix(None) {
if all_sidecars_present(&prefix, kind) {
return;
}
}
let opts = InstallOptions {
lock_behavior: LockBehavior::SkipIfBusy,
..InstallOptions::default()
};
match install(opts) {
Ok(report) if report.skipped_lock_busy => {
eprintln!(
"zccache: another process is installing debug sidecars in {}, skipping",
report.prefix.display()
);
}
Ok(report) if report.skipped_already_present => {
}
Ok(report) => {
eprintln!(
"zccache: installed {} debug sidecar(s) into {}",
report.installed.len(),
report.prefix.display()
);
}
Err(err) => {
eprintln!("zccache: debug symbol auto-install failed: {err}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_kind_for_target() {
assert!(matches!(
ArchiveKind::for_target("x86_64-pc-windows-msvc"),
ArchiveKind::WindowsPdb
));
assert!(matches!(
ArchiveKind::for_target("aarch64-pc-windows-msvc"),
ArchiveKind::WindowsPdb
));
assert!(matches!(
ArchiveKind::for_target("x86_64-apple-darwin"),
ArchiveKind::MacOsDsym
));
assert!(matches!(
ArchiveKind::for_target("aarch64-unknown-linux-musl"),
ArchiveKind::LinuxDwp
));
assert!(matches!(
ArchiveKind::for_target("x86_64-unknown-linux-gnu"),
ArchiveKind::LinuxDwp
));
}
#[test]
fn build_url_windows_uses_zip_and_v_prefix() {
let url = build_url("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
assert_eq!(
url,
"https://github.com/zackees/zccache/releases/download/1.6.0/zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip"
);
}
#[test]
fn build_url_linux_uses_tar_gz() {
let url = build_url("1.6.0", "x86_64-unknown-linux-musl", ArchiveKind::LinuxDwp);
assert!(url.ends_with(".tar.gz"));
assert!(url.contains("zccache-v1.6.0-x86_64-unknown-linux-musl-debug"));
}
#[test]
fn expected_sidecars_use_underscored_pdb_names_on_windows() {
let names = ArchiveKind::WindowsPdb.expected_sidecars();
assert!(names.contains(&"zccache.pdb"));
assert!(names.contains(&"zccache_daemon.pdb"));
assert!(names.contains(&"zccache_fp.pdb"));
}
#[test]
fn is_debug_sidecar_recognizes_extensions() {
assert!(is_debug_sidecar(Path::new("zccache.pdb")));
assert!(is_debug_sidecar(Path::new("zccache-daemon.dwp")));
assert!(is_debug_sidecar(Path::new("zccache-fp.dSYM")));
assert!(!is_debug_sidecar(Path::new("zccache.exe")));
assert!(!is_debug_sidecar(Path::new("README.md")));
}
#[test]
fn skips_install_when_sidecars_already_present() {
let dir = tempfile::tempdir().expect("tempdir");
for name in ArchiveKind::WindowsPdb.expected_sidecars() {
fs::write(dir.path().join(name), b"stub").unwrap();
}
assert!(all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
}
#[test]
fn detects_missing_sidecar() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("zccache.pdb"), b"stub").unwrap();
assert!(!all_sidecars_present(dir.path(), ArchiveKind::WindowsPdb));
}
#[test]
fn lockfile_blocks_second_try_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let lock = dir.path().join(LOCK_FILENAME);
let first = open_lockfile(&lock).expect("open lock 1");
assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
let second = open_lockfile(&lock).expect("open lock 2");
assert!(
!acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
"second process should have been told the lock is busy"
);
}
#[test]
fn lockfile_released_on_handle_drop() {
let dir = tempfile::tempdir().expect("tempdir");
let lock = dir.path().join(LOCK_FILENAME);
{
let first = open_lockfile(&lock).expect("open lock 1");
assert!(acquire_exclusive(&first, LockBehavior::SkipIfBusy).unwrap());
}
let second = open_lockfile(&lock).expect("open lock 2");
assert!(
acquire_exclusive(&second, LockBehavior::SkipIfBusy).unwrap(),
"lock should be free after first holder drops the handle"
);
}
#[test]
fn write_atomically_persists_full_contents() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("zccache.pdb");
let mut src: &[u8] = b"PDB-payload";
write_atomically(&dest, &mut src).unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"PDB-payload");
}
#[test]
fn skip_if_busy_classifies_contended_lock_as_skip() {
let dir = tempfile::tempdir().expect("tempdir");
let lock = dir.path().join(LOCK_FILENAME);
let holder = open_lockfile(&lock).unwrap();
assert!(acquire_exclusive(&holder, LockBehavior::SkipIfBusy).unwrap());
let challenger = open_lockfile(&lock).unwrap();
let got = acquire_exclusive(&challenger, LockBehavior::SkipIfBusy).unwrap();
assert!(!got, "challenger must see SkipIfBusy -> Ok(false)");
}
#[test]
fn archive_cache_path_is_under_zccache_cache_dir() {
let path = archive_cache_path("1.6.0", "x86_64-pc-windows-msvc", ArchiveKind::WindowsPdb);
let expected_leaf = "zccache-v1.6.0-x86_64-pc-windows-msvc-debug.zip";
assert_eq!(
path.file_name().and_then(|s| s.to_str()),
Some(expected_leaf)
);
let parent = path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str());
assert_eq!(parent, Some("symbols"));
let expected_root = zccache_core::config::default_cache_dir();
assert!(
path.starts_with(expected_root.as_path()),
"cache path {} should be under default_cache_dir {}",
path.display(),
expected_root.as_path().display(),
);
}
}