diff --git a/changelog.md b/changelog.md index 992849fa..ff002537 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,17 @@ +# 0.6.4 — user-visible changes + +## A configured releases namespace caches by its host, not in the shared Typst cache + +A namespace configured with `releases = ...` now caches its downloaded +packages under `rheo/releases` in the cache directory, keyed by the host it +downloads from, rather than in the shared Typst package cache. Two projects +backing one namespace name with different `releases` hosts no longer share +packages — each host gets its own directory, so neither can serve the other's +packages by accident. A copy placed by hand in the Typst package cache no +longer shadows a configured host either; a developer wanting that kind of +override should use `path = ...` instead. The one-time cost is that each +configured namespace re-downloads its packages once. + # 0.6.3 — user-visible changes ## A childless directory index gets a real page: `[spine] auto_index` diff --git a/crates/core/src/config/packages.rs b/crates/core/src/config/packages.rs index 5f5b83af..77fba7e9 100644 --- a/crates/core/src/config/packages.rs +++ b/crates/core/src/config/packages.rs @@ -97,6 +97,14 @@ impl ReleasesSource { .replace("{version}", version), } } + + /// The value identifying this source, for keying its cache directory. + pub fn source_key(&self) -> &str { + match self { + ReleasesSource::Base(base) => base, + ReleasesSource::Template(template) => template, + } + } } /// A namespace served from a directory on disk — a package's own working diff --git a/crates/core/src/packages/git.rs b/crates/core/src/packages/git.rs index 0ca4d1b0..29c29a08 100644 --- a/crates/core/src/packages/git.rs +++ b/crates/core/src/packages/git.rs @@ -1,4 +1,3 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -9,7 +8,7 @@ use typst_kit::files::FsRoot; use typst_library::diag::{PackageError, PackageResult}; use typst_syntax::package::PackageSpec; -use super::{Announce, PackageSource, SourceKind, cache_root, package_dir}; +use super::{Announce, PackageSource, SourceKind, cache_root, package_dir, slug}; use crate::config::{GitRef, RepoSource}; /// Serves a namespace from a repository, checked out at a resolved commit sha. @@ -257,19 +256,6 @@ impl PackageSource for GitPackages { } } -/// A filesystem-safe stand-in for a git URL. Hashed rather than sanitised: a URL -/// carries `:`, `/` and `@`, and any escaping scheme that stayed readable would -/// also have to stay injective. -/// -/// `DefaultHasher` is deterministic across runs but not promised to be stable -/// across Rust versions; the cost of it changing is one extra clone, not a wrong -/// answer, since the sha below it still names the content. -fn slug(url: &str) -> String { - let mut hasher = DefaultHasher::new(); - url.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/packages/mod.rs b/crates/core/src/packages/mod.rs index 9fe5e518..f10e4aba 100644 --- a/crates/core/src/packages/mod.rs +++ b/crates/core/src/packages/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -84,6 +85,20 @@ fn package_dir(root: &Path, subdir: &str, spec: &PackageSpec) -> Result-` release. +pub(super) fn slug(url: &str) -> String { + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + /// Resolves a package spec to a directory on disk, routing by namespace. /// /// Built once per build and shared, because the repository backends memoise @@ -191,6 +206,20 @@ fn downloader() -> SystemDownloader { SystemDownloader::new(USER_AGENT) } +/// Where the built-in `@rheo` backend caches: the shared Typst package cache, +/// which an unconfigured namespace also resolves from. +fn builtin_cache_root(root: &Path) -> PathBuf { + root.join("typst/packages") +} + +/// Where a namespace configured with `releases = ...` caches. Keyed by the +/// source, so two projects backing one namespace name with different hosts +/// cannot serve each other stale packages, and nothing dropped in the shared +/// Typst cache can shadow what the project declares. +fn releases_cache_root(root: &Path, source: &ReleasesSource) -> PathBuf { + root.join("rheo/releases").join(slug(source.source_key())) +} + /// Downloads and caches packages served as release tarballs. /// /// Packages are stored as `{name}-{version}.tar.gz` release assets under the tag @@ -205,14 +234,25 @@ pub struct RheoPackages { impl RheoPackages { /// The built-in `@rheo` backend, serving from the rheo-packages releases. pub fn new(downloader: SystemDownloader) -> Self { - Self::with_source(downloader, ReleasesSource::Base(REGISTRY_URL.to_string())) + let source = ReleasesSource::Base(REGISTRY_URL.to_string()); + let cache = cache_root().map(|d| FsPackages::new(builtin_cache_root(&d))); + Self::with_root(downloader, source, cache) } /// A backend for a namespace configured with `releases = ...`. pub fn with_source(downloader: SystemDownloader, source: ReleasesSource) -> Self { + let cache = cache_root().map(|d| FsPackages::new(releases_cache_root(&d, &source))); + Self::with_root(downloader, source, cache) + } + + fn with_root( + downloader: SystemDownloader, + source: ReleasesSource, + cache: Option, + ) -> Self { Self { source, - cache: cache_root().map(|d| FsPackages::new(d.join("typst/packages"))), + cache, downloader, } } @@ -223,8 +263,9 @@ impl RheoPackages { // is keyed by commit sha for the opposite reason — a branch moves — so // do not "fix" one of these to match the other. // - // `FsPackages` keys its layout `{namespace}/{name}/{version}`, so two - // namespaces backed by different hosts cannot collide here. + // The cache root for a configured namespace is keyed by its source + // (`releases_cache_root`), so two hosts serving one namespace name get + // two directories and cannot collide here. if let Some(cache) = &self.cache && let Some(root) = cache.obtain(spec) { @@ -308,6 +349,31 @@ mod tests { .expect("namespace absent") } + /// Unwraps a `releases = ...` value straight to its `ReleasesSource`. + fn releases_of(releases: &str) -> ReleasesSource { + match parse_source(releases) { + NamespaceSource::Releases(source) => source, + other => panic!("expected a releases source, got {other:?}"), + } + } + + #[test] + fn two_releases_hosts_do_not_share_a_cache_directory() { + let root = Path::new("/cache"); + let mine = releases_cache_root(root, &releases_of("freecomputinglab/rookery")); + let theirs = releases_cache_root(root, &releases_of("someone-else/rookery")); + assert_ne!(mine, theirs); + assert!(mine.starts_with("/cache/rheo/releases")); + } + + #[test] + fn the_built_in_backend_stays_in_the_typst_cache() { + assert_eq!( + builtin_cache_root(Path::new("/cache")), + Path::new("/cache/typst/packages"), + ); + } + /// The built-in base is unchanged, so a project configuring nothing requests /// exactly the URL it always did. #[test]