1use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::OnceLock;
19
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::digest::Digest;
22use crate::error::RkError;
23
24use super::{DirReleaseSource, ReleaseManifest, ReleaseSource};
25
26pub const CRATE: &str = "release-kit";
28
29pub const INDEX_URL: &str = "https://index.crates.io/re/le/release-kit";
33
34pub const DL_URL: &str = "https://static.crates.io/crates";
37
38pub const CACHE_DIR: &str = "release";
40
41pub const RETAIN: usize = 4;
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Resolved {
51 pub version: String,
53 pub cksum: Digest,
55 pub index_fetched: bool,
57 pub archive_fetched: bool,
59}
60
61#[derive(Debug)]
63pub struct CrateReleaseSource {
64 selector: String,
65 cache: PathBuf,
66 resolved: OnceLock<Resolved>,
67}
68
69impl CrateReleaseSource {
70 pub fn new(selector: &str) -> Result<Self, RkError> {
77 let root = crate::applog::state_root().ok_or_else(|| {
78 RkError::refusal(
79 Diagnostic::new(
80 Reason::PrerequisiteUnmet,
81 "no state root: neither XDG_STATE_HOME nor HOME is set",
82 )
83 .action("set XDG_STATE_HOME or HOME so the release cache has a home"),
84 )
85 })?;
86 Ok(Self::with_cache(selector, root.join(CACHE_DIR)))
87 }
88
89 #[must_use]
91 pub fn with_cache(selector: &str, cache: impl Into<PathBuf>) -> Self {
92 Self {
93 selector: selector.to_owned(),
94 cache: cache.into(),
95 resolved: OnceLock::new(),
96 }
97 }
98
99 #[must_use]
101 pub fn selector(&self) -> &str {
102 &self.selector
103 }
104
105 pub fn resolve(&self) -> Result<&Resolved, RkError> {
116 if let Some(resolved) = self.resolved.get() {
117 return Ok(resolved);
118 }
119 let resolved = self.resolve_fresh()?;
120 Ok(self.resolved.get_or_init(|| resolved))
121 }
122
123 fn resolve_fresh(&self) -> Result<Resolved, RkError> {
124 let (version, cksum, index_fetched) = if let Some((version, cksum)) = self.cached_index() {
125 (version, cksum, false)
126 } else {
127 let (version, cksum) = self.resolve_at_index()?;
128 (version, cksum, true)
129 };
130 let dir = self.cache.join(cksum.to_string());
131 let archive_fetched = if dir.is_dir() {
139 verify_seal(&self.cache, &cksum, &dir)?;
140 false
141 } else {
142 self.fetch_and_verify(&version, &cksum, &dir)?;
143 true
144 };
145 let index = self.cache.join("index");
148 std::fs::create_dir_all(&index)?;
149 std::fs::write(index.join(&version), format!("{cksum}\n"))?;
150 if archive_fetched {
151 prune(&self.cache, RETAIN)?;
152 }
153 Ok(Resolved {
154 version,
155 cksum,
156 index_fetched,
157 archive_fetched,
158 })
159 }
160
161 #[must_use]
164 pub fn is_cached(&self) -> bool {
165 self.cached_index().is_some()
166 }
167
168 fn cached_index(&self) -> Option<(String, Digest)> {
170 if self.selector == "latest" {
171 return None;
172 }
173 let text = std::fs::read_to_string(self.cache.join("index").join(&self.selector)).ok()?;
174 let cksum = Digest::parse(text.trim())?;
175 self.cache
176 .join(cksum.to_string())
177 .is_dir()
178 .then(|| (self.selector.clone(), cksum))
179 }
180
181 fn resolve_at_index(&self) -> Result<(String, Digest), RkError> {
183 let body =
184 fetch(INDEX_URL).map_err(|detail| unreachable("the crates.io index", &detail))?;
185 let entries = parse_index(&body).map_err(|detail| {
186 RkError::refusal(
187 Diagnostic::new(
188 Reason::RegistryUnreachable,
189 format!("the crates.io index entry for {CRATE} did not parse: {detail}"),
190 )
191 .expected("one JSON object per line, each naming vers and cksum"),
192 )
193 })?;
194 let chosen = if self.selector == "latest" {
195 entries
196 .iter()
197 .filter(|entry| !entry.yanked && !entry.version.contains('-'))
198 .max_by(|a, b| compare_versions(&a.version, &b.version))
199 } else {
200 entries.iter().find(|entry| entry.version == self.selector)
201 };
202 let Some(entry) = chosen else {
203 return Err(RkError::Usage(format!(
204 "the crates.io index lists no {CRATE} version matching '{}'",
205 self.selector
206 )));
207 };
208 if entry.yanked {
209 return Err(RkError::refusal(
210 Diagnostic::new(
211 Reason::BundleUnverified,
212 format!("{CRATE} {} is yanked at the registry", entry.version),
213 )
214 .expected("a version the registry still vouches for"),
215 ));
216 }
217 Ok((entry.version.clone(), entry.cksum.clone()))
218 }
219
220 fn fetch_and_verify(&self, version: &str, cksum: &Digest, dir: &Path) -> Result<(), RkError> {
223 std::fs::create_dir_all(&self.cache)?;
224 let scratch = Scratch::new(self.cache.join(format!("fetch-{}", std::process::id())))?;
225 let archive = scratch.path().join(format!("{CRATE}-{version}.crate"));
226 let url = format!("{DL_URL}/{CRATE}/{CRATE}-{version}.crate");
227 fetch_to(&url, &archive).map_err(|detail| unreachable("the crate archive", &detail))?;
228 let bytes = std::fs::read(&archive)?;
229 let actual = Digest::of(&bytes);
230 if actual != *cksum {
231 return Err(RkError::refusal(
232 Diagnostic::new(
233 Reason::BundleUnverified,
234 format!(
235 "{CRATE}-{version}.crate digests to {actual}, and the registry index names {cksum}"
236 ),
237 )
238 .expected("an archive whose sha256 equals the index checksum")
239 .target_state("nothing was cached"),
240 ));
241 }
242 let unpacked = scratch.path().join("unpacked");
243 std::fs::create_dir_all(&unpacked)?;
244 let tar = std::env::var_os("RK_TAR_BIN").unwrap_or_else(|| "tar".into());
245 let status = Command::new(tar)
246 .arg("-xzf")
247 .arg(&archive)
248 .arg("-C")
249 .arg(&unpacked)
250 .status()
251 .map_err(|source| {
252 RkError::subprocess(Diagnostic::new(
253 Reason::SubprocessSpawn,
254 format!("tar did not run: {source}"),
255 ))
256 })?;
257 if !status.success() {
258 return Err(RkError::subprocess(Diagnostic::new(
259 Reason::SubprocessFailed,
260 format!("tar could not unpack {CRATE}-{version}.crate"),
261 )));
262 }
263 let tree = unpacked.join(format!("{CRATE}-{version}"));
264 if !tree.is_dir() {
265 return Err(RkError::refusal(
266 Diagnostic::new(
267 Reason::BundleUnverified,
268 format!("{CRATE}-{version}.crate does not unpack to {CRATE}-{version}/"),
269 )
270 .target_state("nothing was cached"),
271 ));
272 }
273 std::fs::rename(&tree, dir)?;
274 std::fs::write(
277 seal_path(&self.cache, cksum),
278 seal_body(cksum, &tree_digest(dir)?),
279 )?;
280 Ok(())
281 }
282}
283
284fn seal_path(cache: &Path, cksum: &Digest) -> PathBuf {
290 cache.join(format!("{cksum}.seal"))
291}
292
293fn seal_body(cksum: &Digest, tree: &Digest) -> String {
298 [cksum.to_string(), tree.to_string(), String::new()].join("\n")
299}
300
301fn tree_digest(dir: &Path) -> Result<Digest, RkError> {
304 let mut files = Vec::new();
305 walk(dir, &mut files)?;
306 files.sort();
307 let mut acc = Vec::new();
308 for file in files {
309 let rel = file
310 .strip_prefix(dir)
311 .map_err(|_| anyhow::anyhow!("{} is outside the bundle", file.display()))?
312 .to_string_lossy()
313 .replace('\\', "/");
314 acc.extend_from_slice(rel.as_bytes());
315 acc.push(b'\n');
316 acc.extend_from_slice(Digest::of(&std::fs::read(&file)?).to_string().as_bytes());
317 acc.push(b'\n');
318 }
319 Ok(Digest::of(&acc))
320}
321
322fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
324 for entry in std::fs::read_dir(dir)? {
325 let entry = entry?;
326 let path = entry.path();
327 if path.is_dir() {
328 walk(&path, out)?;
329 } else if path.is_file() {
330 out.push(path);
331 }
332 }
333 Ok(())
334}
335
336fn verify_seal(cache: &Path, cksum: &Digest, dir: &Path) -> Result<(), RkError> {
342 let path = seal_path(cache, cksum);
343 let altered = |detail: String| {
344 RkError::refusal(
345 Diagnostic::new(
346 Reason::BundleUnverified,
347 format!("the cached bundle for {cksum} {detail}"),
348 )
349 .expected("a cached bundle whose bytes are the ones its verified archive unpacked to")
350 .action(format!(
351 "remove {} and its seal, so the next read fetches and verifies the archive again",
352 dir.display()
353 ))
354 .target_state("nothing was read from it"),
355 )
356 };
357 let Ok(text) = std::fs::read_to_string(&path) else {
358 return Err(altered("carries no seal".to_owned()));
359 };
360 let actual = tree_digest(dir)?;
361 if text != seal_body(cksum, &actual) {
362 return Err(altered(
363 "does not match the seal its verified archive left".to_owned(),
364 ));
365 }
366 Ok(())
367}
368
369impl ReleaseSource for CrateReleaseSource {
370 fn manifest(&self) -> Result<ReleaseManifest, RkError> {
371 let resolved = self.resolve()?;
372 let manifest =
373 DirReleaseSource::new(self.cache.join(resolved.cksum.to_string())).manifest()?;
374 manifest.check_schema()?;
375 Ok(manifest)
376 }
377
378 fn blob(&self, digest: &Digest) -> Result<Vec<u8>, RkError> {
379 let resolved = self.resolve()?;
380 DirReleaseSource::new(self.cache.join(resolved.cksum.to_string())).blob(digest)
381 }
382}
383
384struct Scratch(PathBuf);
387
388impl Scratch {
389 fn new(path: PathBuf) -> std::io::Result<Self> {
390 if path.exists() {
391 std::fs::remove_dir_all(&path)?;
392 }
393 std::fs::create_dir_all(&path)?;
394 Ok(Self(path))
395 }
396
397 fn path(&self) -> &Path {
398 &self.0
399 }
400}
401
402impl Drop for Scratch {
403 fn drop(&mut self) {
404 let _ = std::fs::remove_dir_all(&self.0);
405 }
406}
407
408#[derive(Debug, PartialEq, Eq)]
410struct IndexEntry {
411 version: String,
412 cksum: Digest,
413 yanked: bool,
414}
415
416fn parse_index(body: &[u8]) -> Result<Vec<IndexEntry>, String> {
418 let text = std::str::from_utf8(body).map_err(|e| e.to_string())?;
419 let mut out = Vec::new();
420 for (number, line) in text.lines().enumerate() {
421 if line.trim().is_empty() {
422 continue;
423 }
424 let value: serde_json::Value =
425 serde_json::from_str(line).map_err(|e| format!("line {}: {e}", number + 1))?;
426 let version = value
427 .get("vers")
428 .and_then(serde_json::Value::as_str)
429 .ok_or_else(|| format!("line {}: no vers", number + 1))?
430 .to_owned();
431 let cksum = value
432 .get("cksum")
433 .and_then(serde_json::Value::as_str)
434 .and_then(Digest::parse)
435 .ok_or_else(|| format!("line {}: no sha256 cksum", number + 1))?;
436 let yanked = value
437 .get("yanked")
438 .and_then(serde_json::Value::as_bool)
439 .unwrap_or(false);
440 out.push(IndexEntry {
441 version,
442 cksum,
443 yanked,
444 });
445 }
446 Ok(out)
447}
448
449fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
452 parse_version(a).cmp(&parse_version(b))
453}
454
455fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
456 let core = text.split(['-', '+']).next()?;
457 let mut parts = core.split('.').map(str::parse::<u64>);
458 Some((
459 parts.next()?.ok()?,
460 parts.next()?.ok()?,
461 parts.next()?.ok()?,
462 ))
463}
464
465fn fetch(url: &str) -> Result<Vec<u8>, String> {
467 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
468 let output = Command::new(curl)
469 .args(["-fsSL", "--max-time", "30", url])
470 .output()
471 .map_err(|source| format!("curl did not run: {source}"))?;
472 if output.status.success() {
473 Ok(output.stdout)
474 } else {
475 Err(String::from_utf8_lossy(&output.stderr)
476 .lines()
477 .last()
478 .unwrap_or("curl failed")
479 .to_owned())
480 }
481}
482
483fn fetch_to(url: &str, path: &Path) -> Result<(), String> {
485 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
486 let output = Command::new(curl)
487 .args(["-fsSL", "--max-time", "120", "-o"])
488 .arg(path)
489 .arg(url)
490 .output()
491 .map_err(|source| format!("curl did not run: {source}"))?;
492 if output.status.success() && path.is_file() {
493 Ok(())
494 } else {
495 Err(String::from_utf8_lossy(&output.stderr)
496 .lines()
497 .last()
498 .unwrap_or("curl failed")
499 .to_owned())
500 }
501}
502
503fn unreachable(what: &str, detail: &str) -> RkError {
504 RkError::refusal(
505 Diagnostic::new(
506 Reason::RegistryUnreachable,
507 format!("{what} did not answer: {detail}"),
508 )
509 .expected("a host that can reach crates.io, or a bundle already in the cache")
510 .target_state("nothing was cached"),
511 )
512}
513
514fn prune(cache: &Path, retain: usize) -> Result<(), RkError> {
517 let mut bundles: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
518 for entry in std::fs::read_dir(cache)? {
519 let entry = entry?;
520 let path = entry.path();
521 let name = entry.file_name().to_string_lossy().into_owned();
522 if path.is_dir() && Digest::parse(&name).is_some() {
523 let modified = entry
524 .metadata()?
525 .modified()
526 .unwrap_or(std::time::UNIX_EPOCH);
527 bundles.push((modified, path));
528 }
529 }
530 bundles.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
531 for (_, path) in bundles.iter().skip(retain) {
532 std::fs::remove_dir_all(path)?;
533 let gone = path.file_name().map(|n| n.to_string_lossy().into_owned());
534 if let Some(gone) = &gone {
538 if let Some(cksum) = Digest::parse(gone) {
539 let _ = std::fs::remove_file(seal_path(cache, &cksum));
540 }
541 }
542 let index = cache.join("index");
543 if let (Some(gone), Ok(entries)) = (gone, std::fs::read_dir(&index)) {
544 for entry in entries.flatten() {
545 let names_it =
546 std::fs::read_to_string(entry.path()).is_ok_and(|text| text.trim() == gone);
547 if names_it {
548 let _ = std::fs::remove_file(entry.path());
549 }
550 }
551 }
552 }
553 Ok(())
554}
555
556#[cfg(test)]
557mod tests {
558 use super::{RETAIN, compare_versions, parse_index, prune};
559 use crate::digest::Digest;
560
561 #[test]
562 fn the_index_parses_one_entry_per_line() {
563 let a = Digest::of(b"a").to_string();
564 let b = Digest::of(b"b").to_string();
565 let body = format!(
566 "{{\"name\":\"release-kit\",\"vers\":\"0.3.17\",\"cksum\":\"{a}\",\"yanked\":false}}\n{{\"name\":\"release-kit\",\"vers\":\"0.3.18\",\"cksum\":\"{b}\",\"yanked\":true}}\n"
567 );
568 let entries = parse_index(body.as_bytes()).expect("the index parses");
569 assert_eq!(entries.len(), 2);
570 assert_eq!(entries[0].version, "0.3.17");
571 assert!(!entries[0].yanked);
572 assert!(entries[1].yanked);
573 assert!(
574 parse_index(b"{\"vers\":\"1.0.0\"}\n").is_err(),
575 "no cksum refuses"
576 );
577 }
578
579 #[test]
580 fn versions_compare_numerically() {
581 use std::cmp::Ordering;
582 assert_eq!(compare_versions("0.3.9", "0.3.10"), Ordering::Less);
583 assert_eq!(compare_versions("1.0.0", "0.99.99"), Ordering::Greater);
584 assert_eq!(compare_versions("0.3.18", "0.3.18"), Ordering::Equal);
585 }
586
587 #[test]
588 fn the_cache_keeps_the_newest_bundles() {
589 let cache = tempfile::tempdir().expect("a scratch cache");
590 let index = cache.path().join("index");
591 std::fs::create_dir_all(&index).expect("the index dir exists");
592 let mut names = Vec::new();
593 for i in 0..=RETAIN {
594 let name = Digest::of(&[u8::try_from(i).expect("small")]).to_string();
595 std::fs::create_dir_all(cache.path().join(&name)).expect("a bundle dir");
596 std::fs::write(index.join(format!("0.0.{i}")), format!("{name}\n"))
597 .expect("an index entry");
598 let when = std::time::SystemTime::UNIX_EPOCH
599 + std::time::Duration::from_secs(1_000 + i as u64);
600 std::fs::File::open(cache.path().join(&name))
601 .and_then(|f| f.set_modified(when))
602 .expect("mtime set");
603 names.push(name);
604 }
605 prune(cache.path(), RETAIN).expect("the prune runs");
606 assert!(!cache.path().join(&names[0]).exists(), "the oldest went");
607 assert!(
608 !index.join("0.0.0").exists(),
609 "its index entry went with it"
610 );
611 for name in &names[1..] {
612 assert!(cache.path().join(name).is_dir(), "{name} kept");
613 }
614 }
615}