use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use super::{DirReleaseSource, ReleaseManifest, ReleaseSource};
pub const CRATE: &str = "release-kit";
pub const INDEX_URL: &str = "https://index.crates.io/re/le/release-kit";
pub const DL_URL: &str = "https://static.crates.io/crates";
pub const CACHE_DIR: &str = "release";
pub const RETAIN: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub version: String,
pub cksum: Digest,
pub index_fetched: bool,
pub archive_fetched: bool,
}
#[derive(Debug)]
pub struct CrateReleaseSource {
selector: String,
cache: PathBuf,
resolved: OnceLock<Resolved>,
}
impl CrateReleaseSource {
pub fn new(selector: &str) -> Result<Self, RkError> {
let root = crate::applog::state_root().ok_or_else(|| {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"no state root: neither XDG_STATE_HOME nor HOME is set",
)
.action("set XDG_STATE_HOME or HOME so the release cache has a home"),
)
})?;
Ok(Self::with_cache(selector, root.join(CACHE_DIR)))
}
#[must_use]
pub fn with_cache(selector: &str, cache: impl Into<PathBuf>) -> Self {
Self {
selector: selector.to_owned(),
cache: cache.into(),
resolved: OnceLock::new(),
}
}
#[must_use]
pub fn selector(&self) -> &str {
&self.selector
}
pub fn resolve(&self) -> Result<&Resolved, RkError> {
if let Some(resolved) = self.resolved.get() {
return Ok(resolved);
}
let resolved = self.resolve_fresh()?;
Ok(self.resolved.get_or_init(|| resolved))
}
fn resolve_fresh(&self) -> Result<Resolved, RkError> {
let (version, cksum, index_fetched) = if let Some((version, cksum)) = self.cached_index() {
(version, cksum, false)
} else {
let (version, cksum) = self.resolve_at_index()?;
(version, cksum, true)
};
let dir = self.cache.join(cksum.to_string());
let archive_fetched = if dir.is_dir() {
verify_seal(&self.cache, &cksum, &dir)?;
false
} else {
self.fetch_and_verify(&version, &cksum, &dir)?;
true
};
let index = self.cache.join("index");
std::fs::create_dir_all(&index)?;
std::fs::write(index.join(&version), format!("{cksum}\n"))?;
if archive_fetched {
prune(&self.cache, RETAIN)?;
}
Ok(Resolved {
version,
cksum,
index_fetched,
archive_fetched,
})
}
#[must_use]
pub fn is_cached(&self) -> bool {
self.cached_index().is_some()
}
fn cached_index(&self) -> Option<(String, Digest)> {
if self.selector == "latest" {
return None;
}
let text = std::fs::read_to_string(self.cache.join("index").join(&self.selector)).ok()?;
let cksum = Digest::parse(text.trim())?;
self.cache
.join(cksum.to_string())
.is_dir()
.then(|| (self.selector.clone(), cksum))
}
fn resolve_at_index(&self) -> Result<(String, Digest), RkError> {
let body =
fetch(INDEX_URL).map_err(|detail| unreachable("the crates.io index", &detail))?;
let entries = parse_index(&body).map_err(|detail| {
RkError::refusal(
Diagnostic::new(
Reason::RegistryUnreachable,
format!("the crates.io index entry for {CRATE} did not parse: {detail}"),
)
.expected("one JSON object per line, each naming vers and cksum"),
)
})?;
let chosen = if self.selector == "latest" {
entries
.iter()
.filter(|entry| !entry.yanked && !entry.version.contains('-'))
.max_by(|a, b| compare_versions(&a.version, &b.version))
} else {
entries.iter().find(|entry| entry.version == self.selector)
};
let Some(entry) = chosen else {
return Err(RkError::Usage(format!(
"the crates.io index lists no {CRATE} version matching '{}'",
self.selector
)));
};
if entry.yanked {
return Err(RkError::refusal(
Diagnostic::new(
Reason::BundleUnverified,
format!("{CRATE} {} is yanked at the registry", entry.version),
)
.expected("a version the registry still vouches for"),
));
}
Ok((entry.version.clone(), entry.cksum.clone()))
}
fn fetch_and_verify(&self, version: &str, cksum: &Digest, dir: &Path) -> Result<(), RkError> {
std::fs::create_dir_all(&self.cache)?;
let scratch = Scratch::new(self.cache.join(format!("fetch-{}", std::process::id())))?;
let archive = scratch.path().join(format!("{CRATE}-{version}.crate"));
let url = format!("{DL_URL}/{CRATE}/{CRATE}-{version}.crate");
fetch_to(&url, &archive).map_err(|detail| unreachable("the crate archive", &detail))?;
let bytes = std::fs::read(&archive)?;
let actual = Digest::of(&bytes);
if actual != *cksum {
return Err(RkError::refusal(
Diagnostic::new(
Reason::BundleUnverified,
format!(
"{CRATE}-{version}.crate digests to {actual}, and the registry index names {cksum}"
),
)
.expected("an archive whose sha256 equals the index checksum")
.target_state("nothing was cached"),
));
}
let unpacked = scratch.path().join("unpacked");
std::fs::create_dir_all(&unpacked)?;
let tar = std::env::var_os("RK_TAR_BIN").unwrap_or_else(|| "tar".into());
let status = Command::new(tar)
.arg("-xzf")
.arg(&archive)
.arg("-C")
.arg(&unpacked)
.status()
.map_err(|source| {
RkError::subprocess(Diagnostic::new(
Reason::SubprocessSpawn,
format!("tar did not run: {source}"),
))
})?;
if !status.success() {
return Err(RkError::subprocess(Diagnostic::new(
Reason::SubprocessFailed,
format!("tar could not unpack {CRATE}-{version}.crate"),
)));
}
let tree = unpacked.join(format!("{CRATE}-{version}"));
if !tree.is_dir() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::BundleUnverified,
format!("{CRATE}-{version}.crate does not unpack to {CRATE}-{version}/"),
)
.target_state("nothing was cached"),
));
}
std::fs::rename(&tree, dir)?;
std::fs::write(
seal_path(&self.cache, cksum),
seal_body(cksum, &tree_digest(dir)?),
)?;
Ok(())
}
}
fn seal_path(cache: &Path, cksum: &Digest) -> PathBuf {
cache.join(format!("{cksum}.seal"))
}
fn seal_body(cksum: &Digest, tree: &Digest) -> String {
[cksum.to_string(), tree.to_string(), String::new()].join("\n")
}
fn tree_digest(dir: &Path) -> Result<Digest, RkError> {
let mut files = Vec::new();
walk(dir, &mut files)?;
files.sort();
let mut acc = Vec::new();
for file in files {
let rel = file
.strip_prefix(dir)
.map_err(|_| anyhow::anyhow!("{} is outside the bundle", file.display()))?
.to_string_lossy()
.replace('\\', "/");
acc.extend_from_slice(rel.as_bytes());
acc.push(b'\n');
acc.extend_from_slice(Digest::of(&std::fs::read(&file)?).to_string().as_bytes());
acc.push(b'\n');
}
Ok(Digest::of(&acc))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
fn verify_seal(cache: &Path, cksum: &Digest, dir: &Path) -> Result<(), RkError> {
let path = seal_path(cache, cksum);
let altered = |detail: String| {
RkError::refusal(
Diagnostic::new(
Reason::BundleUnverified,
format!("the cached bundle for {cksum} {detail}"),
)
.expected("a cached bundle whose bytes are the ones its verified archive unpacked to")
.action(format!(
"remove {} and its seal, so the next read fetches and verifies the archive again",
dir.display()
))
.target_state("nothing was read from it"),
)
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Err(altered("carries no seal".to_owned()));
};
let actual = tree_digest(dir)?;
if text != seal_body(cksum, &actual) {
return Err(altered(
"does not match the seal its verified archive left".to_owned(),
));
}
Ok(())
}
impl ReleaseSource for CrateReleaseSource {
fn manifest(&self) -> Result<ReleaseManifest, RkError> {
let resolved = self.resolve()?;
let manifest =
DirReleaseSource::new(self.cache.join(resolved.cksum.to_string())).manifest()?;
manifest.check_schema()?;
Ok(manifest)
}
fn blob(&self, digest: &Digest) -> Result<Vec<u8>, RkError> {
let resolved = self.resolve()?;
DirReleaseSource::new(self.cache.join(resolved.cksum.to_string())).blob(digest)
}
}
struct Scratch(PathBuf);
impl Scratch {
fn new(path: PathBuf) -> std::io::Result<Self> {
if path.exists() {
std::fs::remove_dir_all(&path)?;
}
std::fs::create_dir_all(&path)?;
Ok(Self(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[derive(Debug, PartialEq, Eq)]
struct IndexEntry {
version: String,
cksum: Digest,
yanked: bool,
}
fn parse_index(body: &[u8]) -> Result<Vec<IndexEntry>, String> {
let text = std::str::from_utf8(body).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for (number, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("line {}: {e}", number + 1))?;
let version = value
.get("vers")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("line {}: no vers", number + 1))?
.to_owned();
let cksum = value
.get("cksum")
.and_then(serde_json::Value::as_str)
.and_then(Digest::parse)
.ok_or_else(|| format!("line {}: no sha256 cksum", number + 1))?;
let yanked = value
.get("yanked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
out.push(IndexEntry {
version,
cksum,
yanked,
});
}
Ok(out)
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
parse_version(a).cmp(&parse_version(b))
}
fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
let core = text.split(['-', '+']).next()?;
let mut parts = core.split('.').map(str::parse::<u64>);
Some((
parts.next()?.ok()?,
parts.next()?.ok()?,
parts.next()?.ok()?,
))
}
fn fetch(url: &str) -> Result<Vec<u8>, String> {
let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
let output = Command::new(curl)
.args(["-fsSL", "--max-time", "30", url])
.output()
.map_err(|source| format!("curl did not run: {source}"))?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(String::from_utf8_lossy(&output.stderr)
.lines()
.last()
.unwrap_or("curl failed")
.to_owned())
}
}
fn fetch_to(url: &str, path: &Path) -> Result<(), String> {
let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
let output = Command::new(curl)
.args(["-fsSL", "--max-time", "120", "-o"])
.arg(path)
.arg(url)
.output()
.map_err(|source| format!("curl did not run: {source}"))?;
if output.status.success() && path.is_file() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr)
.lines()
.last()
.unwrap_or("curl failed")
.to_owned())
}
}
fn unreachable(what: &str, detail: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::RegistryUnreachable,
format!("{what} did not answer: {detail}"),
)
.expected("a host that can reach crates.io, or a bundle already in the cache")
.target_state("nothing was cached"),
)
}
fn prune(cache: &Path, retain: usize) -> Result<(), RkError> {
let mut bundles: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
for entry in std::fs::read_dir(cache)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() && Digest::parse(&name).is_some() {
let modified = entry
.metadata()?
.modified()
.unwrap_or(std::time::UNIX_EPOCH);
bundles.push((modified, path));
}
}
bundles.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
for (_, path) in bundles.iter().skip(retain) {
std::fs::remove_dir_all(path)?;
let gone = path.file_name().map(|n| n.to_string_lossy().into_owned());
if let Some(gone) = &gone
&& let Some(cksum) = Digest::parse(gone)
{
let _ = std::fs::remove_file(seal_path(cache, &cksum));
}
let index = cache.join("index");
if let (Some(gone), Ok(entries)) = (gone, std::fs::read_dir(&index)) {
for entry in entries.flatten() {
let names_it =
std::fs::read_to_string(entry.path()).is_ok_and(|text| text.trim() == gone);
if names_it {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{RETAIN, compare_versions, parse_index, prune};
use crate::digest::Digest;
#[test]
fn the_index_parses_one_entry_per_line() {
let a = Digest::of(b"a").to_string();
let b = Digest::of(b"b").to_string();
let body = format!(
"{{\"name\":\"release-kit\",\"vers\":\"0.3.17\",\"cksum\":\"{a}\",\"yanked\":false}}\n{{\"name\":\"release-kit\",\"vers\":\"0.3.18\",\"cksum\":\"{b}\",\"yanked\":true}}\n"
);
let entries = parse_index(body.as_bytes()).expect("the index parses");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].version, "0.3.17");
assert!(!entries[0].yanked);
assert!(entries[1].yanked);
assert!(
parse_index(b"{\"vers\":\"1.0.0\"}\n").is_err(),
"no cksum refuses"
);
}
#[test]
fn versions_compare_numerically() {
use std::cmp::Ordering;
assert_eq!(compare_versions("0.3.9", "0.3.10"), Ordering::Less);
assert_eq!(compare_versions("1.0.0", "0.99.99"), Ordering::Greater);
assert_eq!(compare_versions("0.3.18", "0.3.18"), Ordering::Equal);
}
#[test]
fn the_cache_keeps_the_newest_bundles() {
let cache = tempfile::tempdir().expect("a scratch cache");
let index = cache.path().join("index");
std::fs::create_dir_all(&index).expect("the index dir exists");
let mut names = Vec::new();
for i in 0..=RETAIN {
let name = Digest::of(&[u8::try_from(i).expect("small")]).to_string();
std::fs::create_dir_all(cache.path().join(&name)).expect("a bundle dir");
std::fs::write(index.join(format!("0.0.{i}")), format!("{name}\n"))
.expect("an index entry");
let when = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(1_000 + i as u64);
std::fs::File::open(cache.path().join(&name))
.and_then(|f| f.set_modified(when))
.expect("mtime set");
names.push(name);
}
prune(cache.path(), RETAIN).expect("the prune runs");
assert!(!cache.path().join(&names[0]).exists(), "the oldest went");
assert!(
!index.join("0.0.0").exists(),
"its index entry went with it"
);
for name in &names[1..] {
assert!(cache.path().join(name).is_dir(), "{name} kept");
}
}
}