diff --git a/Cargo.lock b/Cargo.lock index 4f630f50b0..9e3157f96d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2993,12 +2993,14 @@ dependencies = [ "clap", "derive_more", "futures-util", + "indexmap", "miette 7.6.0", "mockito", "pacquet-network", "pipe-trait", "reqwest", "serde", + "serde-saphyr", "serde_json", "sha2", "tempfile", diff --git a/pacquet/tasks/registry-mock/src/pnpm_registry_command.rs b/pacquet/tasks/registry-mock/src/pnpm_registry_command.rs index f987009685..ffb3d811ce 100644 --- a/pacquet/tasks/registry-mock/src/pnpm_registry_command.rs +++ b/pacquet/tasks/registry-mock/src/pnpm_registry_command.rs @@ -83,10 +83,12 @@ pub fn pnpm_registry_command(port: u16) -> Command { eprintln!("info: seeded {seeded} fixture file(s) into runtime storage"); } let mut cmd = Command::new(bin); + // `pnpm-registry` defaults to its bundled verdaccio-shaped config + // (npmjs uplink + `**` proxy rule), which matches what the mock + // needs — no `-c` override required. We only pin the runtime + // bits the bundled config can't know about. cmd.arg("--storage") .arg(runtime_storage()) - .arg("--upstream") - .arg("https://registry.npmjs.org") .arg("--packument-ttl-secs") .arg("31536000") .arg("--listen") diff --git a/registry/crates/pnpm-registry/Cargo.toml b/registry/crates/pnpm-registry/Cargo.toml index 6132922e2a..1eb611764c 100644 --- a/registry/crates/pnpm-registry/Cargo.toml +++ b/registry/crates/pnpm-registry/Cargo.toml @@ -25,9 +25,11 @@ base64 = { workspace = true } clap = { workspace = true } derive_more = { workspace = true } futures-util = { workspace = true } +indexmap = { workspace = true } miette = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } +serde-saphyr = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true } diff --git a/registry/crates/pnpm-registry/config.yaml b/registry/crates/pnpm-registry/config.yaml new file mode 100644 index 0000000000..8363c040c2 --- /dev/null +++ b/registry/crates/pnpm-registry/config.yaml @@ -0,0 +1,73 @@ +# path to a directory with all packages +storage: ./storage +secret: pnpm-registry-mock-secret-key-32 + +auth: + htpasswd: + file: ./htpasswd + # Maximum amount of users allowed to register, defaults to "+inf". + # You can set this to -1 to disable registration. + #max_users: 1000 + +plugins: ../node_modules + +middlewares: + audit: + enabled: true + +# a list of other known repositories we can talk to +uplinks: + npmjs: + url: https://registry.npmjs.org/ + +web: + enable: false + +packages: + '@private/*': + access: $authenticated + publish: $authenticated + + '@pnpm.e2e/needs-auth': + access: $authenticated + publish: $authenticated + + '@*/*': + access: $all + publish: $authenticated + proxy: npmjs + + 'plugin-example': + access: $all + publish: $authenticated + + 'pkg-with-1-dep': + access: $all + publish: $authenticated + + 'dep-of-pkg-with-1-dep': + access: $all + publish: $authenticated + + 'foobar': + access: $all + publish: $authenticated + + 'alpha': + access: $all + publish: $authenticated + + 'deprecated': + access: $all + publish: $authenticated + + '**': + access: $all + publish: $authenticated + proxy: npmjs + +# log settings +logs: + - type: stdout + format: pretty + level: error diff --git a/registry/crates/pnpm-registry/src/config.rs b/registry/crates/pnpm-registry/src/config.rs index 4c01a96345..ed07acc5b8 100644 --- a/registry/crates/pnpm-registry/src/config.rs +++ b/registry/crates/pnpm-registry/src/config.rs @@ -1,35 +1,53 @@ use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; +use indexmap::IndexMap; +use serde::Deserialize; + use crate::policy::PackagePolicies; +/// The bundled verdaccio-shaped YAML config, mirrored from +/// `@pnpm/registry-mock`'s `registry/config.yaml`. Other crates can +/// pull this in directly when they need pnpm-registry's defaults +/// (uplinks, package routing) without reading a file from disk — +/// e.g. test mocks that want to run with the standard `**` -> `npmjs` +/// routing applied. +pub const DEFAULT_CONFIG_YAML: &str = include_str!("../config.yaml"); + /// Runtime configuration for the pnpm registry server. +/// +/// The persisted (YAML) shape follows verdaccio's `config.yaml` — +/// `storage`, `uplinks`, `packages` — restricted to the subset +/// pnpm-registry implements (no web UI, auth, plugins, or logs +/// routing). +/// +/// Runtime-only fields (`listen`, `public_url`, `packument_ttl`) +/// are set by the binary's CLI flags after the YAML is loaded, +/// matching verdaccio's CLI overrides. #[derive(Debug, Clone)] pub struct Config { /// Address the HTTP server binds to. pub listen: SocketAddr, - /// Upstream registry to proxy and cache from. `None` puts the - /// server in *static* mode: cache misses become `404`, and the - /// storage directory is treated as the authoritative source. - pub upstream: Option, /// URL clients should use to reach this server. Used to rewrite /// `dist.tarball` URLs in served packuments so tarball requests /// flow through this server. pub public_url: String, - /// Directory under which packuments and tarballs live. The layout - /// is Verdaccio's: - /// - /// ```text - /// //package.json - /// //.tgz - /// ``` - /// - /// In proxy mode this doubles as the cache; in static mode it's - /// the source of truth. + /// Directory under which packuments and tarballs live. + /// In proxy mode this doubles as the cache; with no matching + /// `proxy:` rule it is the source of truth. pub storage: PathBuf, + /// Named upstream npm registries. Referenced by name from + /// [`PackageAccess::proxy`]. + pub uplinks: IndexMap, + /// Package routing rules, evaluated in declared order. The first + /// pattern that matches a requested package supplies its + /// uplink (via `proxy`). Patterns without a `proxy` make the + /// package storage-only (effectively static for that pattern). + pub packages: IndexMap, /// How long a cached packument is considered fresh before it is - /// re-fetched from the upstream. Ignored in static mode. + /// re-fetched from the resolved uplink. Ignored when no uplink + /// matches. pub packument_ttl: Duration, /// Per-package access and publish rules. Defaults to /// [`PackagePolicies::registry_mock_defaults`] so a vanilla @@ -39,31 +57,418 @@ pub struct Config { pub policies: PackagePolicies, } +/// Verdaccio-shaped uplink declaration. Only `url` is honored — +/// other fields verdaccio supports (auth headers, timeouts, agent +/// options) are not implemented yet. +#[derive(Debug, Clone, Deserialize)] +pub struct UplinkConfig { + pub url: String, +} + +/// Per-package routing rules. `access` and `publish` are parsed for +/// config compatibility but ignored (pnpm-registry is read-only and +/// has no auth). `proxy` selects the [`UplinkConfig`] by name. +#[derive(Debug, Default, Clone, Deserialize)] +pub struct PackageAccess { + pub access: Option, + pub publish: Option, + pub unpublish: Option, + pub proxy: Option, +} + +/// Disk shape of the YAML file. Fields verdaccio supports but +/// pnpm-registry doesn't (`auth`, `web`, `plugins`, `middlewares`, +/// `logs`, `secret`) are accepted and silently dropped via +/// `#[serde(default)]` on the fields we care about plus +/// `#[serde(deny_unknown_fields)]` *not* being set — so the same +/// `config.yaml` works for both servers. +#[derive(Debug, Deserialize)] +struct ConfigFile { + #[serde(default = "default_storage_string")] + storage: String, + #[serde(default)] + uplinks: IndexMap, + #[serde(default)] + packages: IndexMap, +} + impl Config { - /// Build a proxy-mode config with the default npm upstream. + /// Default `listen` when one isn't supplied by the caller. + pub const DEFAULT_LISTEN: &'static str = "127.0.0.1:4873"; + /// Default packument TTL — five minutes, matching the historical + /// proxy-mode default. + pub const DEFAULT_PACKUMENT_TTL: Duration = Duration::from_secs(5 * 60); + + /// Build a proxy-mode config with the default npm upstream: a single + /// `npmjs` uplink plus a `**` package rule that routes everything + /// through it. Kept for callers that don't use YAML config. pub fn proxy(listen: SocketAddr, storage: PathBuf) -> Self { - let public_url = format!("http://{listen}"); + let mut uplinks = IndexMap::new(); + uplinks.insert( + "npmjs".to_string(), + UplinkConfig { url: "https://registry.npmjs.org".to_string() }, + ); + let mut packages = IndexMap::new(); + packages.insert( + "**".to_string(), + PackageAccess { proxy: Some("npmjs".to_string()), ..Default::default() }, + ); Self { listen, - upstream: Some("https://registry.npmjs.org".to_string()), - public_url, + public_url: format!("http://{listen}"), storage, - packument_ttl: Duration::from_secs(5 * 60), + uplinks, + packages, + packument_ttl: Self::DEFAULT_PACKUMENT_TTL, policies: PackagePolicies::registry_mock_defaults(), } } - /// Build a static-mode config that serves `storage` verbatim, - /// never reaching out to a remote. + /// Build a static-mode config that serves `storage` verbatim: + /// no uplinks declared, so no package rule resolves to one. pub fn static_serve(listen: SocketAddr, storage: PathBuf) -> Self { - let public_url = format!("http://{listen}"); Self { listen, - upstream: None, - public_url, + public_url: format!("http://{listen}"), storage, - packument_ttl: Duration::from_secs(5 * 60), + uplinks: IndexMap::new(), + packages: IndexMap::new(), + packument_ttl: Self::DEFAULT_PACKUMENT_TTL, policies: PackagePolicies::registry_mock_defaults(), } } + + /// Load YAML from `path` and merge it with runtime values + /// supplied by the binary. `listen` and `public_url` are not + /// represented in verdaccio's YAML and must be provided here; + /// `packument_ttl` defaults to [`Self::DEFAULT_PACKUMENT_TTL`]. + /// + /// `storage` from the YAML is resolved relative to the config + /// file's parent directory when not absolute — same convention + /// verdaccio uses for `./storage`. + pub fn from_yaml( + path: &Path, + listen: SocketAddr, + public_url: Option, + ) -> std::io::Result { + let raw = std::fs::read_to_string(path)?; + let base = path.parent().unwrap_or_else(|| Path::new(".")); + Self::from_yaml_str(&raw, base, listen, public_url).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("parse {}: {err}", path.display()), + ) + }) + } + + /// Parse [`DEFAULT_CONFIG_YAML`] (the verdaccio-shaped YAML + /// bundled into the binary) and merge it with the given runtime + /// values. Relative `storage:` paths in the bundled YAML are + /// resolved against `base_dir` — pass `Path::new(".")` to mirror + /// verdaccio's CWD-relative behaviour, or an absolute path when + /// the caller knows where the storage should live. + /// + /// Panics if the bundled YAML fails to parse — that would be a + /// build-time bug since the file is compiled in. + pub fn from_default_yaml( + base_dir: &Path, + listen: SocketAddr, + public_url: Option, + ) -> Self { + Self::from_yaml_str(DEFAULT_CONFIG_YAML, base_dir, listen, public_url) + .expect("bundled DEFAULT_CONFIG_YAML must always parse") + } + + fn from_yaml_str( + raw: &str, + base_dir: &Path, + listen: SocketAddr, + public_url: Option, + ) -> Result { + let file: ConfigFile = serde_saphyr::from_str(raw)?; + let storage = resolve_relative(&file.storage, base_dir); + let public_url = public_url.unwrap_or_else(|| format!("http://{listen}")); + Ok(Self { + listen, + public_url, + storage, + uplinks: file.uplinks, + packages: file.packages, + packument_ttl: Self::DEFAULT_PACKUMENT_TTL, + // Policies could be derived from `packages[*].{access,publish}` + // here, but the bundled `config.yaml` already matches the + // `registry_mock_defaults` set verbatim, and a YAML-driven + // policy wiring is out of scope for this rebase. Keep the + // hard-coded defaults — same as `proxy` / `static_serve`. + policies: PackagePolicies::registry_mock_defaults(), + }) + } + + /// Find the uplink for `package_name` by walking [`Self::packages`] + /// in declared order: the first pattern that matches is the rule + /// that applies. If that rule has no `proxy:`, the package is + /// storage-only and this returns `None` — matching verdaccio's + /// first-match-wins semantics. The returned tuple's first element + /// is the uplink *name* (the key in [`Self::uplinks`]); callers + /// that have pre-built per-uplink state can use it as an index. + pub fn resolve_uplink(&self, package_name: &str) -> Option<(&str, &UplinkConfig)> { + let access = self.packages.iter().find_map(|(pattern, access)| { + pattern_matches(pattern, package_name).then_some(access) + })?; + let proxy_name = access.proxy.as_deref()?; + self.uplinks.get_key_value(proxy_name).map(|(k, v)| (k.as_str(), v)) + } +} + +/// Resolve a (possibly relative) storage path against `base_dir`. +/// Verdaccio's `./storage` convention. +fn resolve_relative(raw: &str, base_dir: &Path) -> PathBuf { + let path = PathBuf::from(raw); + if path.is_absolute() { + return path; + } + base_dir.join(path) +} + +fn default_storage_string() -> String { + "./storage".to_string() +} + +/// Match a verdaccio package pattern against a package name. +/// Supports: +/// - `**` — matches everything +/// - `@*/*` — matches all scoped packages +/// - `@scope/*` — matches every package in a specific scope +/// - exact name — literal match +fn pattern_matches(pattern: &str, name: &str) -> bool { + if pattern == "**" { + return true; + } + if pattern == "@*/*" { + return name.starts_with('@'); + } + if let Some(scope) = pattern.strip_suffix("/*").and_then(|p| p.strip_prefix('@')) { + let Some(name_scope) = name.strip_prefix('@').and_then(|n| n.split('/').next()) else { + return false; + }; + return name_scope == scope; + } + pattern == name +} + +#[cfg(test)] +mod tests { + use super::{Config, DEFAULT_CONFIG_YAML, pattern_matches, resolve_relative}; + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + use std::path::{Path, PathBuf}; + + fn listen() -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)) + } + + #[test] + fn pattern_double_star_matches_anything() { + assert!(pattern_matches("**", "lodash")); + assert!(pattern_matches("**", "@foo/bar")); + assert!(pattern_matches("**", "")); + } + + #[test] + fn pattern_any_scope_matches_only_scoped() { + assert!(pattern_matches("@*/*", "@foo/bar")); + assert!(pattern_matches("@*/*", "@pnpm.e2e/needs-auth")); + assert!(!pattern_matches("@*/*", "lodash")); + } + + #[test] + fn pattern_specific_scope_matches_only_that_scope() { + assert!(pattern_matches("@private/*", "@private/anything")); + assert!(!pattern_matches("@private/*", "@public/anything")); + assert!(!pattern_matches("@private/*", "private")); + } + + #[test] + fn pattern_exact_match() { + assert!(pattern_matches("foobar", "foobar")); + assert!(!pattern_matches("foobar", "foobaz")); + assert!(!pattern_matches("foobar", "@scope/foobar")); + } + + #[test] + fn resolve_relative_passes_absolute_paths_through() { + let absolute = PathBuf::from("/tmp/storage"); + assert_eq!(resolve_relative("/tmp/storage", Path::new("/anywhere")), absolute); + } + + #[test] + fn resolve_relative_joins_relative_paths_to_base() { + assert_eq!( + resolve_relative("./storage", Path::new("/etc/pnpr")), + PathBuf::from("/etc/pnpr/./storage"), + ); + } + + #[test] + fn proxy_constructor_routes_everything_through_npmjs() { + let config = Config::proxy(listen(), PathBuf::from("/tmp")); + let (name, uplink) = config.resolve_uplink("anything").expect("** rule matches"); + assert_eq!(name, "npmjs"); + assert_eq!(uplink.url, "https://registry.npmjs.org"); + } + + #[test] + fn static_constructor_has_no_uplinks() { + let config = Config::static_serve(listen(), PathBuf::from("/tmp")); + assert!(config.uplinks.is_empty()); + assert!(config.packages.is_empty()); + assert!(config.resolve_uplink("anything").is_none()); + } + + #[test] + fn from_default_yaml_parses_bundled_file() { + let config = Config::from_default_yaml(Path::new("/tmp"), listen(), None); + assert!(config.uplinks.contains_key("npmjs")); + assert_eq!(config.uplinks["npmjs"].url, "https://registry.npmjs.org/"); + // The bundled file routes the catch-all through npmjs. + let (name, _) = config.resolve_uplink("lodash").expect("** -> npmjs in defaults"); + assert_eq!(name, "npmjs"); + } + + #[test] + fn default_yaml_const_matches_what_from_default_parses() { + // Sanity check: the const is non-empty and round-trips through + // the parser without panicking — i.e. `from_default_yaml`'s + // `expect(...)` is not a tripwire under future edits. + assert!(!DEFAULT_CONFIG_YAML.is_empty()); + let _ = Config::from_default_yaml(Path::new("."), listen(), None); + } + + #[test] + fn from_yaml_str_storage_is_resolved_relative_to_base_dir() { + let yaml = "storage: ./store\nuplinks: {}\npackages: {}\n"; + let config = Config::from_yaml_str(yaml, Path::new("/etc/pnpr"), listen(), None).unwrap(); + assert_eq!(config.storage, PathBuf::from("/etc/pnpr/./store")); + } + + #[test] + fn from_yaml_str_absolute_storage_is_left_alone() { + let yaml = "storage: /var/lib/pnpr\nuplinks: {}\npackages: {}\n"; + let config = Config::from_yaml_str(yaml, Path::new("/etc/pnpr"), listen(), None).unwrap(); + assert_eq!(config.storage, PathBuf::from("/var/lib/pnpr")); + } + + #[test] + fn from_yaml_str_ignores_unknown_sections() { + // Sections we don't implement (`auth`, `web`, `plugins`, etc.) + // must parse silently so existing config files work untouched. + let yaml = "\ +storage: ./s +auth: + htpasswd: + file: ./htpasswd +web: + enable: false +plugins: ../node_modules +secret: hunter2 +uplinks: + npmjs: + url: https://registry.npmjs.org/ +packages: + '**': + access: $all + proxy: npmjs +"; + let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); + let (name, uplink) = config.resolve_uplink("anything").expect("** -> npmjs"); + assert_eq!(name, "npmjs"); + assert_eq!(uplink.url, "https://registry.npmjs.org/"); + } + + #[test] + fn from_yaml_str_packages_evaluated_in_declared_order() { + // First match wins: `@private/*` should resolve before `**` + // even though both are declared. + let yaml = "\ +storage: ./s +uplinks: + mirror: { url: https://mirror.example/ } + npmjs: { url: https://registry.npmjs.org/ } +packages: + '@private/*': + proxy: mirror + '**': + proxy: npmjs +"; + let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); + assert_eq!(config.resolve_uplink("@private/foo").unwrap().0, "mirror"); + assert_eq!(config.resolve_uplink("lodash").unwrap().0, "npmjs"); + } + + #[test] + fn from_yaml_str_package_without_proxy_does_not_resolve_an_uplink() { + // Verdaccio first-match-wins: a pattern entry that matches but + // has no `proxy:` is storage-only — resolution stops there and + // returns None instead of falling through to a later catch-all. + let yaml = "\ +storage: ./s +uplinks: + npmjs: { url: https://registry.npmjs.org/ } +packages: + '@private/*': + access: $authenticated + '**': + proxy: npmjs +"; + let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); + assert!(config.resolve_uplink("@private/foo").is_none()); + // Unrelated names still fall through to `**` -> `npmjs`. + assert_eq!(config.resolve_uplink("lodash").unwrap().0, "npmjs"); + } + + #[test] + fn from_yaml_str_public_url_defaults_to_listen_when_none_passed() { + let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n"; + let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); + assert_eq!(config.public_url, format!("http://{}", listen())); + } + + #[test] + fn from_yaml_str_public_url_override_wins() { + let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n"; + let config = Config::from_yaml_str( + yaml, + Path::new("/x"), + listen(), + Some("http://override.test".to_string()), + ) + .unwrap(); + assert_eq!(config.public_url, "http://override.test"); + } + + #[test] + fn from_yaml_path_round_trips_through_tempfile() { + // Exercise the file-reading path (not just the in-memory + // `from_yaml_str` shortcut). Confirms relative `storage:` is + // resolved against the *config file's* parent dir. + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("registry.yml"); + std::fs::write(&config_path, "storage: ./store\nuplinks: {}\npackages: {}\n").unwrap(); + let config = Config::from_yaml(&config_path, listen(), None).unwrap(); + assert_eq!(config.storage, dir.path().join("./store")); + } + + #[test] + fn from_yaml_path_surfaces_parse_errors_as_invalid_data() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("broken.yml"); + std::fs::write(&config_path, "storage: [not, a, string\n").unwrap(); + let err = Config::from_yaml(&config_path, listen(), None).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn from_yaml_path_propagates_missing_file_errors() { + let err = Config::from_yaml(Path::new("/no/such/file.yml"), listen(), None).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } } diff --git a/registry/crates/pnpm-registry/src/lib.rs b/registry/crates/pnpm-registry/src/lib.rs index 0c99d81477..0a99389401 100644 --- a/registry/crates/pnpm-registry/src/lib.rs +++ b/registry/crates/pnpm-registry/src/lib.rs @@ -19,7 +19,7 @@ mod server; mod streaming; mod upstream; -pub use config::Config; +pub use config::{Config, DEFAULT_CONFIG_YAML, PackageAccess, UplinkConfig}; pub use error::{RegistryError, Result}; pub use policy::{AccessRule, PackagePolicies, PackagePolicy}; pub use server::{router, serve}; diff --git a/registry/crates/pnpm-registry/src/main.rs b/registry/crates/pnpm-registry/src/main.rs index 8739091726..99b97d02a9 100644 --- a/registry/crates/pnpm-registry/src/main.rs +++ b/registry/crates/pnpm-registry/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use clap::Parser; @@ -10,35 +10,31 @@ use pnpm_registry::{Config, serve}; #[derive(Debug, Parser)] #[command(name = "pnpm-registry", version, about = "pnpm-compatible npm registry server")] struct Args { + /// Path to a verdaccio-shaped YAML config (storage, uplinks, + /// packages). When omitted, the bundled default config is used. + #[arg(short = 'c', long)] + config: Option, + /// Address to bind to. #[arg(long, default_value = "127.0.0.1:4873")] listen: SocketAddr, - /// Upstream npm registry to proxy and cache from. Ignored when - /// `--static` is set. - #[arg(long, default_value = "https://registry.npmjs.org")] - upstream: String, - - /// Storage directory (verdaccio-shaped). In proxy mode this - /// doubles as the cache; in static mode it's the source of truth. - #[arg(long, default_value = "./storage")] - storage: PathBuf, - - /// Serve `--storage` verbatim with no upstream — useful for - /// running against a pre-populated verdaccio store (e.g. - /// `@pnpm/registry-mock`'s `registry/storage-cache`). - #[arg(long = "static")] - static_serve: bool, + /// Override the storage path from the loaded config (bundled or + /// `-c`). Useful for tests and benchmarks that want their own + /// cache directory without writing a custom YAML. + #[arg(long)] + storage: Option, /// URL clients should use to reach this server. Used when - /// rewriting `dist.tarball` URLs in served packuments. + /// rewriting `dist.tarball` URLs in served packuments. Defaults + /// to `http://`. #[arg(long)] public_url: Option, /// Seconds before a cached packument is considered stale and - /// refetched. Ignored in static mode. - #[arg(long, default_value_t = 300)] - packument_ttl_secs: u64, + /// refetched. When omitted, the loaded config's value wins. + #[arg(long)] + packument_ttl_secs: Option, } #[tokio::main] @@ -50,17 +46,21 @@ async fn main() -> miette::Result<()> { .init(); let args = Args::parse(); - let mut config = if args.static_serve { - Config::static_serve(args.listen, args.storage) - } else { - let mut config = Config::proxy(args.listen, args.storage); - config.upstream = Some(args.upstream); - config + let mut config = match args.config.as_deref() { + Some(path) => { + Config::from_yaml(path, args.listen, args.public_url.clone()).map_err(|err| { + let path = path.display(); + miette::miette!("load {path}: {err}") + })? + } + None => Config::from_default_yaml(Path::new("."), args.listen, args.public_url.clone()), }; - if let Some(url) = args.public_url { - config.public_url = url; + if let Some(storage) = args.storage { + config.storage = storage; + } + if let Some(ttl_secs) = args.packument_ttl_secs { + config.packument_ttl = Duration::from_secs(ttl_secs); } - config.packument_ttl = Duration::from_secs(args.packument_ttl_secs); serve(config).await.map_err(|err| miette::miette!("{err}")) } diff --git a/registry/crates/pnpm-registry/src/server.rs b/registry/crates/pnpm-registry/src/server.rs index a00e0b5750..ef1f12ff4f 100644 --- a/registry/crates/pnpm-registry/src/server.rs +++ b/registry/crates/pnpm-registry/src/server.rs @@ -6,6 +6,7 @@ use axum::extract::{DefaultBodyLimit, OriginalUri, Path, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get}; +use indexmap::IndexMap; use serde_json::{Value, json}; use tower_http::trace::TraceLayer; @@ -43,7 +44,10 @@ struct AppState { struct AppInner { cache: Cache, - upstream: Option, + /// One [`Upstream`] per declared uplink, keyed by the same name + /// used in [`Config::uplinks`]. Built once at router construction + /// time so each request avoids re-allocating a `ThrottledClient`. + upstreams: IndexMap, config: Config, users: UserStore, tokens: TokenStore, @@ -59,11 +63,15 @@ struct AppInner { /// the `@` prefix and the literal-`-` segment. pub fn router(config: Config) -> Router { let cache = Cache::new(config.storage.clone()); - let upstream = config.upstream.as_ref().map(|base| Upstream::new(base.clone())); + let upstreams: IndexMap = config + .uplinks + .iter() + .map(|(name, uplink)| (name.clone(), Upstream::new(uplink.url.clone()))) + .collect(); let state = AppState { inner: Arc::new(AppInner { cache, - upstream, + upstreams, config, users: UserStore::new(), tokens: TokenStore::new(), @@ -439,7 +447,7 @@ async fn serve_tarball( } } - let Some(upstream) = state.inner.upstream.as_ref() else { + let Some(upstream) = resolve_upstream(state, &name) else { return not_found(); }; @@ -707,7 +715,7 @@ async fn serve_search(state: &AppState, headers: &HeaderMap, query_string: &str) /// on disk, so subsequent searches find it without another upstream /// hit. async fn augment_search_with_upstream(state: &AppState, query: &str, body: &mut Value) { - if state.inner.upstream.is_none() { + if state.inner.upstreams.is_empty() { return; } let Ok(name) = PackageName::parse(query) else { @@ -1055,6 +1063,16 @@ fn wants_abbreviated(headers: &HeaderMap) -> bool { .is_some_and(|accept| accept.contains(ABBREVIATED_CONTENT_TYPE)) } +/// Resolve which prebuilt [`Upstream`] should serve `package`, by +/// walking the verdaccio-style `packages` rules in declared order and +/// looking up the resolved uplink name in [`AppInner::upstreams`]. +/// Returns `None` when no rule with a `proxy:` field matches the +/// package, leaving the request to fall through to a not-found. +fn resolve_upstream<'a>(state: &'a AppState, package: &PackageName) -> Option<&'a Upstream> { + let (uplink_name, _) = state.inner.config.resolve_uplink(package.as_str())?; + state.inner.upstreams.get(uplink_name) +} + /// Result of loading the packument for a package — either bytes (raw, /// from cache or upstream), a definite not-found, or a real error. enum PackumentLoad { @@ -1067,7 +1085,7 @@ enum PackumentLoad { /// the cache when configured. The same logic backs both the packument /// and the version-manifest endpoints. async fn load_packument_bytes(state: &AppState, name: &PackageName) -> PackumentLoad { - let Some(upstream) = state.inner.upstream.as_ref() else { + let Some(upstream) = resolve_upstream(state, name) else { return match state.inner.cache.read_packument_any_age(name).await { Ok(Some(bytes)) => PackumentLoad::Ok(bytes), Ok(None) => PackumentLoad::NotFound, diff --git a/registry/crates/pnpm-registry/tests/auth_publish.rs b/registry/crates/pnpm-registry/tests/auth_publish.rs index 375a92c164..afc22b73f2 100644 --- a/registry/crates/pnpm-registry/tests/auth_publish.rs +++ b/registry/crates/pnpm-registry/tests/auth_publish.rs @@ -675,7 +675,7 @@ async fn search_augments_with_upstream_when_local_misses_exact_name() { let tmp = TempDir::new().unwrap(); let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); let mut config = Config::proxy(listen, tmp.path().to_path_buf()); - config.upstream = Some(upstream.url()); + config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.url(); config.public_url = "http://example.test".to_string(); config.packument_ttl = Duration::from_secs(60); let app = router(config); @@ -719,7 +719,7 @@ async fn search_augment_skips_when_upstream_404s() { let tmp = TempDir::new().unwrap(); let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); let mut config = Config::proxy(listen, tmp.path().to_path_buf()); - config.upstream = Some(upstream.url()); + config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.url(); config.public_url = "http://example.test".to_string(); config.packument_ttl = Duration::from_secs(60); let app = router(config); diff --git a/registry/crates/pnpm-registry/tests/server.rs b/registry/crates/pnpm-registry/tests/server.rs index 66ae47879d..5ab297655f 100644 --- a/registry/crates/pnpm-registry/tests/server.rs +++ b/registry/crates/pnpm-registry/tests/server.rs @@ -14,7 +14,7 @@ use pnpm_registry::{Config, router}; fn config_for(upstream: &str, storage: std::path::PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::proxy(listen, storage); - config.upstream = Some(upstream.to_string()); + config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.to_string(); config.public_url = "http://example.test".to_string(); config.packument_ttl = Duration::from_secs(60); config