Skip to main content

release_kit/release/
crate_source.rs

1//! The crates venue behind the seam: another release's bundle, fetched
2//! once, verified against the registry's own checksum, and cached by
3//! that checksum.
4//!
5//! The published crate carries every payload root, so the `.crate` file
6//! at an exact version is the bundle for that release. Two reads reach
7//! the network: the sparse index, which resolves a selector to one exact
8//! version and one checksum, and the archive itself. The archive is
9//! verified against the index checksum before anything is kept, unpacked
10//! under one directory outside every target, and read back through
11//! [`DirReleaseSource`]. A checksum already cached is served with no
12//! network touch, and an exact version once verified resolves offline
13//! too. The bundle is data: nothing under the cache is executed, put on
14//! `PATH`, or written into a repository.
15
16use 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
26/// The crate's name at the registry.
27pub const CRATE: &str = "release-kit";
28
29/// The sparse index entry for the crate, by the registry's path rule for
30/// names of four or more characters: the first two, then the next two,
31/// then the name.
32pub const INDEX_URL: &str = "https://index.crates.io/re/le/release-kit";
33
34/// The archive download root the registry's `config.json` names; the
35/// crate file lives at `<dl>/<crate>/<crate>-<version>.crate`.
36pub const DL_URL: &str = "https://static.crates.io/crates";
37
38/// The cache directory under the state root.
39pub const CACHE_DIR: &str = "release";
40
41/// How many fetched bundles the cache keeps.
42///
43/// The newest by fetch time, pruned after every fetch that adds one. An
44/// upgrade needs one bundle beside the embedded release, and a small
45/// window covers a retry and a comparison without growing without bound.
46pub const RETAIN: usize = 4;
47
48/// The one release the selector resolved to.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Resolved {
51    /// The exact version.
52    pub version: String,
53    /// The registry's checksum of the `.crate` file.
54    pub cksum: Digest,
55    /// Whether this call reached the network for the index.
56    pub index_fetched: bool,
57    /// Whether this call fetched the archive, or served it from cache.
58    pub archive_fetched: bool,
59}
60
61/// The crates venue for one selector.
62#[derive(Debug)]
63pub struct CrateReleaseSource {
64    selector: String,
65    cache: PathBuf,
66    resolved: OnceLock<Resolved>,
67}
68
69impl CrateReleaseSource {
70    /// A source for `selector` — `latest` or an exact version — caching
71    /// under the state root.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`RkError::Refusal`] when no state root can be located.
76    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    /// A source caching under an explicit directory.
90    #[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    /// The selector as given.
100    #[must_use]
101    pub fn selector(&self) -> &str {
102        &self.selector
103    }
104
105    /// Resolve the selector, fetch and verify the archive where the cache
106    /// does not hold it, and answer with what happened. Idempotent: the
107    /// second call answers from the first.
108    ///
109    /// # Errors
110    ///
111    /// Returns a `registry-unreachable` refusal when the index or the
112    /// archive cannot be fetched, a `bundle-unverified` refusal when the
113    /// archive's digest differs from the index checksum, and a usage
114    /// error for a version the index does not list.
115    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        // A cache hit is served only where the extracted tree still
132        // digests to what the verified archive unpacked to. The registry
133        // vouches for the archive, and the seal carries that vouching
134        // forward to the bytes on disk; without the check, an altered
135        // cache entry would be read back as a release the registry
136        // verified, because the manifest recomputes its digests from
137        // whatever the directory now holds.
138        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        // The version maps to its checksum only once the archive behind it
146        // verified, so a mismatch caches nothing, not even the name.
147        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    /// Whether the selector names an exact version whose verified bundle
162    /// the cache already holds, so a read touches no network.
163    #[must_use]
164    pub fn is_cached(&self) -> bool {
165        self.cached_index().is_some()
166    }
167
168    /// An exact version's checksum, from a previous verified fetch.
169    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    /// The selector against the live index.
182    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    /// Fetch the archive to a scratch file beside the cache, verify it,
221    /// unpack it, and move the unpacked tree to `dir` in one rename.
222    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        // The seal, written only now: the archive verified, so the tree
275        // it unpacked to is what the registry's checksum vouches for.
276        std::fs::write(
277            seal_path(&self.cache, cksum),
278            seal_body(cksum, &tree_digest(dir)?),
279        )?;
280        Ok(())
281    }
282}
283
284/// The seal beside one cached bundle, naming the archive checksum the
285/// registry vouched for and the digest of the tree it unpacked to.
286///
287/// It lives beside the directory rather than inside it, so the tree
288/// digest covers the whole bundle and nothing else.
289fn seal_path(cache: &Path, cksum: &Digest) -> PathBuf {
290    cache.join(format!("{cksum}.seal"))
291}
292
293/// The seal's two lines: the archive checksum, then the tree digest.
294///
295/// Joined rather than formatted, because two escapes in one format
296/// string read to the source scan as an artifact body.
297fn seal_body(cksum: &Digest, tree: &Digest) -> String {
298    [cksum.to_string(), tree.to_string(), String::new()].join("\n")
299}
300
301/// One digest over every file below `dir`, path and bytes, sorted, so a
302/// changed byte, a removed file, and an added file all move it.
303fn 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
322/// Every regular file below `dir`, recursively.
323fn 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
336/// A cached bundle read back against the seal the verified archive left.
337///
338/// The seal's first line must be the checksum the directory is named
339/// for, so a seal lifted from another bundle does not vouch for this
340/// one, and its second line must be the tree's digest now.
341fn 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
384/// A scratch directory removed on drop, whatever the fetch did, so a
385/// refused archive leaves nothing behind.
386struct 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/// One line of the sparse index.
409#[derive(Debug, PartialEq, Eq)]
410struct IndexEntry {
411    version: String,
412    cksum: Digest,
413    yanked: bool,
414}
415
416/// The index body: one JSON object per line.
417fn 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
449/// Numeric semver order over `major.minor.patch`; anything unparsable
450/// sorts first.
451fn 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
465/// One GET through curl, body on stdout.
466fn 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
483/// One GET through curl, body to a file.
484fn 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
514/// Keep the `retain` newest bundle directories by modification time and
515/// remove the rest, together with the index entries that named them.
516fn 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        // The seal goes with the bundle it vouches for, so a later fetch
535        // of the same checksum writes a fresh one rather than reading a
536        // seal left by the tree it replaced.
537        if let Some(gone) = &gone
538            && let Some(cksum) = Digest::parse(gone)
539        {
540            let _ = std::fs::remove_file(seal_path(cache, &cksum));
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}