use super::{SECS_PER_DAY, USER_AGENT, urls};
use anyhow::{Context, Result, anyhow, bail, ensure};
use cargo_metadata::{Package, PackageName};
use crates_io_api::{SyncClient, Version};
use elaborate::std::{
fs::{create_dir_all_wc, read_to_string_wc, remove_dir_all_wc, remove_file_wc, write_wc},
path::PathContext,
process::CommandContext,
time::SystemTimeContext,
};
use serde::{Deserialize, Serialize};
use std::{
cell::{OnceCell, RefCell},
collections::HashMap,
fs::File,
path::{Path, PathBuf},
process::{Command, Stdio},
str::FromStr,
sync::LazyLock,
time::{Duration, SystemTime},
};
use tempfile::{TempDir, tempdir};
const DEFAULT_REFRESH_AGE: u64 = 30;
const RATE_LIMIT: Duration = Duration::from_secs(1);
#[derive(Clone, Deserialize, Serialize)]
struct Entry {
named_url: String,
cloned_url: String,
}
pub(crate) struct Cache {
tempdir: Option<TempDir>,
refresh_age: u64, entries: HashMap<PackageName, Entry>,
repository_timestamps: HashMap<String, SystemTime>,
versions_with_timestamps: HashMap<String, (Vec<Version>, SystemTime)>,
}
thread_local! {
static CACHE_ONCE_CELL: RefCell<OnceCell<Cache>> = const { RefCell::new(OnceCell::new()) };
}
#[cfg(all(feature = "on-disk-cache", not(windows)))]
#[allow(clippy::unwrap_used)]
static CACHE_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
elaborate::std::env::var_wc("CARGO_UNMAINTAINED_CACHE").map_or_else(
|_| {
let base_directories = xdg::BaseDirectories::new();
base_directories
.create_cache_directory("cargo-unmaintained")
.unwrap()
},
PathBuf::from,
)
});
#[cfg(all(feature = "on-disk-cache", not(windows)))]
const VERSION: &str = "v2";
#[allow(clippy::unwrap_used)]
static CRATES_IO_SYNC_CLIENT: LazyLock<SyncClient> =
LazyLock::new(|| SyncClient::new(USER_AGENT, RATE_LIMIT).unwrap());
pub fn with_cache<T>(f: impl FnOnce(&mut Cache) -> T) -> T {
CACHE_ONCE_CELL.with_borrow_mut(|once_cell| {
let _: &Cache = once_cell.get_or_init(|| {
#[cfg(all(feature = "on-disk-cache", not(windows)))]
let temporary = crate::opts::get().no_cache;
#[cfg(any(not(feature = "on-disk-cache"), windows))]
let temporary = true;
#[allow(clippy::panic)]
Cache::new(
temporary,
std::cmp::min(DEFAULT_REFRESH_AGE, crate::opts::get().max_age),
)
.unwrap_or_else(|error| panic!("failed to create on-disk repository cache: {error}"))
});
#[allow(clippy::unwrap_used)]
let cache = once_cell.get_mut().unwrap();
f(cache)
})
}
impl Cache {
fn new(temporary: bool, refresh_age: u64) -> Result<Self> {
let tempdir = if temporary {
tempdir()
.map(Option::Some)
.with_context(|| "failed to create temporary directory")?
} else {
None
};
Ok(Self {
tempdir,
refresh_age,
entries: HashMap::new(),
repository_timestamps: HashMap::new(),
versions_with_timestamps: HashMap::new(),
})
}
#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_unhandled_error))]
pub fn clone_repository(&mut self, pkg: &Package) -> Result<(String, PathBuf)> {
if let Ok(entry) = self.entry(pkg)
&& self
.repository_is_current(&entry.cloned_url)
.unwrap_or_default()
{
let repo_dir = self.repositories_dir().join(url_digest(&entry.cloned_url));
return Ok((entry.cloned_url, repo_dir));
}
let url_and_dir = self.clone_repository_uncached(pkg)?;
#[allow(clippy::unwrap_used)]
let entry = Entry {
named_url: pkg.repository.clone().unwrap(),
cloned_url: url_and_dir.0.as_str().to_owned(),
};
self.write_entry(&pkg.name, &entry)?;
self.entries.insert(pkg.name.clone(), entry);
let digest = url_digest(&url_and_dir.0);
let timestamp = SystemTime::now();
self.write_repository_timestamp(&digest, timestamp)?;
self.repository_timestamps.insert(digest, timestamp);
Ok(url_and_dir)
}
fn clone_repository_uncached(&mut self, pkg: &Package) -> Result<(String, PathBuf)> {
let _lock: File;
#[cfg(all(
feature = "on-disk-cache",
feature = "crates-index",
not(feature = "tame-index"),
not(windows)
))]
if self.tempdir.is_none() {
_lock = crate::flock::lock_path(&CACHE_DIRECTORY)
.with_context(|| format!("failed to lock `{}`", CACHE_DIRECTORY.display()))?;
}
let mut errors = Vec::new();
let mut urls = urls(pkg).into_iter().peekable();
while let Some(url) = urls.peek() {
let repo_dir = self.repositories_dir().join(url_digest(url.as_str()));
let exists = repository_existence(&repo_dir)?;
let mut command = if exists {
let branch_name = branch_name(&repo_dir)?;
let mut command = Command::new("git");
command.args([
"fetch",
"--update-head-ok",
"origin",
&format!("{branch_name}:{branch_name}"),
]);
command.current_dir(&repo_dir);
command
} else {
let mut command = Command::new("git");
command.args([
"clone",
"--depth=1",
"--no-checkout",
"--quiet",
url.as_str(),
&repo_dir.to_string_lossy(),
]);
command
};
command
.env("GCM_INTERACTIVE", "never")
.env("GIT_ASKPASS", "echo")
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped());
let output = command.output_wc()?;
if output.status.success() {
return Ok((url.as_str().to_owned(), repo_dir));
}
let error = String::from_utf8(output.stderr)?;
if exists && error.starts_with("fatal: couldn't find remote ref ") {
self.purge_one_entry(pkg)?;
debug_assert!(!repository_existence(&repo_dir)?);
continue;
}
errors.push(error);
let _: Option<crate::url::Url> = urls.next();
}
errors.dedup();
Err(anyhow!("{errors:#?}"))
}
pub fn purge_one_entry(&mut self, pkg: &Package) -> Result<()> {
if self
.versions_with_timestamps
.contains_key(pkg.name.as_str())
{
let path_buf = self.versions_dir().join(pkg.name.as_str());
remove_file_wc(&path_buf)?;
let path_buf = self.versions_timestamps_dir().join(pkg.name.as_str());
remove_file_wc(&path_buf)?;
self.versions_with_timestamps.remove(pkg.name.as_str());
}
let entry = self.entry(pkg)?;
let digest = url_digest(&entry.cloned_url);
let path_buf = self.repository_timestamps_dir().join(&digest);
remove_file_wc(&path_buf)?;
self.repository_timestamps.remove(&digest);
let path_buf = self.entries_dir().join(pkg.name.as_str());
remove_file_wc(&path_buf)?;
self.entries.remove(&pkg.name);
let path_buf = self.repositories_dir().join(digest);
remove_dir_all_wc(&path_buf)?;
Ok(())
}
fn entry(&mut self, pkg: &Package) -> Result<Entry> {
if !self.entries.contains_key(&pkg.name) {
let path_buf = self.entries_dir().join(pkg.name.as_str());
let contents = read_to_string_wc(&path_buf)?;
let entry = serde_json::from_str::<Entry>(&contents)?;
ensure!(
pkg.repository.as_ref() == Some(&entry.named_url),
"`pkg.repository` and `entry.named_url` differ"
);
self.entries.insert(pkg.name.clone(), entry);
}
#[allow(clippy::unwrap_used)]
Ok(self.entries.get(&pkg.name).cloned().unwrap())
}
fn repository_is_current(&mut self, url: &str) -> Result<bool> {
self.repository_timestamp(url).and_then(|timestamp| {
let duration = SystemTime::now().duration_since_wc(timestamp)?;
Ok(duration.as_secs() < self.refresh_age * SECS_PER_DAY)
})
}
fn repository_timestamp(&mut self, url: &str) -> Result<SystemTime> {
let digest = url_digest(url);
if !self.repository_timestamps.contains_key(&digest) {
let path_buf = self.repository_timestamps_dir().join(url_digest(url));
let contents = read_to_string_wc(&path_buf)?;
let secs = u64::from_str(&contents)?;
let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
self.repository_timestamps.insert(digest.clone(), timestamp);
}
#[allow(clippy::unwrap_used)]
Ok(*self.repository_timestamps.get(&digest).unwrap())
}
pub fn fetch_versions(&mut self, name: &str) -> Result<Vec<Version>> {
if let Ok(versions) = self.versions(name)
&& self.versions_are_current(name).unwrap_or_default()
{
return Ok(versions);
}
let crate_response = CRATES_IO_SYNC_CLIENT.get_crate(name)?;
let versions = crate_response.versions;
self.write_versions(name, &versions)?;
let timestamp = SystemTime::now();
self.write_versions_timestamp(name, timestamp)?;
self.versions_with_timestamps
.insert(name.to_owned(), (versions.clone(), timestamp));
Ok(versions)
}
fn versions(&mut self, name: &str) -> Result<Vec<Version>> {
self.versions_with_timestamp(name)
.map(|(versions, _)| versions)
}
fn versions_are_current(&mut self, url: &str) -> Result<bool> {
self.versions_timestamp(url).and_then(|timestamp| {
let duration = SystemTime::now().duration_since_wc(timestamp)?;
Ok(duration.as_secs() < self.refresh_age * SECS_PER_DAY)
})
}
fn versions_timestamp(&mut self, name: &str) -> Result<SystemTime> {
self.versions_with_timestamp(name)
.map(|(_, timestamp)| timestamp)
}
fn versions_with_timestamp(&mut self, name: &str) -> Result<(Vec<Version>, SystemTime)> {
if !self.versions_with_timestamps.contains_key(name) {
let path_buf = self.versions_timestamps_dir().join(name);
let contents = read_to_string_wc(&path_buf)?;
let versions = serde_json::from_str::<Vec<Version>>(&contents)?;
self.versions_with_timestamps
.insert(name.to_owned(), (versions, SystemTime::now()));
}
#[allow(clippy::unwrap_used)]
Ok(self.versions_with_timestamps.get(name).cloned().unwrap())
}
fn write_entry(&self, pkg_name: &str, entry: &Entry) -> Result<()> {
create_dir_all_wc(self.entries_dir())?;
let path_buf = self.entries_dir().join(pkg_name);
let json = serde_json::to_string_pretty(entry)?;
write_wc(&path_buf, json)?;
Ok(())
}
fn write_repository_timestamp(&self, digest: &str, timestamp: SystemTime) -> Result<()> {
create_dir_all_wc(self.repository_timestamps_dir())?;
let path_buf = self.repository_timestamps_dir().join(digest);
let duration = timestamp.duration_since_wc(SystemTime::UNIX_EPOCH)?;
write_wc(&path_buf, duration.as_secs().to_string())?;
Ok(())
}
fn write_versions(&self, name: &str, versions: &[Version]) -> Result<()> {
create_dir_all_wc(self.versions_dir())?;
let path_buf = self.versions_dir().join(name);
let json = serde_json::to_string_pretty(versions)?;
write_wc(&path_buf, json)?;
Ok(())
}
fn write_versions_timestamp(&self, name: &str, timestamp: SystemTime) -> Result<()> {
create_dir_all_wc(self.versions_timestamps_dir())?;
let path_buf = self.versions_timestamps_dir().join(name);
let duration = timestamp.duration_since_wc(SystemTime::UNIX_EPOCH)?;
write_wc(&path_buf, duration.as_secs().to_string())?;
Ok(())
}
fn entries_dir(&self) -> PathBuf {
self.base_dir().join("entries")
}
fn repositories_dir(&self) -> PathBuf {
self.base_dir().join("repositories")
}
fn repository_timestamps_dir(&self) -> PathBuf {
self.base_dir().join("timestamps")
}
fn versions_dir(&self) -> PathBuf {
self.base_dir().join("versions")
}
fn versions_timestamps_dir(&self) -> PathBuf {
self.base_dir().join("versions_timestamps")
}
fn base_dir(&self) -> PathBuf {
let base_dir = self.tempdir.as_ref().map(TempDir::path);
#[cfg(all(feature = "on-disk-cache", not(windows)))]
{
base_dir.unwrap_or(&CACHE_DIRECTORY).join(VERSION)
}
#[cfg(any(not(feature = "on-disk-cache"), windows))]
#[allow(clippy::unwrap_used)]
base_dir.unwrap().to_path_buf()
}
}
fn url_digest(url: &str) -> String {
sha1_smol::Sha1::from(url).hexdigest()
}
fn repository_existence(repo_dir: &Path) -> Result<bool> {
repo_dir.try_exists_wc()
}
fn branch_name(repo_dir: &Path) -> Result<String> {
let mut command = Command::new("git");
command.args(["branch", "--show-current"]);
command.current_dir(repo_dir);
let output = command.output_wc()?;
if !output.status.success() {
let error = String::from_utf8(output.stderr)?;
bail!(
"failed to get `{}` branch name: {}",
repo_dir.display(),
error
);
}
let stdout = std::str::from_utf8(&output.stdout)?;
Ok(stdout.trim_end().to_owned())
}
#[cfg(all(feature = "on-disk-cache", not(windows)))]
pub fn purge_cache() -> Result<()> {
if CACHE_DIRECTORY.try_exists_wc()? {
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
let _lock = crate::flock::lock_path(&CACHE_DIRECTORY)
.with_context(|| format!("failed to lock `{}`", CACHE_DIRECTORY.display()))?;
remove_dir_all_wc(&*CACHE_DIRECTORY)?;
eprintln!("Cache directory removed: {}", CACHE_DIRECTORY.display());
} else {
eprintln!(
"Cache directory does not exist: {}",
CACHE_DIRECTORY.display()
);
}
Ok(())
}
#[cfg(all(test, not(windows)))]
mod tests {
#![allow(clippy::unwrap_used)]
use super::{Cache, DEFAULT_REFRESH_AGE};
use cargo_metadata::MetadataCommand;
#[test]
fn purge_one_entry() {
let mut cache = Cache::new(false, DEFAULT_REFRESH_AGE).unwrap();
let metadata = MetadataCommand::new().exec().unwrap();
let pkg = metadata.packages.first().unwrap();
cache.clone_repository(pkg).unwrap();
cache.fetch_versions(&pkg.name).unwrap();
cache.purge_one_entry(pkg).unwrap();
}
}