Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
8 changes: 8 additions & 0 deletions crates/core/src/config/packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 1 addition & 15 deletions crates/core/src/packages/git.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;

Expand All @@ -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.
Expand Down Expand Up @@ -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::*;
Expand Down
74 changes: 70 additions & 4 deletions crates/core/src/packages/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -84,6 +85,20 @@ fn package_dir(root: &Path, subdir: &str, spec: &PackageSpec) -> Result<PathBuf,
if dir.exists() { Ok(dir) } else { Err(dir) }
}

/// A filesystem-safe stand-in for a source's URL — a git remote or a releases
/// host. 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 re-fetch, not a wrong
/// answer, since what sits under a slug names its own content — a commit sha, or
/// an immutable `<name>-<version>` 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
Expand Down Expand Up @@ -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
Expand All @@ -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<FsPackages>,
) -> Self {
Self {
source,
cache: cache_root().map(|d| FsPackages::new(d.join("typst/packages"))),
cache,
downloader,
}
}
Expand All @@ -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)
{
Expand Down Expand Up @@ -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]
Expand Down
Loading