From 23a3f8917a5008fa070f205fa736b1a65b7bcb8b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sun, 25 Oct 2026 17:45:36 +0100 Subject: [PATCH] feat(pnpr): rewrite dist.tarball URLs on .tgz registries to @ (#17340) Rewrite served `dist.tarball` URLs on `.tgz` registries to `{public_url}/{pkg}@{version}` for every addressable version (matching tar registries), so `serverType: pnpr` drops canonical tarball URLs from `pnpm-lock.yaml` on both `.tgz` and tar registries while keeping `{public_url}/{pkg}/-/{filename}` served for existing lockfiles and older clients. - Emit `{public_url}/{pkg}@{version}` in `pnpr-upstream` for all addressable versions and normalize `@{version}` tarball URLs to `-.tgz` in `tarball_basename_for_package` for chained `pnpr` instances. - Accept round-tripped `{public_url}/{pkg}@{version}` URLs in `check_tarball_immutable` during `PUT /:pkg/-rev/:rev` and restore the stored `.tgz` URL on disk. - Route `DELETE /:pkg@:version/-rev/:rev` and `DELETE /@:scope/:pkg@:version/-rev/:rev` to `unpublish_version_tarball` while rejecting reserved `+r` revision suffixes. - Fall back to the version parsed from a canonical `-.tgz` request in `expected_tarball_dist` so chained `pnpr` proxies resolve non-canonical upstream filenames, and preserve `@` routes in `TarballRouter`. Related to pnpm/tasks#208. --- .changeset/pnpr-tarball-urls.md | 5 + .../cli/tests/suite/config_dependencies.rs | 4 +- .../cli/tests/suite/custom_resolvers.rs | 17 ++- .../cli/tests/suite/metadata_request_log.rs | 9 +- .../cli/tests/suite/pnpm_compatibility.rs | 20 +++- pnpm/crates/cli/tests/suite/tar_registry.rs | 3 + pnpm/crates/cli/tests/suite/update.rs | 7 +- .../env-installer/src/tests/release_age.rs | 3 +- .../src/install/tests/modules_dir_disabled.rs | 7 ++ .../tests/integration/streaming.rs | 13 ++- pnpm/docs/registries.md | 6 +- .../integrated-benchmark/src/work_env.rs | 2 +- .../src/work_env/server_config.rs | 37 +++++- .../src/work_env/servers.rs | 108 +++++++++++------- pnpr/crates/pnpr/src/resolver/wire.rs | 9 +- .../pnpr/src/resolver/wire/tarball_router.rs | 62 ++++++---- pnpr/crates/pnpr/src/server.rs | 2 +- .../server/package_mutation/immutability.rs | 65 +++++++---- .../package_mutation/immutability/tests.rs | 43 +++++++ .../pnpr/src/server/package_responses.rs | 17 ++- .../pnpr/src/server/routing/npm_handlers.rs | 40 ++++++- .../pnpr/src/server/upstream_integrity.rs | 14 ++- .../src/server/upstream_integrity/tests.rs | 1 + .../pnpr/src/server/upstream_tarballs.rs | 21 +++- .../pnpr/src/server/version_tarballs.rs | 45 +++++--- .../pnpr/src/server/version_tarballs/cache.rs | 56 ++++++--- .../server/version_tarballs/cache/tests.rs | 1 + .../pnpr/tests/auth_publish/authentication.rs | 2 +- .../tests/auth_publish/scoped_publishing.rs | 74 ++++++++++++ pnpr/crates/pnpr/tests/batch_publish.rs | 2 +- pnpr/crates/pnpr/tests/registry_mock.rs | 14 +-- pnpr/crates/pnpr/tests/s3_backend.rs | 2 +- .../pnpr/tests/server/hosted_discovery.rs | 2 +- pnpr/crates/pnpr/tests/server/packuments.rs | 15 +-- .../pnpr/tests/server/registry_routing.rs | 2 +- .../crates/pnpr/tests/server/tarball_proxy.rs | 100 +++++++++++++++- .../pnpr/tests/server/tarball_verification.rs | 12 +- .../pnpr/tests/server/version_tarballs.rs | 4 + pnpr/crates/upstream/src/lib.rs | 38 +++++- pnpr/crates/upstream/src/packument.rs | 93 ++++++++++----- .../upstream/src/tests/packument_rewriting.rs | 63 ++++++---- pnpr/docs/endpoints.md | 7 +- 42 files changed, 802 insertions(+), 245 deletions(-) create mode 100644 .changeset/pnpr-tarball-urls.md diff --git a/.changeset/pnpr-tarball-urls.md b/.changeset/pnpr-tarball-urls.md new file mode 100644 index 0000000000..fdbd76d54f --- /dev/null +++ b/.changeset/pnpr-tarball-urls.md @@ -0,0 +1,5 @@ +--- +"@pnpm/pnpr": patch +--- + +Served `dist.tarball` URLs on `.tgz` registries now use the `@` route. The `/-/` route remains supported for existing lockfiles and older clients. diff --git a/pnpm/crates/cli/tests/suite/config_dependencies.rs b/pnpm/crates/cli/tests/suite/config_dependencies.rs index 6d824b1b2e..61921173bc 100644 --- a/pnpm/crates/cli/tests/suite/config_dependencies.rs +++ b/pnpm/crates/cli/tests/suite/config_dependencies.rs @@ -18,7 +18,7 @@ use std::{ }; fn pacquet_at(workspace: &Path) -> Command { - Command::cargo_bin("pnpm").expect("find the pnpm binary").with_current_dir(workspace) + _utils::pacquet_in(workspace).with_env("PNPM_CONFIG_NPMRC_AUTH_FILE", workspace.join(".npmrc")) } /// `pacquet install` resolves the `configDependencies` declared in @@ -525,7 +525,7 @@ fn add_config_keeps_an_integrity_pin() { let integrity = lockfile .lines() .find_map(|line| line.trim().strip_prefix("resolution: {integrity: ")) - .and_then(|rest| rest.strip_suffix('}')) + .and_then(|rest| rest.split([',', '}']).next()) .expect("the lockfile records an integrity"); fs::remove_file(workspace.join("pnpm-lock.yaml")).expect("remove lockfile"); let yaml_path = workspace.join("pnpm-workspace.yaml"); diff --git a/pnpm/crates/cli/tests/suite/custom_resolvers.rs b/pnpm/crates/cli/tests/suite/custom_resolvers.rs index 7e3224f99b..c27af5822d 100644 --- a/pnpm/crates/cli/tests/suite/custom_resolvers.rs +++ b/pnpm/crates/cli/tests/suite/custom_resolvers.rs @@ -11,6 +11,7 @@ use pnpm_testing_utils::{ registry::TestRegistry, }; use std::{ + fmt::Write as _, fs, path::{Path, PathBuf}, process::Command, @@ -30,6 +31,15 @@ fn write_manifest(workspace: &Path) { fs::write(workspace.join("package.json"), manifest.to_string()).expect("write package.json"); } +fn declare_pnpr_registry(workspace: &Path, registry_url: &str) { + let yaml_path = workspace.join("pnpm-workspace.yaml"); + let mut workspace_yaml = fs::read_to_string(&yaml_path).expect("read workspace yaml"); + write!(workspace_yaml, "\nregistries:\n '{registry_url}':\n serverType: pnpr\n").expect( + "append registry declaration", + ); + fs::write(&yaml_path, workspace_yaml).expect("write workspace yaml"); +} + /// A resolver that claims `@pnpm.e2e/dep-of-pkg-with-1-dep` and resolves /// it to `100.1.0` — a version the built-in npm resolver would never pick /// for the exact `100.0.0` specifier, so its presence in the result @@ -267,6 +277,7 @@ fn custom_resolver_receives_current_pkg_on_subsequent_installs() { let AddMockedRegistry { mock_instance, .. } = npmrc_info; write_manifest(&workspace); + declare_pnpr_registry(&workspace, mock_instance.url()); // First install: the resolver claims nothing, so the npm resolver // pins 100.0.0 and the lockfile compacts it to `{integrity}`. fs::write( @@ -322,10 +333,7 @@ module.exports = { // the tarball URL re-derived from the registry, like pnpm's // `pkgSnapshotToResolution`. let tarball = current_pkg["resolution"]["tarball"].as_str().expect("derived tarball URL"); - assert!( - tarball.ends_with("/@pnpm.e2e/dep-of-pkg-with-1-dep/-/dep-of-pkg-with-1-dep-100.0.0.tgz"), - "got: {tarball}", - ); + assert!(tarball.ends_with("/@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0"), "got: {tarball}"); assert!( current_pkg["resolution"]["integrity"].is_string(), "the recorded integrity carries over: {current_pkg}", @@ -443,6 +451,7 @@ fn custom_typed_resolver_without_manifest_installs_dependencies() { fs::write(workspace.join("vendor/package.tgz"), &body).unwrap(); fs::write(workspace.join("package.json"), r#"{"dependencies":{"custom-typed":"1.0.0"}}"#) .unwrap(); + declare_pnpr_registry(&workspace, mock_instance.url()); let integrity = pnpm_testing_utils::fixtures::sha512_integrity(&body); let resolution = serde_json::json!({ "type": "custom:vendored", "name": "custom-typed", "version": "1.0.0", diff --git a/pnpm/crates/cli/tests/suite/metadata_request_log.rs b/pnpm/crates/cli/tests/suite/metadata_request_log.rs index 0caa9d5a6a..e74b4b448e 100644 --- a/pnpm/crates/cli/tests/suite/metadata_request_log.rs +++ b/pnpm/crates/cli/tests/suite/metadata_request_log.rs @@ -15,9 +15,8 @@ use std::{collections::HashMap, fs, io}; const CASE_TABLE: &str = include_str!("../../../../../pnpr/.fixtures/request-logs/metadata-three-installs.json"); -const PKG_TARBALL: &str = "GET /@pnpm.e2e/pkg-with-1-dep/-/pkg-with-1-dep-100.1.0.tgz 200"; -const DEP_TARBALL: &str = - "GET /@pnpm.e2e/dep-of-pkg-with-1-dep/-/dep-of-pkg-with-1-dep-100.1.0.tgz 200"; +const PKG_TARBALL: &str = "GET /@pnpm.e2e/pkg-with-1-dep@100.1.0 200"; +const DEP_TARBALL: &str = "GET /@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0 200"; /// Install 1 also downloads both tarballs, which the store keeps for installs /// 2 and 3. The table leaves tarballs out: in pnpm v11's test they bypass the @@ -138,6 +137,10 @@ fn remove_if_present(result: io::Result<()>) { fn is_tarball(request: &RecordedRequest) -> bool { request.path.ends_with(".tgz") + || request.path + .strip_prefix('/') + .and_then(|path| path.strip_prefix('@').or(Some(path))) + .is_some_and(|path| path.contains('@')) } fn document(request: &RecordedRequest) -> &'static str { diff --git a/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs b/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs index fa9fd82c35..77c2992b19 100644 --- a/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs +++ b/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs @@ -867,6 +867,18 @@ fn same_lockfiles_modules_and_tree_hoisted_and_filtered() { } } +fn is_tarball_request_path(path: &str) -> bool { + path.ends_with(".tgz") + || path + .strip_prefix('/') + .and_then(|rest| { + rest.strip_prefix('@') + .unwrap_or(rest) + .split_once('@') + }) + .is_some() +} + fn format_recorded_request(req: RecordedRequest) -> String { let RecordedRequest { method, @@ -878,7 +890,7 @@ fn format_recorded_request(req: RecordedRequest) -> String { .. } = req; let path = path.strip_prefix("/npm").unwrap_or(&path); - if path.ends_with(".tgz") { + if is_tarball_request_path(path) { return format!("{method} {path} {status}"); } let is_abbreviated = accept @@ -955,7 +967,11 @@ fn same_registry_request_logs_across_install_modes() { ); let warm_reresolve_tarballs: Vec<&String> = pacquet_logs[3] .iter() - .filter(|line| line.contains(".tgz")) + .filter(|line| { + line.split_whitespace() + .nth(1) + .is_some_and(is_tarball_request_path) + }) .collect(); assert_eq!( warm_reresolve_tarballs, diff --git a/pnpm/crates/cli/tests/suite/tar_registry.rs b/pnpm/crates/cli/tests/suite/tar_registry.rs index d436dffd48..4aab63135e 100644 --- a/pnpm/crates/cli/tests/suite/tar_registry.rs +++ b/pnpm/crates/cli/tests/suite/tar_registry.rs @@ -178,11 +178,14 @@ fn switching_to_a_tar_registry_needs_update_checksums() { npmrc_info, .. } = env; + let initial_workspace_yaml = fs::read_to_string(workspace.join("pnpm-workspace.yaml")).unwrap(); + use_tar_registry(&workspace, npmrc_info.mock_instance.url(), Some("pnpr")); pacquet .with_arg("install") .assert() .success(); + fs::write(workspace.join("pnpm-workspace.yaml"), initial_workspace_yaml).unwrap(); use_tar_registry(&workspace, &tar_registry, Some("pnpr")); fs::remove_dir_all(workspace.join("node_modules")).unwrap(); fs::remove_dir_all(&npmrc_info.store_dir).unwrap(); diff --git a/pnpm/crates/cli/tests/suite/update.rs b/pnpm/crates/cli/tests/suite/update.rs index e85509754c..ee2b040185 100644 --- a/pnpm/crates/cli/tests/suite/update.rs +++ b/pnpm/crates/cli/tests/suite/update.rs @@ -860,7 +860,12 @@ fn lock_unserved_version_of_dep(workspace: &Path) { .expect("parse pnpm-lock.yaml"); for section in ["packages", "snapshots"] { let entries = lockfile[section].as_object_mut().expect("the lockfile has the section"); - let entry = entries.remove(&locked_key).expect("the section has the locked entry"); + let mut entry = entries.remove(&locked_key).expect("the section has the locked entry"); + if let Some(resolution) = + entry.get_mut("resolution").and_then(serde_json::Value::as_object_mut) + { + resolution.remove("tarball"); + } entries.insert(unserved_key.clone(), entry); } for snapshot in lockfile["snapshots"] diff --git a/pnpm/crates/env-installer/src/tests/release_age.rs b/pnpm/crates/env-installer/src/tests/release_age.rs index 0aadffbd3f..d5b3e7406e 100644 --- a/pnpm/crates/env-installer/src/tests/release_age.rs +++ b/pnpm/crates/env-installer/src/tests/release_age.rs @@ -65,7 +65,8 @@ async fn resolves_a_pinned_config_dep_newer_than_the_release_age_cutoff() { dbg!(resolution); assert!(matches!( resolution, - LockfileResolution::Registry(registry) if registry.integrity.to_string() == integrity, + LockfileResolution::Tarball(tarball) + if tarball.integrity.as_ref().map(ToString::to_string).as_deref() == Some(integrity.as_str()), )); assert!( root.path() diff --git a/pnpm/crates/package-manager/src/install/tests/modules_dir_disabled.rs b/pnpm/crates/package-manager/src/install/tests/modules_dir_disabled.rs index 5fd19d9c78..7a1364bcfa 100644 --- a/pnpm/crates/package-manager/src/install/tests/modules_dir_disabled.rs +++ b/pnpm/crates/package-manager/src/install/tests/modules_dir_disabled.rs @@ -32,6 +32,13 @@ fn project(registry: &TestRegistry) -> Project { config.install_state_dir = modules_dir.join(".pacquet"); config.modules_dir = modules_dir; config.registry = registry.url().to_string(); + config.registry_options_by_url.insert( + registry.url().to_string(), + pnpm_lockfile::RegistryOptions { + server_type: Some(pnpm_lockfile::RegistryServerType::Pnpr), + ..Default::default() + }, + ); config.enable_modules_dir = false; Project { dir, config: config.leak(), manifest } } diff --git a/pnpm/crates/pnpr-client/tests/integration/streaming.rs b/pnpm/crates/pnpr-client/tests/integration/streaming.rs index 9b2df02736..fa05e3fff9 100644 --- a/pnpm/crates/pnpr-client/tests/integration/streaming.rs +++ b/pnpm/crates/pnpr-client/tests/integration/streaming.rs @@ -59,10 +59,15 @@ async fn unknown_route_keeps_its_upstream_tarball_url() { let registry = TestRegistry::start(); let (pnpr_url, pnpr_auth, _storage) = start_pnpr(registry.url()).await; - let outcome = PnprClient::new(pnpr_url) - .resolve(options(registry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")]))) - .await - .expect("install should succeed"); + let mut opts = options(registry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")])); + opts.routing.registries.insert( + registry.url().to_string(), + pnpm_config::RegistryDeclaration { + server_type: Some(pnpm_lockfile::RegistryServerType::Pnpr), + ..pnpm_config::RegistryDeclaration::default() + }, + ); + let outcome = PnprClient::new(pnpr_url).resolve(opts).await.expect("install should succeed"); let lockfile = serde_json::to_value(&outcome.lockfile).expect("lockfile serializes"); let resolution = &lockfile["packages"]["@foo/no-deps@1.0.0"]["resolution"]; diff --git a/pnpm/docs/registries.md b/pnpm/docs/registries.md index 4a2e4cb3ef..3f150f5460 100644 --- a/pnpm/docs/registries.md +++ b/pnpm/docs/registries.md @@ -77,7 +77,7 @@ Registries do not all lay out tarball URLs the way the npm registry does. pnpm o | *undeclared* | Strict: a tarball URL is omitted only when it is exactly the canonical npm-layout URL (`//-/-.tgz`). This is how every registry but `registry.npmjs.org` is read by default. | | `npm` | The server behaves like `registry.npmjs.org`, which serves a scoped package from the percent-encoded path (`/@acme%2Fwidget/...`) as well as the unencoded one. A faithful mirror or caching proxy of the public registry is this. Built in for `registry.npmjs.org` itself. | | `artifactory` | The server repeats the scope in a scoped package's tarball filename: `@acme/widget/-/@acme/widget-1.0.0.tgz`, where the npm layout strips it (`@acme/widget/-/widget-1.0.0.tgz`). | -| `pnpr` | The server serves a version's tarball at `/@`, such as `@acme/widget@1.0.0`. pnpr uses this layout on a [tar registry](#tar-registries), where the metadata carries no tarball URL, except for a version with [registry revisions](./registry-revisions.md). A pnpr `.tgz` registry advertises the plain npm layout instead, which the undeclared default already handles. | +| `pnpr` | The server serves a version's tarball at `/@`, such as `@acme/widget@1.0.0`. pnpr uses this layout on both `.tgz` registries and [tar registries](#tar-registries), except for a version with [registry revisions](./registry-revisions.md). | For a JFrog Artifactory registry this is the difference between a lockfile that records a host-specific tarball URL for **every scoped package** and one that records none of them: @@ -95,7 +95,7 @@ Values for common servers: | JFrog Artifactory | `artifactory` | | GitLab package registry | `artifactory`. It uses the same layout, repeating the scope in the filename. | | A faithful mirror or caching proxy of the npm registry | `npm` | -| pnpr | `pnpr` for a [tar registry](#tar-registries). Nothing for a `.tgz` registry, which advertises the npm layout. | +| pnpr | `pnpr` | | Verdaccio, Sonatype Nexus, Azure Artifacts | None needed. They serve the exact canonical npm layout, which the strict default already reconstructs. | | GitHub Packages | None. Its download URLs contain a content digest that cannot be derived from the package's identity, so they are kept in the lockfile. | | GitHub Enterprise Server | None. It serves a scoped package only from the percent-encoded path, which the strict default keeps in the lockfile and requests verbatim. | @@ -123,7 +123,7 @@ registries: serverType: pnpr ``` -The server decides which base serves tars. In pnpr's example they are at `/npm/~zstd/`, next to the usual `.tgz` base, but a registry can serve tars from its main base too; see [tar registries in pnpr](/pnpr/configuration#tar-registries). Put `serverType: pnpr` on the tar base only. A `.tgz` base from pnpr advertises the plain npm layout, so leave `serverType` out there. +The server decides which base serves tars. In pnpr's example they are at `/npm/~zstd/`, next to the usual `.tgz` base, but a registry can serve tars from its main base too; see [tar registries in pnpr](/pnpr/configuration#tar-registries). Declare `serverType: pnpr` on any pnpr registry base so pnpm omits canonical `@` tarball URLs from `pnpm-lock.yaml`. pnpm tells a tar from a `.tgz` by the first bytes of the decoded response, so nothing else needs to be declared. The lockfile keeps its `integrity` field, which for packages from a tar registry holds the hash of the tar. diff --git a/pnpm/tasks/integrated-benchmark/src/work_env.rs b/pnpm/tasks/integrated-benchmark/src/work_env.rs index b6a6afb5f0..4874fcb549 100644 --- a/pnpm/tasks/integrated-benchmark/src/work_env.rs +++ b/pnpm/tasks/integrated-benchmark/src/work_env.rs @@ -21,7 +21,7 @@ mod unlocked_deps; mod server_config; use server_config::{ PnprServer, PnprServerPaths, RevisionMockRegistry, append_pnpr_auth_to_npmrc, - cold_mock_config_yaml, distinct_public_route_registries, mint_pnpr_token, seed_pnpr_auth, + cold_revision_mock_command, distinct_public_route_registries, mint_pnpr_token, seed_pnpr_auth, serves_npm_under_base_path, wait_for_pnpr_ready, write_pnpr_benchmark_config, }; diff --git a/pnpm/tasks/integrated-benchmark/src/work_env/server_config.rs b/pnpm/tasks/integrated-benchmark/src/work_env/server_config.rs index 9630468efe..6b64a0308d 100644 --- a/pnpm/tasks/integrated-benchmark/src/work_env/server_config.rs +++ b/pnpm/tasks/integrated-benchmark/src/work_env/server_config.rs @@ -7,7 +7,7 @@ use std::{ io::Write, net::{TcpListener, TcpStream}, path::{Path, PathBuf}, - process::Child, + process::{Child, Command}, thread, time::Duration, }; @@ -42,6 +42,10 @@ pub(super) struct PnprServer { /// The latency proxy fronting this server, when `--pnpr-latency-ms` /// is set. Dropped (stopping the proxy) alongside the server. pub(super) latency_proxy: Option, + /// Warm origin server backing a cold revision mock, spawned from the same + /// revision's `pnpr` binary so each revision speaks its own packument and + /// tarball URL format to its upstream. + pub(super) origin: Option>, } impl Drop for PnprServer { fn drop(&mut self) { @@ -55,6 +59,7 @@ impl Drop for PnprServer { let _ = self.process.kill(); let _ = self.process.wait(); eprintln!("info: Terminated pnpr server pid {pid}"); + self.origin = None; } } /// Poll the pnpr server's TCP port until it accepts a connection. Stays @@ -101,6 +106,36 @@ pub(super) fn cold_mock_config_yaml(storage: &Path, origin: &str) -> String { }; serde_saphyr::to_string(&config).expect("serialize cold mock config") } +/// Use isolated storage, wiped between iterations, with the warm mock as origin. +pub(super) fn cold_revision_mock_command( + revision: &str, + binary: &Path, + bench_dir: &Path, + mock_port: u16, + public_url: &str, + origin_url: &str, +) -> Command { + let cold_storage = bench_dir.join("cold-mock-storage"); + let config_path = bench_dir.join("cold-mock-config.yaml"); + fs::write(&config_path, cold_mock_config_yaml(&cold_storage, origin_url)) + .expect("write cold mock config"); + eprintln!( + "Serving {revision}'s tarballs from a COLD mock built from pnpr@{revision} on 127.0.0.1:{mock_port} (origin {origin_url})...", + ); + let mut command = Command::new(binary); + command + .arg("--config") + .arg(&config_path) + .arg("--storage") + .arg(&cold_storage) + .arg("--listen") + .arg(format!("127.0.0.1:{mock_port}")) + .arg("--public-url") + .arg(public_url) + .arg("--packument-ttl-secs") + .arg("31536000"); + command +} /// The cold mock config, serialized rather than string-formatted so the /// `storage` path and `origin` URL are escaped and the structure can't drift. #[derive(Serialize)] diff --git a/pnpm/tasks/integrated-benchmark/src/work_env/servers.rs b/pnpm/tasks/integrated-benchmark/src/work_env/servers.rs index 03c6a3ee37..9c8b24ff44 100644 --- a/pnpm/tasks/integrated-benchmark/src/work_env/servers.rs +++ b/pnpm/tasks/integrated-benchmark/src/work_env/servers.rs @@ -1,6 +1,6 @@ use super::{ BenchId, PNPR_SERVER_REGISTRY_ENV, PNPR_TARBALL_REWRITE_FROM_ENV, PnprServer, PnprServerPaths, - RevisionMockRegistry, WorkEnv, append_pnpr_auth_to_npmrc, cold_mock_config_yaml, + RevisionMockRegistry, WorkEnv, append_pnpr_auth_to_npmrc, cold_revision_mock_command, distinct_public_route_registries, mint_pnpr_token, seed_pnpr_auth, serves_npm_under_base_path, wait_for_pnpr_ready, write_pnpr_benchmark_config, }; @@ -72,20 +72,8 @@ impl WorkEnv { .expect("create revision mock stderr log"); let cold = self.options.selection.scenario.is_some_and(BenchmarkScenario::cold_pnpr_cache); - let mut command = if cold { - self.cold_revision_mock_command( - revision, - &binary, - &bench_dir, - mock_port, - ®istry.origin, - ) - } else { - eprintln!( - "Serving {revision}'s tarballs from a mock built from pnpr@{revision} on 127.0.0.1:{mock_port}...", - ); - pnpm_registry_mock::pnpr_command_with_binary(&binary, mock_port, Some(®istry.origin)) - }; + let (mut command, origin) = + self.revision_mock_command(revision, &binary, &bench_dir, mock_port, ®istry.origin); self.apply_serve_timing(&mut command); let process = command .stdin(Stdio::null()) @@ -94,43 +82,84 @@ impl WorkEnv { .spawn() .expect(if cold { "spawn cold revision mock" } else { "spawn revision mock" }); - let mut server = PnprServer { process, latency_proxy: None }; + let mut server = PnprServer { process, latency_proxy: None, origin }; wait_for_pnpr_ready(mock_port); self.follow_revision_npm_base_path(revision, ®istry.origin, mock_port); server.latency_proxy = Some(self.front_revision_mock(revision, registry.listener, mock_port)); server } - /// Use isolated storage, wiped between iterations, with the warm mock as origin. - fn cold_revision_mock_command( + fn revision_mock_command( &self, revision: &str, binary: &Path, bench_dir: &Path, mock_port: u16, public_url: &str, - ) -> Command { - let cold_storage = bench_dir.join("cold-mock-storage"); - let config_path = bench_dir.join("cold-mock-config.yaml"); - fs::write(&config_path, cold_mock_config_yaml(&cold_storage, &self.registry.client)) - .expect("write cold mock config"); - eprintln!( - "Serving {revision}'s tarballs from a COLD mock built from pnpr@{revision} on 127.0.0.1:{mock_port} (origin {})...", - self.registry.client, + ) -> (Command, Option>) { + if self.options.selection.scenario.is_some_and(BenchmarkScenario::cold_pnpr_cache) { + let (origin_url, origin) = self.start_cold_revision_mock_origin(binary); + let cmd = cold_revision_mock_command( + revision, + binary, + bench_dir, + mock_port, + public_url, + &origin_url, + ); + (cmd, Some(Box::new(origin))) + } else { + eprintln!( + "Serving {revision}'s tarballs from a mock built from pnpr@{revision} on 127.0.0.1:{mock_port}...", + ); + ( + pnpm_registry_mock::pnpr_command_with_binary(binary, mock_port, Some(public_url)), + None, + ) + } + } + /// Spawn a warm origin from the same revision's `pnpr` binary over the + /// shared runtime storage, fronted by the client↔registry latency and + /// bandwidth profile so the cold mock fetches packuments and tarballs from + /// an origin that speaks its own revision's URL format. + fn start_cold_revision_mock_origin(&self, binary: &Path) -> (String, PnprServer) { + let origin_port = pick_unused_port().expect("pick a port for the cold mock warm origin"); + let rate_limit = mbps_to_bytes_per_sec(self.options.network.registry_bandwidth_mbps); + let latency_proxy = (self.options.network.registry_latency_ms > 0 || rate_limit.is_some()) + .then(|| { + let upstream = SocketAddr::from((Ipv4Addr::LOCALHOST, origin_port)); + let profile = LinkProfile { + one_way: Duration::from_millis(self.options.network.registry_latency_ms) / 2, + rate_limit, + slow_start: self.options.network.registry_slow_start, + }; + LatencyProxy::spawn(upstream, profile) + .expect("spawn cold mock warm origin latency proxy") + }); + let public_origin = latency_proxy + .as_ref() + .map(|proxy| format!("http://{}", proxy.addr)); + let mut command = pnpm_registry_mock::pnpr_command_with_binary( + binary, + origin_port, + public_origin.as_deref(), ); - let mut command = Command::new(binary); - command - .arg("--config") - .arg(&config_path) - .arg("--storage") - .arg(&cold_storage) - .arg("--listen") - .arg(format!("127.0.0.1:{mock_port}")) - .arg("--public-url") - .arg(public_url) - .arg("--packument-ttl-secs") - .arg("31536000"); - command + self.apply_serve_timing(&mut command); + let process = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cold mock warm origin"); + let origin = PnprServer { process, latency_proxy, origin: None }; + wait_for_pnpr_ready(origin_port); + let base = public_origin.unwrap_or_else(|| format!("http://127.0.0.1:{origin_port}")); + let origin_url = if serves_npm_under_base_path(origin_port) { + pnpm_registry_mock::npm_registry_url(&base) + } else { + format!("{base}/") + }; + (origin_url, origin) } /// Start a pnpr resolver server for every `pnpr@` /// target and write the `.pnpr-env` its `install.bash` sources. Each @@ -206,6 +235,7 @@ impl WorkEnv { port, }), latency_proxy: None, + origin: None, }; wait_for_pnpr_ready(port); diff --git a/pnpr/crates/pnpr/src/resolver/wire.rs b/pnpr/crates/pnpr/src/resolver/wire.rs index 6aa3c2b6fd..6f7371fab8 100644 --- a/pnpr/crates/pnpr/src/resolver/wire.rs +++ b/pnpr/crates/pnpr/src/resolver/wire.rs @@ -22,7 +22,9 @@ use pnpr_osv::{OsvIndex, format_advisory_ids}; use pnpr_package_name::CanonicalPackageName; use pnpr_policy::Identity; use pnpr_route::{RouteClass, RouteContext, sanitize_registry_tarball_url, strip_url_credentials}; -use pnpr_upstream::tarball_basename; +use pnpr_upstream::{ + tarball_basename, tarball_basename_for_package, version_route_tarball_version, +}; /// NDJSON content type for the `/-/pnpr/v0/resolve` response. One JSON object /// per line; the client parses frames as they arrive. Excluded from the @@ -384,6 +386,11 @@ fn vulnerability_ids_for_entry( /// for non-standard naming so a legitimate custom registry isn't /// misjudged. Never parses the URL strictly — the lockfile is untrusted. pub(super) fn tarball_url_version<'a>(url: &'a str, name: &str) -> Option<&'a str> { + if let Ok(canonical) = CanonicalPackageName::parse(name, pnpr_package_name::Ecosystem::Npm) + && let Some(version) = version_route_tarball_version(url, &canonical) + { + return Some(version); + } let last = url.rsplit('/').next()?; let last = last .split(['?', '#']) diff --git a/pnpr/crates/pnpr/src/resolver/wire/tarball_router.rs b/pnpr/crates/pnpr/src/resolver/wire/tarball_router.rs index 20df0f41d1..e9b3fb86cc 100644 --- a/pnpr/crates/pnpr/src/resolver/wire/tarball_router.rs +++ b/pnpr/crates/pnpr/src/resolver/wire/tarball_router.rs @@ -2,7 +2,8 @@ use super::{ Arc, CanonicalPackageName, HashMap, Identity, Lockfile, LockfileResolution, PacquetConfig, RouteClass, RouteContext, TarballResolution, is_git_hosted_tarball_url, is_http_tarball_url, pick_registry_for_package, sanitize_registry_tarball_url, strip_url_credentials, - tarball_basename, tarball_url_and_integrity, + tarball_basename, tarball_basename_for_package, tarball_url_and_integrity, + version_route_tarball_version, }; #[derive(Clone)] @@ -47,16 +48,15 @@ impl TarballRouter { // token. A genuinely public tarball is anonymously fetchable, so the // sanitized URL still works. RouteClass::Public => sanitize_registry_tarball_url(tarball_url), - RouteClass::Hosted { .. } => pnpr_tarball_url( - &self.public_url, - package, - &tarball_filename(package, version, tarball_url), - ), + RouteClass::Hosted { .. } => { + pnpr_tarball_url(&self.public_url, package, version, tarball_url) + } RouteClass::Proxied { alias, .. } => upstream_endpoint_tarball_url( &self.public_url, &alias, package, - &tarball_filename(package, version, tarball_url), + version, + tarball_url, ), } } @@ -129,16 +129,15 @@ impl TarballRouter { // malicious/compromised registry embedded in `dist.tarball` is // stripped first, so pnpr never streams or caches it. RouteClass::Public => strip_url_credentials(tarball_url), - RouteClass::Hosted { .. } => pnpr_tarball_url( - &self.public_url, - package, - &tarball_filename(package, version, tarball_url), - ), + RouteClass::Hosted { .. } => { + pnpr_tarball_url(&self.public_url, package, version, tarball_url) + } RouteClass::Proxied { alias, .. } => upstream_endpoint_tarball_url( &self.public_url, &alias, package, - &tarball_filename(package, version, tarball_url), + version, + tarball_url, ), } } @@ -157,18 +156,28 @@ impl TarballRouter { } } -pub(super) fn tarball_filename(package: &str, version: &str, tarball_url: &str) -> String { - if let Some(basename) = tarball_basename(tarball_url) { - return basename.to_string(); - } - match CanonicalPackageName::parse(package, pnpr_package_name::Ecosystem::Npm) { - Ok(name) => name.tarball_name_for_version(version), - Err(_) => format!("{package}-{version}.tgz"), +pub(super) fn tarball_route_tail(package: &str, version: &str, tarball_url: &str) -> String { + if let Ok(name) = CanonicalPackageName::parse(package, pnpr_package_name::Ecosystem::Npm) + && let Some(route_version) = version_route_tarball_version(tarball_url, &name) + { + return format!("{package}@{route_version}"); } + let filename = match CanonicalPackageName::parse(package, pnpr_package_name::Ecosystem::Npm) { + Ok(name) => tarball_basename_for_package(tarball_url, &name) + .map_or_else(|| name.tarball_name_for_version(version), std::borrow::Cow::into_owned), + Err(_) => tarball_basename(tarball_url) + .map_or_else(|| format!("{package}-{version}.tgz"), str::to_string), + }; + format!("{package}/-/{filename}") } -pub(super) fn pnpr_tarball_url(public_url: &str, package: &str, filename: &str) -> String { - format!("{}/{package}/-/{filename}", npm_endpoint(public_url)) +pub(super) fn pnpr_tarball_url( + public_url: &str, + package: &str, + version: &str, + tarball_url: &str, +) -> String { + format!("{}/{}", npm_endpoint(public_url), tarball_route_tail(package, version, tarball_url)) } /// The registry-endpoint URL a proxied route's tarball is served through. @@ -176,9 +185,14 @@ pub(super) fn upstream_endpoint_tarball_url( public_url: &str, upstream: &str, package: &str, - filename: &str, + version: &str, + tarball_url: &str, ) -> String { - format!("{}/~{upstream}/{package}/-/{filename}", npm_endpoint(public_url)) + format!( + "{}/~{upstream}/{}", + npm_endpoint(public_url), + tarball_route_tail(package, version, tarball_url), + ) } fn npm_endpoint(public_url: &str) -> String { diff --git a/pnpr/crates/pnpr/src/server.rs b/pnpr/crates/pnpr/src/server.rs index 2106526f92..fd4f1d7054 100644 --- a/pnpr/crates/pnpr/src/server.rs +++ b/pnpr/crates/pnpr/src/server.rs @@ -187,7 +187,7 @@ use pnpr_upstream::{ CacheValidators, FetchOutcome, PackumentFetch, Upstream, abbreviate_packument, extract_upstream_revision_integrity, extract_upstream_version_manifest, extract_version_manifest, rewrite_tarball_urls, rewrite_upstream_tarball_urls, - tarball_basename, + tarball_basename_for_package, version_route_tarball_version, }; use serde::Deserialize; use serde_json::{Map, Value, json}; diff --git a/pnpr/crates/pnpr/src/server/package_mutation/immutability.rs b/pnpr/crates/pnpr/src/server/package_mutation/immutability.rs index f327d8adcc..cbd3a23022 100644 --- a/pnpr/crates/pnpr/src/server/package_mutation/immutability.rs +++ b/pnpr/crates/pnpr/src/server/package_mutation/immutability.rs @@ -1,4 +1,4 @@ -use pnpr_upstream::tarball_basename; +use pnpr_upstream::{tarball_basename_for_package, version_route_tarball_version}; use serde_json::Map; use super::{CanonicalPackageName, RegistryError, Value}; @@ -184,12 +184,14 @@ pub(super) fn check_integrity_immutable( } /// `dist.tarball` of a published version may not change, and an update that -/// omits it has the stored one put back. +/// omits it or submits a round-tripped `@` URL has the stored +/// `.tgz` URL put back. /// -/// Basenames are compared, not URLs: the round-trip carries the rewritten URL -/// (see [`pnpr_upstream::rewrite_tarball_urls`]) while the hosted side keeps -/// the original, and [`served_tarball_basename`] applies the same -/// version-derived fallback so a basename-less stored URL is still pinned. +/// Canonical tarball identities are compared, not URLs: the round-trip carries +/// the rewritten URL (see [`pnpr_upstream::rewrite_tarball_urls`], which emits +/// `{public_url}/{pkg}@{version}`) while the hosted side keeps the `.tgz` URL, +/// and [`served_tarball_basename`] applies the same version-derived fallback so +/// a basename-less stored URL is still pinned. pub(super) fn check_tarball_immutable( entry: &PublishedVersion<'_>, name: &CanonicalPackageName, @@ -197,35 +199,53 @@ pub(super) fn check_tarball_immutable( ) -> Option { let version = entry.version; let stored_basename = served_tarball_basename(entry.existing, name)?; - let incoming_basename = entry.manifest + let incoming_url = entry.manifest .get("dist") .and_then(|dist| dist.get("tarball")) - .and_then(Value::as_str) - .and_then(tarball_basename); + .and_then(Value::as_str); + let incoming_basename = + incoming_url.and_then(|url| tarball_basename_or_version_route(url, name)); match incoming_basename { Some(submitted) if submitted != stored_basename => Some(RegistryError::BadRequest { reason: format!("dist.tarball for the published version {version:?} is immutable"), }), - Some(_) => None, + Some(_) => { + if incoming_url.is_some_and(|url| is_version_route_url(url, name)) { + restore_stored_tarball(entry, restore); + } + None + } None => { let refusal = require_object_dist(entry.manifest, version); if refusal.is_none() { - let stored = entry.existing - .get("dist") - .and_then(|dist| dist.get("tarball")) - .cloned() - .unwrap_or(Value::Null); - restore.push((version.clone(), "tarball".to_string(), stored)); + restore_stored_tarball(entry, restore); } refusal } } } +fn restore_stored_tarball( + entry: &PublishedVersion<'_>, + restore: &mut Vec<(String, String, Value)>, +) { + let stored = entry.existing + .get("dist") + .and_then(|dist| dist.get("tarball")) + .cloned() + .unwrap_or(Value::Null); + restore.push((entry.version.clone(), "tarball".to_string(), stored)); +} + +fn is_version_route_url(url: &str, pkg: &CanonicalPackageName) -> bool { + version_route_tarball_version(url, pkg).is_some() +} + /// The tarball basename a version is actually served under, mirroring /// [`pnpr_upstream::rewrite_tarball_urls`]: the `dist.tarball` URL's own basename when it has -/// one, otherwise the version-derived canonical name the rewrite falls back to. -/// Returns `None` when the manifest carries no string `dist.tarball` to serve. +/// one (normalized when it names `@`), otherwise the version-derived canonical name +/// the rewrite falls back to. Returns `None` when the manifest carries no string `dist.tarball` to +/// serve. pub(super) fn served_tarball_basename( manifest: &Value, pkg: &CanonicalPackageName, @@ -234,13 +254,18 @@ pub(super) fn served_tarball_basename( .get("dist") .and_then(|dist| dist.get("tarball")) .and_then(Value::as_str)?; - if let Some(basename) = tarball_basename(url) { - return Some(basename.to_owned()); + if let Some(basename) = tarball_basename_or_version_route(url, pkg) { + return Some(basename); } let version = manifest.get("version").and_then(Value::as_str)?; Some(pkg.tarball_name_for_version(version)) } +fn tarball_basename_or_version_route(url: &str, pkg: &CanonicalPackageName) -> Option { + let basename = tarball_basename_for_package(url, pkg)?; + Some(pkg.canonicalize_tarball_name(&basename).unwrap_or_else(|_| basename.into_owned())) +} + /// Reject a published version whose `dist` isn't an object: a restore needs an /// object to write into, so otherwise it would no-op and persist the version /// without the field — the stripping this guards against. diff --git a/pnpr/crates/pnpr/src/server/package_mutation/immutability/tests.rs b/pnpr/crates/pnpr/src/server/package_mutation/immutability/tests.rs index ec94d4c681..598333ae63 100644 --- a/pnpr/crates/pnpr/src/server/package_mutation/immutability/tests.rs +++ b/pnpr/crates/pnpr/src/server/package_mutation/immutability/tests.rs @@ -300,3 +300,46 @@ fn strips_round_tripped_dist_pnpr_overlay() { assert!(enforce_published_version_immutability(&hosted, &pkg, &mut incoming).is_none()); assert!(incoming["versions"]["1.0.0"]["dist"].get("pnpr").is_none()); } + +#[test] +fn accepts_round_tripped_version_route_tarball_url_and_restores_stored_url() { + let pkg = + CanonicalPackageName::parse("@acme/mypkg", pnpr_package_name::Ecosystem::Npm).unwrap(); + let hosted = json!({ + "versions": { + "1.0.0": { + "name": "@acme/mypkg", + "version": "1.0.0", + "dist": { + "integrity": "sha512-test", + "tarball": "http://localhost/npm/@acme/mypkg/-/mypkg-1.0.0.tgz", + } + } + } + }); + + let mut incoming = json!({ + "versions": { + "1.0.0": { + "name": "@acme/mypkg", + "version": "1.0.0", + "dist": { + "integrity": "sha512-test", + "tarball": "http://example.test/npm/@acme/mypkg@1.0.0", + } + } + } + }); + + assert!(enforce_published_version_immutability(&hosted, &pkg, &mut incoming).is_none()); + assert_eq!( + incoming["versions"]["1.0.0"]["dist"]["tarball"], + json!("http://localhost/npm/@acme/mypkg/-/mypkg-1.0.0.tgz"), + ); + + let mut wrong_version = incoming; + wrong_version["versions"]["1.0.0"]["dist"]["tarball"] = + json!("http://example.test/npm/@acme/mypkg@2.0.0"); + let err = enforce_published_version_immutability(&hosted, &pkg, &mut wrong_version).unwrap(); + assert!(matches!(err, RegistryError::BadRequest { .. })); +} diff --git a/pnpr/crates/pnpr/src/server/package_responses.rs b/pnpr/crates/pnpr/src/server/package_responses.rs index 992f8f5cb9..f499df61f9 100644 --- a/pnpr/crates/pnpr/src/server/package_responses.rs +++ b/pnpr/crates/pnpr/src/server/package_responses.rs @@ -2,7 +2,8 @@ use super::{ ABBREVIATED_CONTENT_TYPE, Arc, Body, CanonicalPackageName, DistView, HeaderMap, IndexMap, Integrity, RegistryError, Response, StatusCode, Utc, Value, abbreviate_packument, describe_tars, filter_osv_vulnerable_versions, header, rewrite_tarball_urls, - rewrite_upstream_tarball_urls, streaming, tarball_basename, + rewrite_upstream_tarball_urls, streaming, tarball_basename_for_package, + version_route_tarball_version, }; /// The version a tarball request resolves to, plus that version's declared @@ -12,6 +13,7 @@ use super::{ /// resolves to the right version, integrity, and OSV identity. pub(super) struct TarballDist { pub(super) version: String, + pub(super) version_route: Option, pub(super) integrity: Integrity, pub(super) unpacked_size: Option, } @@ -58,7 +60,7 @@ pub(super) fn expected_tarball_dist( let dist = manifest.dist.as_ref()?; dist.tarball .as_deref() - .and_then(tarball_basename) + .and_then(|url| tarball_basename_for_package(url, name)) .is_some_and(|basename| basename == filename) .then_some((version, dist)) }); @@ -77,7 +79,16 @@ pub(super) fn expected_tarball_dist( )); } let integrity = declared_tarball_integrity(dist, name, filename, version)?; - Ok(Some(TarballDist { version: version.clone(), integrity, unpacked_size: dist.unpacked_size })) + let version_route = dist.tarball + .as_deref() + .and_then(|url| version_route_tarball_version(url, name)) + .map(str::to_string); + Ok(Some(TarballDist { + version: version.clone(), + version_route, + integrity, + unpacked_size: dist.unpacked_size, + })) } /// Prefer SRI and fall back to legacy SHA-1, while refusing unverified bytes. diff --git a/pnpr/crates/pnpr/src/server/routing/npm_handlers.rs b/pnpr/crates/pnpr/src/server/routing/npm_handlers.rs index cdf079c911..1732724405 100644 --- a/pnpr/crates/pnpr/src/server/routing/npm_handlers.rs +++ b/pnpr/crates/pnpr/src/server/routing/npm_handlers.rs @@ -343,17 +343,30 @@ pub(super) async fn put_scoped_packument_revision( } /// `DELETE {base}/{pkg}/-rev/{rev}` — remove a whole package -/// (`pnpm unpublish --force`). +/// (`pnpm unpublish --force`), or remove one version's tarball when the segment +/// is `{pkg}@{version}` (`DELETE {dist.tarball}/-rev/{rev}`). pub(super) async fn unpublish_package( State(state): State, AuthedCaller(identity): AuthedCaller, TargetRegistry(registry): TargetRegistry, Path(path): Path, ) -> Response { + if let Some((pkg_name, version)) = split_package_identifier(&path.name) { + return unpublish_version_tarball( + &state, + &identity, + registry.as_deref(), + pkg_name, + version, + ) + .await; + } delete_package(&state, &identity, registry.as_deref(), &path.name).await } -/// `DELETE {base}/@{scope}/{pkg}/-rev/{rev}` — remove a whole scoped package. +/// `DELETE {base}/@{scope}/{pkg}/-rev/{rev}` — remove a whole scoped package, +/// or remove one scoped version's tarball when the second segment is +/// `{pkg}@{version}` (`DELETE {dist.tarball}/-rev/{rev}`). pub(super) async fn unpublish_scoped_package( State(state): State, AuthedCaller(identity): AuthedCaller, @@ -364,10 +377,33 @@ pub(super) async fn unpublish_scoped_package( if !scope.starts_with('@') { return not_found(); } + if let Some((bare_name, version)) = split_package_identifier(&name) { + let full = format!("{scope}/{bare_name}"); + return unpublish_version_tarball(&state, &identity, registry.as_deref(), &full, version) + .await; + } let full = format!("{scope}/{name}"); delete_package(&state, &identity, registry.as_deref(), &full).await } +async fn unpublish_version_tarball( + state: &AppState, + identity: &Identity, + registry: Option<&str>, + raw_name: &str, + version: &str, +) -> Response { + if super::super::revision_tarballs::parse_revision_version(version).is_some() { + return not_found(); + } + let canonical = match CanonicalPackageName::parse(raw_name, Ecosystem::Npm) { + Ok(name) => name, + Err(err) => return err.into_response(), + }; + let filename = canonical.tarball_name_for_version(version); + delete_tarball(state, identity, registry, raw_name, &filename).await +} + /// `DELETE {base}/{pkg}/-/{filename}/-rev/{rev}` — remove one version's /// tarball, a step of `pnpm unpublish @`. pub(super) async fn unpublish_tarball( diff --git a/pnpr/crates/pnpr/src/server/upstream_integrity.rs b/pnpr/crates/pnpr/src/server/upstream_integrity.rs index e26470a68e..7ffa2571d1 100644 --- a/pnpr/crates/pnpr/src/server/upstream_integrity.rs +++ b/pnpr/crates/pnpr/src/server/upstream_integrity.rs @@ -10,6 +10,7 @@ use std::collections::HashSet; struct MissingIntegrityTarball { version: String, filename: String, + version_route: Option, needs_integrity: bool, expected_shasum: Option, } @@ -190,7 +191,11 @@ impl PackumentIntegrityResolver<'_> { let fetched = timed( "tarball:integrity_fetch", self.name.as_str(), - self.upstream.fetch_tarball_response(self.name, &candidate.filename), + self.upstream.fetch_tarball_or_version_response( + self.name, + &candidate.filename, + candidate.version_route.as_deref(), + ), ) .await?; let response = match fetched { @@ -326,6 +331,8 @@ fn packument_tarball( return Ok(None); }; let filename = packument_tarball_filename(tarball, name, version)?; + let version_route = + pnpr_upstream::version_route_tarball_version(tarball, name).map(str::to_string); let needs_integrity = needs_integrity(dist, name, &filename, version)?; let expected_shasum = if needs_integrity { legacy_shasum_integrity(dist, name, &filename, version)? @@ -335,6 +342,7 @@ fn packument_tarball( Ok(Some(MissingIntegrityTarball { version: version.to_string(), filename, + version_route, needs_integrity, expected_shasum, })) @@ -345,9 +353,9 @@ fn packument_tarball_filename( name: &CanonicalPackageName, version: &str, ) -> Result { - let basename = pnpr_upstream::tarball_basename(tarball); + let basename = pnpr_upstream::tarball_basename_for_package(tarball, name); let fallback = name.tarball_name_for_version(version); - let raw_filename = basename.unwrap_or(&fallback); + let raw_filename = basename.as_deref().unwrap_or(&fallback); let (filename, _) = tarball_cache_name(name, raw_filename) .map_err(|err| { tarball_integrity_error( diff --git a/pnpr/crates/pnpr/src/server/upstream_integrity/tests.rs b/pnpr/crates/pnpr/src/server/upstream_integrity/tests.rs index d151743eb2..f71898fb31 100644 --- a/pnpr/crates/pnpr/src/server/upstream_integrity/tests.rs +++ b/pnpr/crates/pnpr/src/server/upstream_integrity/tests.rs @@ -13,6 +13,7 @@ fn pinned(version: &str, shasum_of: Option<&[u8]>) -> MissingIntegrityTarball { MissingIntegrityTarball { version: version.to_string(), filename: format!("foo-{version}.tgz"), + version_route: None, needs_integrity: true, expected_shasum: shasum_of.map(|bytes| digest(Algorithm::Sha1, bytes)), } diff --git a/pnpr/crates/pnpr/src/server/upstream_tarballs.rs b/pnpr/crates/pnpr/src/server/upstream_tarballs.rs index bc69dffb3a..b3cf62a146 100644 --- a/pnpr/crates/pnpr/src/server/upstream_tarballs.rs +++ b/pnpr/crates/pnpr/src/server/upstream_tarballs.rs @@ -89,7 +89,12 @@ pub(super) async fn serve_authorized_upstream_tarball( return response; } let dist = bind_tarball_to_packument(state, upstream, namespace, name, filename, ttl).await; - let TarballDist { version, integrity, unpacked_size } = match dist { + let TarballDist { + version, + version_route, + integrity, + unpacked_size, + } = match dist { Ok(Some(dist)) => dist, Ok(None) => return not_found(), Err(err) => return err.into_response(), @@ -97,8 +102,14 @@ pub(super) async fn serve_authorized_upstream_tarball( if let Err(err) = recheck_osv(state, name, &version, parsed_version) { return err.into_response(); } - let tarball = - UpstreamTarball { namespace, name, filename, integrity: &integrity, unpacked_size }; + let tarball = UpstreamTarball { + namespace, + name, + filename, + version_route: version_route.as_deref(), + integrity: &integrity, + unpacked_size, + }; if upstream.caches() && state.inner.osv_index.is_some() && let Some(response) = cached_upstream_tarball(state, namespace, name, filename).await @@ -220,6 +231,7 @@ pub(super) struct UpstreamTarball<'a> { pub(super) namespace: &'a str, pub(super) name: &'a CanonicalPackageName, pub(super) filename: &'a str, + pub(super) version_route: Option<&'a str>, pub(super) integrity: &'a Integrity, pub(super) unpacked_size: Option, } @@ -235,13 +247,14 @@ pub(super) async fn fetch_upstream_tarball( namespace, name, filename, + version_route, integrity, unpacked_size: _, } = tarball; let fetched = timed( "tarball:upstream_fetch", name.as_str(), - upstream.fetch_tarball_response(name, filename), + upstream.fetch_tarball_or_version_response(name, filename, version_route), ) .await; let response = match fetched { diff --git a/pnpr/crates/pnpr/src/server/version_tarballs.rs b/pnpr/crates/pnpr/src/server/version_tarballs.rs index 08a8fd5058..6915a243ca 100644 --- a/pnpr/crates/pnpr/src/server/version_tarballs.rs +++ b/pnpr/crates/pnpr/src/server/version_tarballs.rs @@ -20,8 +20,8 @@ use super::{ declared_tarball_integrity, ensure_osv_allowed, is_tar_registry, not_found, read_source_packument, recover_stale_upstream_packument, resolve_registry_source, serve_bound_upstream_tarball, serve_registry_tarball, serve_revision_tarball, tar_decode_limit, - tar_response, tarball_basename, tarball_cache_name, tgz_integrity_key, timed, - upstream_cache_namespace, + tar_response, tarball_basename_for_package, tarball_cache_name, tgz_integrity_key, timed, + upstream_cache_namespace, version_route_tarball_version, }; use axum::response::IntoResponse; use pnpr_storage::DocumentStamp; @@ -125,19 +125,19 @@ impl VersionRequest<'_> { tar_response(self.state, headers, response, source).await } - /// Serve the version's `.tgz` under its canonical filename without its - /// packument: a hosted blob, which publish names canonically, or a cached - /// upstream `.tgz`, as the filename route serves one. `None` leaves the - /// version to its packument, including a `dist.tarball` that names - /// another file. + /// Serve the version's `.tgz` without reading its packument: a hosted + /// blob, which publish names canonically, or a cached upstream `.tgz` + /// whose filename [`VersionTgzCache`] already bound to this version. + /// `None` leaves the version to its packument, including a `dist.tarball` + /// that names another file. async fn serve_canonical_tgz( &self, name: &CanonicalPackageName, source: &RegistrySource, ) -> Option { - let canonical = name.tarball_name_for_version(self.version); match source { RegistrySource::Hosted(_) => { + let canonical = name.tarball_name_for_version(self.version); let response = serve_registry_tarball( self.state, self.identity, @@ -155,7 +155,13 @@ impl VersionRequest<'_> { Ok(cached) => cached?, Err(err) => return Some(err.into_response()), }; - cached_upstream_tarball(self.state, &cached.namespace, name, &canonical).await + let tgz = self.state.inner.proxy.version_tgzs.get_bound( + &cached.namespace, + name, + self.version, + )?; + let (filename, _) = tarball_cache_name(name, &tgz.filename).ok()?; + cached_upstream_tarball(self.state, &cached.namespace, name, &filename).await } _ => None, } @@ -213,9 +219,15 @@ impl VersionRequest<'_> { else { return Ok(None); }; - // The stamp was taken before the read, so a packument rewritten in - // between only costs the next request a miss. - Ok(version_tgz(&packument, self.version).map(|tgz| (tgz, stamp.map(DistOrigin::Packument)))) + let stamp = match (stamp, cached) { + (Some(stamp), _) => Some(stamp), + (None, Some(cached)) => { + self.state.inner.storage.upstream_document_stamp(&cached.namespace, name).await? + } + (None, None) => None, + }; + Ok(version_tgz(&packument, name, self.version) + .map(|tgz| (tgz, stamp.map(DistOrigin::Packument)))) } /// The version's dist from its manifest, or from the stale cached @@ -241,7 +253,7 @@ impl VersionRequest<'_> { recover_stale_upstream_packument(self.state, &cached?.namespace, upstream, name, err) .await .ok()??; - version_tgz(&stale, self.version).map(|tgz| (tgz, None)) + version_tgz(&stale, name, self.version).map(|tgz| (tgz, None)) } /// The version's dist from `//`. @@ -258,8 +270,10 @@ impl VersionRequest<'_> { ) .await; match fetched { - Ok(FetchOutcome::Ok(manifest)) => version_manifest_tgz(&manifest.bytes, self.version) - .map_or(ManifestRead::Unusable, ManifestRead::Dist), + Ok(FetchOutcome::Ok(manifest)) => { + version_manifest_tgz(&manifest.bytes, name, self.version) + .map_or(ManifestRead::Unusable, ManifestRead::Dist) + } Ok(FetchOutcome::NotFound) => ManifestRead::Unusable, Err( err @ (RegistryError::Upstream { .. } | RegistryError::UpstreamUnavailable { .. }), @@ -366,6 +380,7 @@ impl VersionRequest<'_> { namespace: &namespace, name, filename: &filename, + version_route: tgz.version_route.as_deref(), integrity: &integrity, unpacked_size: tgz.dist.unpacked_size, }; diff --git a/pnpr/crates/pnpr/src/server/version_tarballs/cache.rs b/pnpr/crates/pnpr/src/server/version_tarballs/cache.rs index 2607e1dff6..be80142b11 100644 --- a/pnpr/crates/pnpr/src/server/version_tarballs/cache.rs +++ b/pnpr/crates/pnpr/src/server/version_tarballs/cache.rs @@ -1,5 +1,6 @@ use super::{ - CanonicalPackageName, DistBlock, PackumentDists, Value, tarball_basename, tgz_integrity_key, + CanonicalPackageName, DistBlock, PackumentDists, Value, tarball_basename_for_package, + tgz_integrity_key, version_route_tarball_version, }; use pnpr_storage::DocumentStamp; use std::{ @@ -10,13 +11,24 @@ use std::{ pub(super) struct VersionTgz { pub(super) filename: String, + pub(super) version_route: Option, pub(super) dist: DistBlock, pub(super) integrity_key: Option, } -pub(super) fn version_tgz(packument: &[u8], version: &str) -> Option { +pub(super) fn version_tgz( + packument: &[u8], + name: &CanonicalPackageName, + version: &str, +) -> Option { let mut packument: PackumentDists = serde_json::from_slice(packument).ok()?; - dist_tgz(packument.versions.shift_remove(version)?.dist?) + let tgz = dist_tgz(name, packument.versions.shift_remove(version)?.dist?)?; + let shared_filename = packument.versions + .values() + .filter_map(|manifest| manifest.dist.as_ref()?.tarball.as_deref()) + .filter_map(|url| tarball_basename_for_package(url, name)) + .any(|basename| basename == tgz.filename); + (!shared_filename).then_some(tgz) } #[derive(serde::Deserialize)] @@ -28,20 +40,25 @@ struct VersionManifest { /// The dist of a version manifest for `version`. An upstream resolves a /// dist-tag at the same URL, so a manifest of another version is refused. A -/// dist without a valid integrity is left to the packument, which carries the -/// integrity pnpr pins for it. -pub(super) fn version_manifest_tgz(manifest: &[u8], version: &str) -> Option { +/// dist without a valid integrity or with a non-canonical filename is left to +/// the packument, which carries the integrity pnpr pins for it and checks that +/// no other version claims the same filename. +pub(super) fn version_manifest_tgz( + manifest: &[u8], + name: &CanonicalPackageName, + version: &str, +) -> Option { let manifest: VersionManifest = serde_json::from_slice(manifest).ok()?; let dist = manifest.dist.filter(|dist| dist.integrity.is_some())?; - let tgz = (manifest.version == version).then(|| dist_tgz(dist))??; - tgz.integrity_key.is_some().then_some(tgz) + let tgz = (manifest.version == version).then(|| dist_tgz(name, dist))??; + (tgz.integrity_key.is_some() && tgz.filename == name.tarball_name_for_version(version)) + .then_some(tgz) } -fn dist_tgz(dist: DistBlock) -> Option { - let filename = dist.tarball - .as_deref() - .and_then(tarball_basename)? - .to_string(); +fn dist_tgz(name: &CanonicalPackageName, dist: DistBlock) -> Option { + let url = dist.tarball.as_deref()?; + let filename = tarball_basename_for_package(url, name)?.into_owned(); + let version_route = version_route_tarball_version(url, name).map(str::to_string); let mut fields = serde_json::Map::new(); if let Some(integrity) = &dist.integrity { fields.insert("integrity".to_string(), Value::String(integrity.clone())); @@ -49,7 +66,7 @@ fn dist_tgz(dist: DistBlock) -> Option { if let Some(shasum) = &dist.shasum { fields.insert("shasum".to_string(), Value::String(shasum.clone())); } - Some(VersionTgz { filename, integrity_key: tgz_integrity_key(&fields), dist }) + Some(VersionTgz { filename, version_route, integrity_key: tgz_integrity_key(&fields), dist }) } /// Versions kept before the cache drops some. @@ -80,6 +97,17 @@ struct CachedVersionTgz { } impl VersionTgzCache { + pub(super) fn get_bound( + &self, + namespace: &str, + name: &CanonicalPackageName, + version: &str, + ) -> Option> { + let entries = self.entries.lock().expect("version dist cache lock poisoned"); + let key = (namespace.to_string(), name.as_str().to_string(), version.to_string()); + entries.get(&key).map(|cached| Arc::clone(&cached.tgz)) + } + pub(super) fn get( &self, namespace: &str, diff --git a/pnpr/crates/pnpr/src/server/version_tarballs/cache/tests.rs b/pnpr/crates/pnpr/src/server/version_tarballs/cache/tests.rs index 141feb0093..660c4c4442 100644 --- a/pnpr/crates/pnpr/src/server/version_tarballs/cache/tests.rs +++ b/pnpr/crates/pnpr/src/server/version_tarballs/cache/tests.rs @@ -10,6 +10,7 @@ const TTL: Duration = Duration::from_mins(1); fn tgz() -> Arc { Arc::new(VersionTgz { filename: "foo-1.0.0.tgz".to_string(), + version_route: None, dist: serde_json::from_str("{}").unwrap(), integrity_key: None, }) diff --git a/pnpr/crates/pnpr/tests/auth_publish/authentication.rs b/pnpr/crates/pnpr/tests/auth_publish/authentication.rs index 29c528d0a6..7fca2ee172 100644 --- a/pnpr/crates/pnpr/tests/auth_publish/authentication.rs +++ b/pnpr/crates/pnpr/tests/auth_publish/authentication.rs @@ -133,7 +133,7 @@ async fn authenticated_publish_writes_manifest_and_tarball() { let served = body_json(response.into_body()).await; assert_eq!( served["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/mypkg/-/mypkg-1.0.0.tgz", + "http://example.test/npm/mypkg@1.0.0", ); } diff --git a/pnpr/crates/pnpr/tests/auth_publish/scoped_publishing.rs b/pnpr/crates/pnpr/tests/auth_publish/scoped_publishing.rs index eff2d03923..5f5b25cbff 100644 --- a/pnpr/crates/pnpr/tests/auth_publish/scoped_publishing.rs +++ b/pnpr/crates/pnpr/tests/auth_publish/scoped_publishing.rs @@ -318,6 +318,80 @@ async fn unpublish_scoped_tarball_via_six_segment_route() { assert!(!local_blob_path(&storage, "", "@scope/unpub", "unpub-1.0.0.tgz").exists()); } +#[tokio::test] +async fn unpublish_tarball_via_version_route_url() { + let tmp = TempDir::new().unwrap(); + let storage = tmp.path().to_path_buf(); + let app = router(static_config(storage.clone())); + let (app, token) = add_user_and_get_token(app, "alice", "secret").await; + + for (pkg, put_path, delete_path, blob_name) in [ + ( + "unpub-ver", + "/npm/unpub-ver", + "/npm/unpub-ver@1.0.0/-rev/anything", + "unpub-ver-1.0.0.tgz", + ), + ( + "@scope/unpub-ver", + "/npm/@scope/unpub-ver", + "/npm/@scope/unpub-ver@1.0.0/-rev/anything", + "unpub-ver-1.0.0.tgz", + ), + ] { + let body = publish_doc(pkg, "1.0.0", b"bytes"); + let request = Request::put(put_path) + .header("content-type", "application/json") + .header("Authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + let publish = app + .clone() + .oneshot(request) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::CREATED); + assert!(local_blob_path(&storage, "", pkg, blob_name).exists()); + + let request = Request::delete(delete_path) + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let response = app + .clone() + .oneshot(request) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert!(!local_blob_path(&storage, "", pkg, blob_name).exists()); + } + + // Reserved `+r` revision identifiers must not delete a hosted `.tgz` blob. + let body = publish_doc("unpub-rev", "1.0.0+r0", b"bytes"); + let request = Request::put("/npm/unpub-rev") + .header("content-type", "application/json") + .header("Authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + assert_eq!( + app.clone() + .oneshot(request) + .await + .unwrap() + .status(), + StatusCode::CREATED, + ); + assert!(local_blob_path(&storage, "", "unpub-rev", "unpub-rev-1.0.0+r0.tgz").exists()); + + let request = Request::delete("/npm/unpub-rev@1.0.0+r0/-rev/anything") + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(local_blob_path(&storage, "", "unpub-rev", "unpub-rev-1.0.0+r0.tgz").exists()); +} + #[tokio::test] async fn missing_unpublish_policy_denies_destructive_writes() { let tmp = TempDir::new().unwrap(); diff --git a/pnpr/crates/pnpr/tests/batch_publish.rs b/pnpr/crates/pnpr/tests/batch_publish.rs index 52c7646012..b46f312ae6 100644 --- a/pnpr/crates/pnpr/tests/batch_publish.rs +++ b/pnpr/crates/pnpr/tests/batch_publish.rs @@ -192,7 +192,7 @@ async fn batch_publish_supports_scoped_packages_with_libnpmpublish_attachment_na let served_packument = body_json(packument_response.into_body()).await; assert_eq!( served_packument["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/@scope/wire-form/-/wire-form-1.0.0.tgz", + "http://example.test/npm/@scope/wire-form@1.0.0", ); let served = app diff --git a/pnpr/crates/pnpr/tests/registry_mock.rs b/pnpr/crates/pnpr/tests/registry_mock.rs index a425299da2..b98d21c898 100644 --- a/pnpr/crates/pnpr/tests/registry_mock.rs +++ b/pnpr/crates/pnpr/tests/registry_mock.rs @@ -55,11 +55,11 @@ async fn serves_scoped_packument_from_storage() { // The on-disk packument has a Verdaccio-form tarball URL — // `http://localhost:4873/@foo/no-deps/-/@foo/no-deps-1.0.0.tgz`. - // After our rewrite the client should see the npm-spec form + // After our rewrite the client should see the `@` form // pointed at our `public_url`. assert_eq!( doc["versions"]["1.0.0"]["dist"]["tarball"], - format!("{PUBLIC_URL}/npm/@foo/no-deps/-/no-deps-1.0.0.tgz"), + format!("{PUBLIC_URL}/npm/@foo/no-deps@1.0.0"), ); // Other fields (integrity, shasum, name, version) should pass // through untouched. @@ -149,10 +149,7 @@ async fn abbreviated_accept_header_strips_packument() { assert!(version_obj.get("contributors").is_none(), "abbreviated form should drop contributors"); assert_eq!(version_obj["name"], "@foo/no-deps"); assert_eq!(version_obj["version"], "1.0.0"); - assert_eq!( - version_obj["dist"]["tarball"], - format!("{PUBLIC_URL}/npm/@foo/no-deps/-/no-deps-1.0.0.tgz"), - ); + assert_eq!(version_obj["dist"]["tarball"], format!("{PUBLIC_URL}/npm/@foo/no-deps@1.0.0")); assert!( version_obj["dist"]["integrity"] .as_str() @@ -226,10 +223,7 @@ async fn serves_version_manifest_by_dist_tag() { let manifest: Value = serde_json::from_slice(&body_bytes(response.into_body()).await).unwrap(); assert_eq!(manifest["name"], "@foo/no-deps"); assert_eq!(manifest["version"], "1.0.0"); - assert_eq!( - manifest["dist"]["tarball"], - format!("{PUBLIC_URL}/npm/@foo/no-deps/-/no-deps-1.0.0.tgz"), - ); + assert_eq!(manifest["dist"]["tarball"], format!("{PUBLIC_URL}/npm/@foo/no-deps@1.0.0")); // The response is the single-version manifest, not the whole // packument — `versions` shouldn't be there. assert!(manifest.get("versions").is_none()); diff --git a/pnpr/crates/pnpr/tests/s3_backend.rs b/pnpr/crates/pnpr/tests/s3_backend.rs index 4610623ebf..6dd9d811ae 100644 --- a/pnpr/crates/pnpr/tests/s3_backend.rs +++ b/pnpr/crates/pnpr/tests/s3_backend.rs @@ -79,7 +79,7 @@ async fn publishes_to_and_serves_from_the_object_store() { let served = body_json(response.into_body()).await; assert_eq!( served["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/mypkg/-/mypkg-1.0.0.tgz", + "http://example.test/npm/mypkg@1.0.0", ); // The tarball streams back byte-for-byte out of the bucket. diff --git a/pnpr/crates/pnpr/tests/server/hosted_discovery.rs b/pnpr/crates/pnpr/tests/server/hosted_discovery.rs index 3c1a7270d0..0227e38244 100644 --- a/pnpr/crates/pnpr/tests/server/hosted_discovery.rs +++ b/pnpr/crates/pnpr/tests/server/hosted_discovery.rs @@ -342,7 +342,7 @@ async fn registry_addressed_surface_serves_dist_tags_unpublish_whoami_search_and let manifest = body_json(manifest.into_body()).await; assert_eq!( manifest["dist"]["tarball"], - json!("http://example.test/npm/~acme/@acme/widget/-/widget-1.0.0.tgz"), + json!("http://example.test/npm/~acme/@acme/widget@1.0.0"), ); assert!(local_blob_path(tmp.path(), "acme", "@acme/widget", "widget-1.0.0.tgz").exists()); diff --git a/pnpr/crates/pnpr/tests/server/packuments.rs b/pnpr/crates/pnpr/tests/server/packuments.rs index 3ef87d8a12..0a13a7981c 100644 --- a/pnpr/crates/pnpr/tests/server/packuments.rs +++ b/pnpr/crates/pnpr/tests/server/packuments.rs @@ -53,10 +53,7 @@ async fn packument_is_proxied_cached_and_rewritten() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body: Value = serde_json::from_slice(&body_bytes(response.into_body()).await).unwrap(); - assert_eq!( - body["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(body["versions"]["1.0.0"]["dist"]["tarball"], "http://example.test/npm/foo@1.0.0"); assert_eq!(body["versions"]["1.0.0"]["dist"]["shasum"], sha1_hex_of(tarball)); assert_eq!(body["versions"]["1.0.0"]["dist"]["integrity"], sha512_integrity(tarball)); @@ -541,10 +538,7 @@ async fn packument_is_gzipped_for_clients_that_accept_it() { let mut decoded = Vec::new(); GzDecoder::new(&gzipped[..]).read_to_end(&mut decoded).expect("decode gzip"); let body: Value = serde_json::from_slice(&decoded).unwrap(); - assert_eq!( - body["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(body["versions"]["1.0.0"]["dist"]["tarball"], "http://example.test/npm/foo@1.0.0"); mock.assert_async().await; } @@ -582,10 +576,7 @@ async fn packument_is_zstd_compressed_for_clients_that_prefer_it() { Some("zstd"), ); let body = decode_zstd(&body_bytes(response.into_body()).await).await; - assert_eq!( - body["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(body["versions"]["1.0.0"]["dist"]["tarball"], "http://example.test/npm/foo@1.0.0"); mock.assert_async().await; } diff --git a/pnpr/crates/pnpr/tests/server/registry_routing.rs b/pnpr/crates/pnpr/tests/server/registry_routing.rs index 189ae88e39..b665d94f78 100644 --- a/pnpr/crates/pnpr/tests/server/registry_routing.rs +++ b/pnpr/crates/pnpr/tests/server/registry_routing.rs @@ -682,7 +682,7 @@ async fn a_single_npm_ecosystem_still_answers_under_its_prefix() { .as_str() .unwrap() .to_string(); - assert_eq!(advertised, "http://example.test/npm/npm/-/npm-10.0.0.tgz"); + assert_eq!(advertised, "http://example.test/npm/npm@10.0.0"); let fetched = app .oneshot( diff --git a/pnpr/crates/pnpr/tests/server/tarball_proxy.rs b/pnpr/crates/pnpr/tests/server/tarball_proxy.rs index 67608d4d30..6af10c040d 100644 --- a/pnpr/crates/pnpr/tests/server/tarball_proxy.rs +++ b/pnpr/crates/pnpr/tests/server/tarball_proxy.rs @@ -189,7 +189,7 @@ async fn upstream_endpoint_serves_packument_with_endpoint_rewritten_tarballs() { // URL is canonical for the client's configured registry (integrity-only). assert_eq!( body["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/~npmjs/foo/-/foo-1.0.0.tgz", + "http://example.test/npm/~npmjs/foo@1.0.0", ); mock.assert_async().await; } @@ -812,7 +812,7 @@ async fn non_canonical_upstream_tarball_basename_is_served() { let tmp = TempDir::new().unwrap(); let app = router(config_for(&upstream.url(), tmp.path().to_path_buf())); - // The served packument advertises the preserved basename on this server. + // The served packument advertises the `@` route on this server. let served = app .clone() .oneshot( @@ -824,9 +824,22 @@ async fn non_canonical_upstream_tarball_basename_is_served() { .unwrap(); let doc = body_json(served.into_body()).await; let advertised = doc["versions"]["3001.1.0-exotic"]["dist"]["tarball"].as_str().unwrap(); - assert!(advertised.ends_with(&format!("/npm/foo/-/{exotic}")), "got {advertised}"); + assert!(advertised.ends_with("/npm/foo@3001.1.0-exotic"), "got {advertised}"); + + // Fetching `@` or `/-/` resolves the upstream + // packument's declared filename and serves the verified bytes. + let version_response = app + .clone() + .oneshot( + Request::get("/npm/foo@3001.1.0-exotic") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(version_response.status(), StatusCode::OK); + assert_eq!(body_bytes(version_response.into_body()).await, bytes); - // And fetching that URL back serves the verified bytes. let response = app .oneshot( Request::get(format!("/npm/foo/-/{exotic}").as_str()) @@ -915,3 +928,82 @@ async fn scoped_tarball_filename_is_canonicalized_before_fetch_and_cache() { packument_mock.assert_async().await; mock.assert_async().await; } + +#[tokio::test] +async fn upstream_pnpr_version_route_tarball_is_proxied() { + let mut upstream = mockito::Server::new_async().await; + let bytes = b"chained-pnpr-tarball"; + let packument = json!({ + "name": "@scope/foo", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "name": "@scope/foo", + "version": "1.0.0", + "dist": { + "tarball": format!("{}/@scope/foo@1.0.0", upstream.url()), + "integrity": sha512_integrity(bytes), + }, + }, + }, + }); + let packument_mock = upstream + .mock("GET", "/@scope/foo") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(packument.to_string()) + .expect(1) + .create_async() + .await; + let tarball_mock = upstream + .mock("GET", "/@scope/foo@1.0.0") + .with_status(200) + .with_body(bytes) + .expect(1) + .create_async() + .await; + + let tmp = TempDir::new().unwrap(); + let app = router(config_for(&upstream.url(), tmp.path().to_path_buf())); + + let served = app + .clone() + .oneshot( + Request::get("/npm/@scope/foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let doc = body_json(served.into_body()).await; + assert_eq!( + doc["versions"]["1.0.0"]["dist"]["tarball"], + "http://example.test/npm/@scope/foo@1.0.0", + ); + + let response = app + .clone() + .oneshot( + Request::get("/npm/@scope/foo@1.0.0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_bytes(response.into_body()).await, bytes); + + let filename_response = app + .oneshot( + Request::get("/npm/@scope/foo/-/foo-1.0.0.tgz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(filename_response.status(), StatusCode::OK); + assert_eq!(body_bytes(filename_response.into_body()).await, bytes); + + packument_mock.assert_async().await; + tarball_mock.assert_async().await; +} diff --git a/pnpr/crates/pnpr/tests/server/tarball_verification.rs b/pnpr/crates/pnpr/tests/server/tarball_verification.rs index 0a2e9e0fce..df016c1f7c 100644 --- a/pnpr/crates/pnpr/tests/server/tarball_verification.rs +++ b/pnpr/crates/pnpr/tests/server/tarball_verification.rs @@ -72,7 +72,7 @@ async fn tarball_route_preserves_basename_and_binds_to_declaring_version() { .unwrap(); assert_eq!(selected.status(), StatusCode::OK); let selected: Value = serde_json::from_slice(&body_bytes(selected.into_body()).await).unwrap(); - assert_eq!(selected["dist"]["tarball"], "http://example.test/npm/foo/-/foo-2.0.0.tgz"); + assert_eq!(selected["dist"]["tarball"], "http://example.test/npm/foo@1.0.0"); let full = app .clone() @@ -84,14 +84,8 @@ async fn tarball_route_preserves_basename_and_binds_to_declaring_version() { .await .unwrap(); let full: Value = serde_json::from_slice(&body_bytes(full.into_body()).await).unwrap(); - assert_eq!( - full["versions"]["1.0.0"]["dist"]["tarball"], - "http://example.test/npm/foo/-/foo-2.0.0.tgz", - ); - assert_eq!( - full["versions"]["2.0.0"]["dist"]["tarball"], - "http://example.test/npm/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(full["versions"]["1.0.0"]["dist"]["tarball"], "http://example.test/npm/foo@1.0.0"); + assert_eq!(full["versions"]["2.0.0"]["dist"]["tarball"], "http://example.test/npm/foo@2.0.0"); let route = selected["dist"]["tarball"] .as_str() diff --git a/pnpr/crates/pnpr/tests/server/version_tarballs.rs b/pnpr/crates/pnpr/tests/server/version_tarballs.rs index 429d3b3e4d..d3b4ca1e79 100644 --- a/pnpr/crates/pnpr/tests/server/version_tarballs.rs +++ b/pnpr/crates/pnpr/tests/server/version_tarballs.rs @@ -226,6 +226,10 @@ async fn a_cold_version_request_gets_its_own_tarball() { let tmp = TempDir::new().unwrap(); let app = router(config_for(&url, tmp.path().to_path_buf())); + let other_response = get(&app, "/npm/foo@2.0.0").await; + assert_eq!(other_response.status(), StatusCode::OK); + assert_eq!(body_bytes(other_response.into_body()).await, other); + let response = get(&app, "/npm/foo@1.0.0").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(body_bytes(response.into_body()).await, requested); diff --git a/pnpr/crates/upstream/src/lib.rs b/pnpr/crates/upstream/src/lib.rs index 77d736f4b6..314154964c 100644 --- a/pnpr/crates/upstream/src/lib.rs +++ b/pnpr/crates/upstream/src/lib.rs @@ -3,7 +3,7 @@ pub use discovery::SearchResponse; pub use packument::{ extract_upstream_revision_integrity, extract_upstream_version_manifest, extract_version_manifest, rewrite_tarball_urls, rewrite_upstream_tarball_urls, - tarball_basename, + tarball_basename, tarball_basename_for_package, version_route_tarball_version, }; pub use oci::oci_download_allowed; @@ -258,15 +258,45 @@ impl Upstream { name: &CanonicalPackageName, filename: &str, ) -> Result> { + let url = format!("{}/{}/-/{}", self.base.trim_end_matches('/'), name.as_str(), filename); + self.fetch_tarball_url(&url).await + } + + /// Fetch a version-addressed tarball (`{base}/{name}@{version}`) from an + /// upstream `pnpr` registry. + pub async fn fetch_version_tarball_response( + &self, + name: &CanonicalPackageName, + version: &str, + ) -> Result> { + let url = format!("{}/{}@{version}", self.base.trim_end_matches('/'), name.as_str()); + self.fetch_tarball_url(&url).await + } + + /// Fetch a tarball by its version route (`{base}/{name}@{version}`) when + /// the upstream advertised one, or by its archive route + /// (`{base}/{name}/-/{filename}`) otherwise. + pub async fn fetch_tarball_or_version_response( + &self, + name: &CanonicalPackageName, + filename: &str, + version_route: Option<&str>, + ) -> Result> { + match version_route { + Some(version) => self.fetch_version_tarball_response(name, version).await, + None => self.fetch_tarball_response(name, filename).await, + } + } + + async fn fetch_tarball_url(&self, url: &str) -> Result> { let started = Instant::now(); self.ensure_available()?; - let url = format!("{}/{}/-/{}", self.base.trim_end_matches('/'), name.as_str(), filename); - let (response, guard) = self.get_with_scoped_headers(&url, &HeaderMap::new()).await?; + let (response, guard) = self.get_with_scoped_headers(url, &HeaderMap::new()).await?; if response.status() == StatusCode::NOT_FOUND { self.breaker.record_success(); return Ok(FetchOutcome::NotFound); } - let response = self.checked(response, &url).await?; + let response = self.checked(response, url).await?; // Success here covers only headers; a mid-stream body failure is // the caller's to observe. Recording success on a clean status is // what verdaccio does too. diff --git a/pnpr/crates/upstream/src/packument.rs b/pnpr/crates/upstream/src/packument.rs index 24805a1a3c..a345afb2b5 100644 --- a/pnpr/crates/upstream/src/packument.rs +++ b/pnpr/crates/upstream/src/packument.rs @@ -4,21 +4,12 @@ use super::{ }; /// Rewrite every `dist.tarball` in `value` to a URL served by *this* -/// registry instead of whatever URL the source put there. The new URL is -/// `{public_url}/{pkg}/-/{basename}`, where `basename` is the last -/// `/`-separated segment of the original tarball URL. This handles both -/// npm's canonical `/{pkg}/-/{basename}` shape and verdaccio's -/// `/{scope}/{name}/-/{scope}/{filename}` shape uniformly — we only look -/// at the basename, never at the path prefix. -/// -/// The basename is preserved verbatim rather than reconstructed from the -/// version, so a non-canonical tarball name (e.g. esprima-fb's zero-padded -/// `esprima-fb-3001.0001.0000-dev-harmony-fb.tgz` for version -/// `3001.1.0-dev-harmony-fb`) survives into the client's lockfile and is -/// fetched back from the path the upstream actually hosts. The tarball -/// endpoint binds each request to a version's declared `dist.integrity` -/// (see `serve_tarball`), so a preserved name can't smuggle in unverified -/// bytes. +/// registry instead of whatever URL the source put there. When the version +/// has an addressable version string, the new URL is `{public_url}/{pkg}@{version}`, +/// and `serve_package_tarball` resolves the version's declared `dist.tarball` +/// filename and `dist.integrity` from its packument or version manifest. When +/// no addressable version is available (such as a top-level manifest without a +/// `version` field), the fallback URL is `{public_url}/{pkg}/-/{basename}`. /// /// Walks both packument shape (`{ "versions": { v: { dist: ... } } }`) /// and single-version manifest shape (`{ dist: ... }` at the top level) @@ -61,18 +52,11 @@ pub(super) fn rewrite_dist_tarball( ) { // Every string `dist.tarball` must be rewritten to a route on *this* // server, where integrity and OSV are enforced — never passed through. - // When the upstream URL has no usable basename (e.g. it ends in `/`), fall - // back to the version-derived canonical name (the manifest carries its own - // `version`) so a malformed URL still points at pnpr (and 404s there) - // rather than directing the client at an arbitrary upstream host. let version = value .get("version") .and_then(Value::as_str) .filter(|version| expected_version.is_none_or(|expected| *version == expected)) .map(str::to_string); - let fallback = version - .as_deref() - .map(|version| pkg.tarball_name_for_version(version)); let Some(dist) = value.get_mut("dist").and_then(Value::as_object_mut) else { return; }; @@ -99,11 +83,68 @@ pub(super) fn rewrite_dist_tarball( dist.remove("revisions"); let Some(tarball_value) = dist.get_mut("tarball") else { return }; let Some(tarball) = tarball_value.as_str() else { return }; - let filename = tarball_basename(tarball) - .map(str::to_owned) - .or(fallback) + let target_version = version.as_deref().or(expected_version); + *tarball_value = Value::String(rewritten_tarball_url(pkg, target_version, tarball, public_url)); +} + +fn rewritten_tarball_url( + pkg: &CanonicalPackageName, + version: Option<&str>, + tarball: &str, + public_url: &str, +) -> String { + if let Some(version) = version + && is_addressable_version(version) + { + return format!("{public_url}/{pkg}@{version}", pkg = pkg.as_str()); + } + let filename = tarball_basename_for_package(tarball, pkg) + .map(std::borrow::Cow::into_owned) + .or_else(|| version.map(|version| pkg.tarball_name_for_version(version))) .unwrap_or_default(); - *tarball_value = Value::String(format!("{public_url}/{}/-/{filename}", pkg.as_str())); + format!("{public_url}/{}/-/{filename}", pkg.as_str()) +} + +fn is_addressable_version(version: &str) -> bool { + pnpr_package_name::is_safe_path_segment(version) + && !version + .split_once('+') + .and_then(|(_, build)| build.strip_prefix('r')) + .is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +/// The `` segment when `url` ends on `{pkg}@{version}`, with any +/// `?query`/`#fragment` stripped, when `` is a safe non-revision +/// version segment. +#[must_use] +pub fn version_route_tarball_version<'a>( + url: &'a str, + pkg: &CanonicalPackageName, +) -> Option<&'a str> { + let path = url + .split(['?', '#']) + .next() + .unwrap_or(url); + let (prefix, version) = path.rsplit_once('@')?; + ((prefix.ends_with(&format!("/{}", pkg.as_str())) || prefix == pkg.as_str()) + && is_addressable_version(version)) + .then_some(version) +} + +/// The tarball filename a `dist.tarball` URL addresses for `pkg`: the canonical +/// `-.tgz` when `url` is a `{pkg}@{version}` route, or +/// [`tarball_basename`] otherwise. +#[must_use] +pub fn tarball_basename_for_package<'a>( + url: &'a str, + pkg: &CanonicalPackageName, +) -> Option> { + if let Some(version) = version_route_tarball_version(url, pkg) { + return Some(std::borrow::Cow::Owned(pkg.tarball_name_for_version(version))); + } + tarball_basename(url).map(std::borrow::Cow::Borrowed) } fn rewrite_dist_attestations( diff --git a/pnpr/crates/upstream/src/tests/packument_rewriting.rs b/pnpr/crates/upstream/src/tests/packument_rewriting.rs index ba9e0eee09..8be22d67ae 100644 --- a/pnpr/crates/upstream/src/tests/packument_rewriting.rs +++ b/pnpr/crates/upstream/src/tests/packument_rewriting.rs @@ -24,10 +24,7 @@ fn rewrites_npm_form_tarball() { }); let name = CanonicalPackageName::parse("foo", pnpr_package_name::Ecosystem::Npm).unwrap(); rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:4873"); - assert_eq!( - doc["versions"]["1.0.0"]["dist"]["tarball"], - "http://127.0.0.1:4873/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(doc["versions"]["1.0.0"]["dist"]["tarball"], "http://127.0.0.1:4873/foo@1.0.0"); assert_eq!(doc["versions"]["1.0.0"]["dist"]["shasum"], "abc"); assert_eq!( doc["versions"]["1.0.0"]["dist"]["attestations"]["url"], @@ -153,7 +150,6 @@ fn does_not_preserve_an_invalid_upstream_revision_route() { (json!(0), "https://upstream.test/foo@1.0.0+r0"), (json!(2), "https://other.test/foo@1.0.0+r2"), ] { - let basename = tarball_basename(tarball).unwrap(); let mut doc = json!({ "version": "1.0.0", "dist": { @@ -170,7 +166,7 @@ fn does_not_preserve_an_invalid_upstream_revision_route() { "http://pnpr.test/~corp/", ); - assert_eq!(doc["dist"]["tarball"], format!("http://pnpr.test/~corp/foo/-/{basename}")); + assert_eq!(doc["dist"]["tarball"], "http://pnpr.test/~corp/foo@1.0.0"); assert!(doc["dist"].get("revision").is_none()); } } @@ -260,16 +256,35 @@ fn rewrites_verdaccio_form_tarball_for_scoped() { rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:9999"); assert_eq!( doc["versions"]["1.0.0"]["dist"]["tarball"], - "http://127.0.0.1:9999/@foo/no-deps/-/no-deps-1.0.0.tgz", + "http://127.0.0.1:9999/@foo/no-deps@1.0.0", ); } #[test] -fn preserves_non_canonical_tarball_basename() { - // A version whose tarball filename does not follow the - // `-.tgz` convention (here esprima-fb's zero-padded - // name for version `3001.1.0-dev-harmony-fb`) keeps its basename, so - // the client records and fetches back the path the upstream hosts. +fn rewrites_upstream_pnpr_version_route_tarball_url() { + for (pkg, upstream_url, expected) in [ + ("foo", "https://up.pnpr.test/npm/foo@1.0.0", "http://127.0.0.1:9999/foo@1.0.0"), + ( + "@scope/foo", + "https://up.pnpr.test/npm/@scope/foo@1.0.0", + "http://127.0.0.1:9999/@scope/foo@1.0.0", + ), + ] { + let mut doc = json!({ + "versions": { + "1.0.0": { + "dist": { "tarball": upstream_url } + } + } + }); + let name = CanonicalPackageName::parse(pkg, pnpr_package_name::Ecosystem::Npm).unwrap(); + rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:9999"); + assert_eq!(doc["versions"]["1.0.0"]["dist"]["tarball"], expected); + } +} + +#[test] +fn rewrites_non_canonical_tarball_basename_to_version_route() { let mut doc = json!({ "versions": { "3001.1.0-dev-harmony-fb": { @@ -284,27 +299,28 @@ fn preserves_non_canonical_tarball_basename() { rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:9999"); assert_eq!( doc["versions"]["3001.1.0-dev-harmony-fb"]["dist"]["tarball"], - "http://127.0.0.1:9999/esprima-fb/-/esprima-fb-3001.0001.0000-dev-harmony-fb.tgz", + "http://127.0.0.1:9999/esprima-fb@3001.1.0-dev-harmony-fb", ); } #[test] -fn rewrites_query_bearing_tarball_to_its_path_basename() { - // A signed/query-bearing upstream URL must rewrite to the path basename - // alone: the query is not part of the route a client later requests, so - // keeping it would desync the public URL from the serve-time match. +fn rewrites_query_bearing_tarball_without_query_string() { let mut doc = json!({ "versions": { "1.0.0": { "dist": { "tarball": "https://cdn.example.com/foo/-/foo-1.0.0.tgz?sig=abc&exp=123" } + }, + "3001.1.0-dev-harmony-fb": { + "dist": { "tarball": "https://cdn.example.com/foo/-/foo-3001.0001.0000-dev-harmony-fb.tgz?sig=abc&exp=123" } } } }); let name = CanonicalPackageName::parse("foo", pnpr_package_name::Ecosystem::Npm).unwrap(); rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:9999"); + assert_eq!(doc["versions"]["1.0.0"]["dist"]["tarball"], "http://127.0.0.1:9999/foo@1.0.0"); assert_eq!( - doc["versions"]["1.0.0"]["dist"]["tarball"], - "http://127.0.0.1:9999/foo/-/foo-1.0.0.tgz", + doc["versions"]["3001.1.0-dev-harmony-fb"]["dist"]["tarball"], + "http://127.0.0.1:9999/foo@3001.1.0-dev-harmony-fb", ); } @@ -324,10 +340,7 @@ fn rewrites_basenameless_tarball_url_to_pnpr_route() { }); let name = CanonicalPackageName::parse("foo", pnpr_package_name::Ecosystem::Npm).unwrap(); rewrite_tarball_urls(&mut doc, &name, "http://127.0.0.1:9999"); - assert_eq!( - doc["versions"]["1.0.0"]["dist"]["tarball"], - "http://127.0.0.1:9999/foo/-/foo-1.0.0.tgz", - ); + assert_eq!(doc["versions"]["1.0.0"]["dist"]["tarball"], "http://127.0.0.1:9999/foo@1.0.0"); } #[test] @@ -368,7 +381,7 @@ fn extracts_version_by_dist_tag() { CanonicalPackageName::parse("@foo/no-deps", pnpr_package_name::Ecosystem::Npm).unwrap(); let manifest = extract_version_manifest(&doc, &name, "latest", "http://reg").unwrap(); assert_eq!(manifest["version"], "1.0.0"); - assert_eq!(manifest["dist"]["tarball"], "http://reg/@foo/no-deps/-/no-deps-1.0.0.tgz"); + assert_eq!(manifest["dist"]["tarball"], "http://reg/@foo/no-deps@1.0.0"); assert_eq!(manifest["dist"]["shasum"], "abc"); } @@ -381,7 +394,7 @@ fn extracts_version_by_literal_version() { let name = CanonicalPackageName::parse("foo", pnpr_package_name::Ecosystem::Npm).unwrap(); let manifest = extract_version_manifest(&doc, &name, "2.0.0", "http://reg").unwrap(); assert_eq!(manifest["version"], "2.0.0"); - assert_eq!(manifest["dist"]["tarball"], "http://reg/foo/-/foo-2.0.0.tgz"); + assert_eq!(manifest["dist"]["tarball"], "http://reg/foo@2.0.0"); } #[test] diff --git a/pnpr/docs/endpoints.md b/pnpr/docs/endpoints.md index bdb2bb6429..ad948a69a1 100644 --- a/pnpr/docs/endpoints.md +++ b/pnpr/docs/endpoints.md @@ -75,8 +75,9 @@ Every request routes through the registry graph: - The serving registry's matching `packages:` rule is enforced on every read and write. - Served `dist.tarball` URLs are rewritten onto `--public-url` (`PNPR_PUBLIC_URL`) - and stay canonical for the base the client addressed (`/npm/` or - `/npm/~/`), so lockfiles don't bake in a registry name. + as `/@` and stay canonical for the base the client + addressed (`/npm/` or `/npm/~/`), so lockfiles don't bake in a + registry name. ## pnpr protocol endpoints @@ -254,6 +255,8 @@ a hosted registry. | `PUT` | `/-/pnpm/v1/publish` | pnpm batch publish. Body is `{"packages":[, ...]}`. The batch is all-or-nothing and each package requires `publish`. For a release that spans ecosystems, use [`PUT /-/pnpr/v0/publish`](ecosystems.md#one-publish-for-a-workspace-that-spans-ecosystems) instead. | | `PUT` | `/{package}/-rev/{rev}` | Replace a packument, used by partial unpublish. Requires both `publish` and `unpublish`. Use a percent-encoded scoped package name, for example `@scope%2Fname`. | | `DELETE` | `/{package}/-rev/{rev}` | Remove an entire package, used by force unpublish. Requires `unpublish`. Use a percent-encoded scoped package name for scoped packages. | +| `DELETE` | `/{name}@{version}/-rev/{rev}` | Remove an unscoped version's tarball. Requires `unpublish`. | +| `DELETE` | `/@scope/{name}@{version}/-rev/{rev}` | Remove a scoped version's tarball. Requires `unpublish`. | | `DELETE` | `/{name}/-/{filename}/-rev/{rev}` | Remove an unscoped tarball. Requires `unpublish`. | | `DELETE` | `/@scope/{name}/-/{filename}/-rev/{rev}` | Remove a scoped tarball. Requires `unpublish`. | | `PUT` | `/-/package/{package}/dist-tags/{tag}` | Add or update a dist-tag. Body is a JSON string version, for example `"1.0.0"`. Requires `publish`. |