feat(config): declare each registry once in the registries setting (#13942)
Registries do not all lay out tarball URLs the way the npm registry does. JFrog Artifactory repeats the scope in a scoped package's tarball filename (`@acme/widget/-/@acme/widget-1.0.0.tgz`) where npm strips it. pnpm cannot rebuild such a URL, so it writes it out for every scoped package instead of omitting it, and the lockfile carries a host-specific URL per dependency. pnpm had no place to record such a fact, because it had no place to describe a registry at all — only three ways to name one. `registries` mapped a scope to a URL, `namedRegistries` mapped a bare-specifier prefix to a URL, and neither could carry anything else. `registries` now declares a registry once, keyed by its URL, with every fact about it in the entry: a `serverType`, the `scopes` routed to it, and the `prefix` it answers to. `serverType` has three states: undeclared strict; only the exact canonical URL is reconstructible npm also serves the percent-encoded scoped path artifactory repeats the scope in the tarball filename registry.npmjs.org resolves to `npm` as a built-in, so its behavior is unchanged and the old hostname check becomes that one default rather than a special case in the predicate. `npm` cannot be the default: asserting npmjs-compatibility is a claim only the operator can make. The URL is the key because every fact in an entry is a fact about that server. Keying the layout by scope would bind it to whoever the scope currently points at, so two developers whose scope resolves differently would write lockfiles that disagree about which URLs may be omitted. `scopes` and `prefix` are routes to the registry and are inverted at config-read time into the two lookups the rest of pnpm already queries, leaving the resolver, installer, and lockfile layers untouched and the precedence chain (builtin < .npmrc < yaml < `_auth` < CLI) unchanged. The layout is declared, never inferred. Sniffing the registry URL cannot work: a virtual repository serves both layouts at once, depending on whether each package was synced from upstream or published locally, so no registry-level signal — route, response header, or probe — decides it. Declaring it also keeps a wrong guess a fixable misconfiguration instead of a silent breakage. `serverType` feeds a single URL builder that both sides of the lockfile use: the writer omits a tarball URL only when the builder reproduces it, and the reader rebuilds it with the same call. They therefore agree by construction, so pnpm never has to assume a registry serves some second URL as well. The setting lives in pnpm-workspace.yaml rather than .npmrc because the lockfile depends on it: one developer omitting URLs that another reconstructs differently would break a frozen install. A `serverType` in the global config.yaml is ignored for the same reason, while the routes declared alongside it are kept. Credentials are rejected there — the file is committed — and still belong in .npmrc. The registry URL is the map key, so the request-destination env gate applies to keys as well as values. Credentials and unknown fields are refused after parsing, since a parse error renders the offending source line verbatim. A map whose values are all strings is the older `<scope>: <url>` shape and is still read as one. Mixing the two shapes in one map is refused, and so is a URL-keyed entry written as a string. `namedRegistries` is deprecated in favor of `prefix` and is read only for prefixes `registries` does not declare; a prefix stays singular because it is the registry's identity in a lockfile dep path. An entry that routes nothing to itself and matches no configured registry is reported as a warning rather than silently ignored; it is a warning and not an error because a shared config dependency can legitimately describe registries a given project does not use. Config dependencies and pnpr-server-mode resolution pass no server type; in both, the writer and the reader share that default, so they stay consistent. Closes pnpm/get-npm-tarball-url#16. Supersedes pnpm/pnpm#13920.
This commit is contained in:
1 parent
fe8ec8fbec
commit
c9b8632ea6
325 files changed
+4954
-1978
No files matched your search
@@ -0,0 +1,18 @@
|
||||
---
|
||||
"@pnpm/building.after-install": major
|
||||
"@pnpm/building.commands": major
|
||||
"@pnpm/deps.inspection.tree-builder": major
|
||||
"@pnpm/installing.context": major
|
||||
"@pnpm/installing.deps-installer": major
|
||||
"@pnpm/installing.deps-restorer": major
|
||||
"@pnpm/installing.modules-yaml": major
|
||||
"@pnpm/installing.read-projects-context": major
|
||||
"pacquet": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
`node_modules/.modules.yaml` no longer records the registries an install resolved from, and the recorded copy is dropped from the file on the first install that rewrites it.
|
||||
|
||||
It dated from the lockfile format that spelled a dependency's path relative to its registry, where reading an installed tree meant knowing the registries it was installed with. Dependency paths have not carried a registry for several major versions, and the recorded copy outlived its use: `pnpm list`, `pnpm why`, and single-project installs preferred it over the project's own configuration, so a project whose registry had changed since its last install was still read through the old one.
|
||||
|
||||
They now use the configured registries, like every other command already did.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
"@pnpm/config.normalize-registries": minor
|
||||
"@pnpm/installing.deps-installer": minor
|
||||
"@pnpm/pnpr.client": major
|
||||
"@pnpm/pnpr": minor
|
||||
"pacquet": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
A pnpr resolve request now carries the client's registries the way the `registries` setting declares them — keyed by URL, with the scopes routed to each, the bare-specifier prefix each answers to, and each one's `serverType` — in place of the prefix map it used to send.
|
||||
|
||||
The server routes them through the same inversion the config reader runs, so a pnpr-served install resolves a scoped dependency from the registry that scope is routed to, which it previously could not: only the default registry and the prefix-addressed ones reached the server. A declared `serverType` reaches it too, so the tarball URLs pnpr omits from the lockfile match the ones the client reconstructs.
|
||||
|
||||
Built-in scope routes the project has not pointed elsewhere are not declared, so a pnpr server's allowlist is not asked about `npm.jsr.io` on requests that resolve no JSR package.
|
||||
|
||||
A registry a request only declares is no longer refused up front for being off the server's allowlist — a client describes its whole configuration, including scopes a given resolve never reaches, so a stray `@scope:registry` in a developer's `~/.npmrc` no longer fails every install against a pnpr server that does not serve it. The boundary moves to the fetch itself: an origin the resolve does reach is refused before the request leaves the server, with the same message.
|
||||
|
||||
This changes the resolve and verify-lockfile request bodies. A pnpr server and its clients have to be on matching versions; the protocol is still experimental and unversioned.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
"@pnpm/building.after-install": major
|
||||
"@pnpm/building.commands": major
|
||||
"@pnpm/config.commands": major
|
||||
"@pnpm/config.normalize-registries": major
|
||||
"@pnpm/config.pick-registry-for-package": major
|
||||
"@pnpm/config.reader": major
|
||||
"@pnpm/constants": major
|
||||
"@pnpm/deps.compliance.commands": major
|
||||
"@pnpm/deps.compliance.license-scanner": major
|
||||
"@pnpm/deps.compliance.sbom": major
|
||||
"@pnpm/deps.graph-builder": major
|
||||
"@pnpm/deps.inspection.commands": major
|
||||
"@pnpm/deps.inspection.list": major
|
||||
"@pnpm/deps.inspection.tree-builder": major
|
||||
"@pnpm/deps.path": major
|
||||
"@pnpm/deps.security.signatures": major
|
||||
"@pnpm/engine.pm.commands": major
|
||||
"@pnpm/engine.runtime.commands": major
|
||||
"@pnpm/exec.commands": major
|
||||
"@pnpm/global.commands": major
|
||||
"@pnpm/global.packages": major
|
||||
"@pnpm/hooks.types": major
|
||||
"@pnpm/installing.commands": major
|
||||
"@pnpm/installing.context": major
|
||||
"@pnpm/installing.deps-installer": major
|
||||
"@pnpm/installing.deps-resolver": major
|
||||
"@pnpm/installing.deps-restorer": major
|
||||
"@pnpm/installing.env-installer": major
|
||||
"@pnpm/installing.modules-yaml": major
|
||||
"@pnpm/installing.read-projects-context": major
|
||||
"@pnpm/lockfile.to-pnp": major
|
||||
"@pnpm/lockfile.utils": major
|
||||
"@pnpm/patching.commands": major
|
||||
"@pnpm/pnpr.client": major
|
||||
"@pnpm/registry-access.commands": major
|
||||
"@pnpm/releasing.commands": major
|
||||
"@pnpm/resolving.default-resolver": major
|
||||
"@pnpm/resolving.npm-resolver": major
|
||||
"@pnpm/store.commands": major
|
||||
"@pnpm/store.connection-manager": major
|
||||
"@pnpm/testing.command-defaults": major
|
||||
"@pnpm/testing.temp-store": major
|
||||
"@pnpm/types": major
|
||||
"pacquet": patch
|
||||
---
|
||||
|
||||
The three registry lookups are now named for what they are keyed by, so that none of them is called `registries` — a name the `registries` setting itself has taken:
|
||||
|
||||
| before | after |
|
||||
|---|---|
|
||||
| `Config.registries` | `Config.registriesByScope` |
|
||||
| `Config.namedRegistries` | `Config.registriesByPrefix` |
|
||||
| `Config.registryOptions` | `Config.registryOptionsByUrl` |
|
||||
|
||||
The same rename applies to the `RegistryContext` fields, the `Registries` and `NamedRegistries` types (now `RegistriesByScope` and `RegistriesByPrefix`), `normalizeRegistries` / `normalizeNamedRegistries` (now `normalizeRegistriesByScope` / `normalizeRegistriesByPrefix`), and the `BUILTIN_NAMED_REGISTRIES` constant (now `BUILTIN_REGISTRIES_BY_PREFIX`).
|
||||
|
||||
This is an internal rename: no setting, error code, lockfile field, `.modules.yaml` key, or `.pnpmfile.cjs` hook field changes. A `preResolution` hook still reads `ctx.registries`, which is the name pacquet passes as well. The `registries` and `namedRegistries` settings are read under the names users write them, and `node_modules/.modules.yaml` keeps its `registries` field.
|
||||
|
||||
The pnpr resolve request sends `registriesByPrefix` where it sent `namedRegistries`. A pnpr server and its clients must be on matching versions, which is already the case for an experimental server.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
"@pnpm/config.normalize-registries": minor
|
||||
"@pnpm/lockfile.utils": major
|
||||
"@pnpm/resolving.tarball-url": major
|
||||
"@pnpm/installing.deps-resolver": minor
|
||||
"@pnpm/installing.deps-installer": minor
|
||||
"@pnpm/config.reader": minor
|
||||
"@pnpm/types": minor
|
||||
"pacquet": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
The `registries` setting now declares a registry once, keyed by its URL, with everything about that registry in the entry: how it lays out tarball URLs, the scopes routed to it, and the bare-specifier prefix it answers to.
|
||||
|
||||
```yaml
|
||||
registries:
|
||||
https://artifactory.example.com/artifactory/api/npm/npm-virtual/:
|
||||
serverType: artifactory
|
||||
scopes: ['@acme', '@acme-internal']
|
||||
prefix: work
|
||||
```
|
||||
|
||||
- **`serverType`** tells pnpm how the registry lays out its tarball URLs, which decides whether a URL can be omitted from `pnpm-lock.yaml`:
|
||||
- **undeclared** (the default) — strict. Only the exact canonical URL is treated as reconstructible.
|
||||
- **`npm`** — the registry behaves like `registry.npmjs.org`, which also serves a scoped package from its percent-encoded path. Declare this for a faithful mirror or caching proxy of the public registry so its tarball URLs can be omitted too.
|
||||
- **`artifactory`** — JFrog Artifactory repeats the scope in a scoped package's tarball filename (`@acme/widget/-/@acme/widget-1.0.0.tgz`) where the npm registry strips it (`@acme/widget/-/widget-1.0.0.tgz`). Declaring it lets pnpm rebuild that URL, so it is omitted from `pnpm-lock.yaml` instead of being written out for every scoped package [pnpm/get-npm-tarball-url#16](https://github.com/pnpm/get-npm-tarball-url/issues/16).
|
||||
- **`scopes`** lists the `@`-prefixed scopes that resolve from this registry. A bare `'@'` is the scope-less default registry, the one the `registry` setting names.
|
||||
- **`prefix`** is the alias a dependency addresses this registry by, as in `"foo": "work:^1.0.0"`.
|
||||
|
||||
The layout is never inferred from the registry URL, so nothing changes unless you declare it; `registry.npmjs.org` continues to behave as `npm` without being declared. Because the lockfile depends on `serverType`, it is read from `pnpm-workspace.yaml` only — a `serverType` in the global `config.yaml` is ignored, so one developer's machine cannot shape a lockfile their collaborators read back with a different layout. Credentials are rejected in this setting, in a key as well as in a field, and still belong in `.npmrc`. An entry that routes nothing to itself and matches no configured registry is reported as a warning rather than silently ignored.
|
||||
|
||||
### Migrating
|
||||
|
||||
The older `registries` shape, a map of `<scope>: <url>` strings, still works and needs no change:
|
||||
|
||||
```yaml
|
||||
registries:
|
||||
'@acme': https://npm.acme.example/
|
||||
```
|
||||
|
||||
`namedRegistries` is deprecated in favor of the `prefix` field, and is still read for prefixes `registries` does not declare.
|
||||
|
||||
`toLockfileResolution` and `isCanonicalRegistryTarballUrl` now take their registry and layout as an options object rather than positional arguments, so `@pnpm/lockfile.utils` and `@pnpm/resolving.tarball-url` get a major bump.
|
||||
Generated
+1
@@ -4420,6 +4420,7 @@ dependencies = [
|
||||
"pnpm-detect-libc",
|
||||
"pnpm-env-replace",
|
||||
"pnpm-fs",
|
||||
"pnpm-lockfile",
|
||||
"pnpm-network",
|
||||
"pnpm-package-is-installable",
|
||||
"pnpm-package-manifest",
|
||||
|
||||
Generated
+13
@@ -2373,6 +2373,9 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: '@pnpm/ramda@0.28.1'
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.4.1(supports-color@10.2.2)
|
||||
'@pnpm/config.normalize-registries':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
@@ -3198,6 +3201,9 @@ importers:
|
||||
|
||||
pnpm11/deps/graph-builder:
|
||||
dependencies:
|
||||
'@pnpm/config.normalize-registries':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/normalize-registries
|
||||
'@pnpm/config.package-is-installable':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/package-is-installable
|
||||
@@ -6116,6 +6122,9 @@ importers:
|
||||
'@pnpm/building.policy':
|
||||
specifier: workspace:*
|
||||
version: link:../../building/policy
|
||||
'@pnpm/config.normalize-registries':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/normalize-registries
|
||||
'@pnpm/config.package-is-installable':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/package-is-installable
|
||||
@@ -9047,6 +9056,10 @@ importers:
|
||||
version: 'link:'
|
||||
|
||||
pnpm11/resolving/tarball-url:
|
||||
dependencies:
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/types
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
|
||||
@@ -233,7 +233,7 @@ fn registry_for(config: &Config, name: &str) -> String {
|
||||
let registry = name
|
||||
.strip_prefix('@')
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.and_then(|scope| config.registries.get(&format!("@{scope}")))
|
||||
.and_then(|scope| config.registries_by_scope.get(&format!("@{scope}")))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| config.registry.clone());
|
||||
if registry.ends_with('/') { registry } else { format!("{registry}/") }
|
||||
|
||||
@@ -491,7 +491,7 @@ fn lookup_config(config: &Config, key: &str, is_scoped: bool) -> Option<Value> {
|
||||
if let Some(scope) = key.strip_suffix(":registry") {
|
||||
// Prefer the merged `registries` map so this reports the same URL
|
||||
// resolvers/publish use (pnpm/pnpm#11492).
|
||||
if let Some(merged) = config.registries.get(scope) {
|
||||
if let Some(merged) = config.registries_by_scope.get(scope) {
|
||||
return Some(Value::String(merged.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,19 +141,19 @@ fn set_registries_and_named_registries_global_writes_config_yaml() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let named_registries = json!({ "work": "https://work.example.com/" });
|
||||
let registries_by_prefix = json!({ "work": "https://work.example.com/" });
|
||||
config_set(
|
||||
&config,
|
||||
tmp.path(),
|
||||
flags(true, None, true),
|
||||
"named-registries",
|
||||
Some(named_registries.to_string()),
|
||||
Some(registries_by_prefix.to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_yaml(&config_dir.join("config.yaml")).unwrap(),
|
||||
json!({ "registries": registries, "namedRegistries": named_registries }),
|
||||
json!({ "registries": registries, "namedRegistries": registries_by_prefix }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ fn get_scoped_registry_from_auth_and_merged() {
|
||||
// merged `registries` block wins over the raw .npmrc value (pnpm/pnpm#11492)
|
||||
let mut merged = config_for_get(&[], &[("@scope:registry", "https://from-npmrc.example.com/")]);
|
||||
merged
|
||||
.registries
|
||||
.registries_by_scope
|
||||
.insert("@scope".to_string(), "https://from-workspace-yaml.example.com/".to_string());
|
||||
assert_eq!(
|
||||
config_get(&merged, flags(false, None, false), "@scope:registry").unwrap(),
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
//! captures. Warnings emitted through the reporter stay on stdout; only
|
||||
//! config-load warnings belong here.
|
||||
|
||||
use pnpm_config::Config;
|
||||
use pnpm_default_reporter::colors::Colors;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use pnpm_network::redact_and_sanitize;
|
||||
use pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io::{IsTerminal, Write},
|
||||
};
|
||||
|
||||
/// Write a `[WARN]`-labelled config-load warning to stderr. Best-effort:
|
||||
/// a warning must never abort the command, so a failed write (a closed
|
||||
@@ -22,3 +28,54 @@ pub(crate) fn emit_config_warning(message: &str) {
|
||||
};
|
||||
let _ = writeln!(std::io::stderr(), "{} {message}", colors.warn_label());
|
||||
}
|
||||
|
||||
/// Warn about `registries` entries that name no configured registry.
|
||||
///
|
||||
/// Such an entry is inert — the wrong URL, a stale entry, a scope that moved —
|
||||
/// and silently doing nothing is the failure mode users cannot debug. A warning
|
||||
/// rather than an error: a shared config dependency can legitimately describe
|
||||
/// registries a given project does not use.
|
||||
pub(crate) fn warn_unmatched_registry_options(config: &Config) {
|
||||
if let Some(message) = unmatched_registry_options_warning(config) {
|
||||
emit_config_warning(&message);
|
||||
}
|
||||
}
|
||||
|
||||
/// The message [`warn_unmatched_registry_options`] emits, or [`None`] when
|
||||
/// every entry matches. Split out so the wording is testable without
|
||||
/// capturing stderr.
|
||||
fn unmatched_registry_options_warning(config: &Config) -> Option<String> {
|
||||
if config.registry_options_by_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let configured: BTreeSet<&str> = config
|
||||
.registries_by_scope
|
||||
.values()
|
||||
.chain(config.registries_by_prefix.values())
|
||||
.map(String::as_str)
|
||||
.chain(BUILTIN_REGISTRIES_BY_PREFIX.iter().map(|(_, url)| *url))
|
||||
.collect();
|
||||
let unmatched = config
|
||||
.registry_options_by_url
|
||||
.keys()
|
||||
.filter(|registry| !configured.contains(registry.as_str()))
|
||||
.map(|registry| format!(r#""{}""#, redact_and_sanitize(registry)))
|
||||
.collect::<Vec<_>>();
|
||||
if unmatched.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// A registry URL can carry `user:pass@` credentials, so neither list may be
|
||||
// echoed raw into a terminal or a CI log.
|
||||
let configured = configured
|
||||
.iter()
|
||||
.map(|registry| format!(r#""{}""#, redact_and_sanitize(registry)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(format!(
|
||||
r#"The following "registries" entries do not match any configured registry and were ignored: {}. The configured registries are: {configured}."#,
|
||||
unmatched.join(", "),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::unmatched_registry_options_warning;
|
||||
use pnpm_config::Config;
|
||||
use pnpm_lockfile::{RegistryOptions, RegistryServerType};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn config_with(registries: &[(&str, &str)], registry_options_by_url: &[&str]) -> Config {
|
||||
let mut config = Config::new();
|
||||
config.registries_by_scope =
|
||||
registries.iter().map(|(scope, url)| ((*scope).to_string(), (*url).to_string())).collect();
|
||||
config.registry_options_by_url = registry_options_by_url
|
||||
.iter()
|
||||
.map(|registry| {
|
||||
(
|
||||
(*registry).to_string(),
|
||||
RegistryOptions { server_type: Some(RegistryServerType::Artifactory) },
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_warning_when_every_entry_matches_a_configured_registry() {
|
||||
let config = config_with(
|
||||
&[("default", "https://npm.example.com/"), ("@acme", "https://acme.example.com/")],
|
||||
&["https://npm.example.com/", "https://acme.example.com/"],
|
||||
);
|
||||
assert_eq!(unmatched_registry_options_warning(&config), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_warning_without_any_registry_options() {
|
||||
let config = config_with(&[("default", "https://npm.example.com/")], &[]);
|
||||
assert_eq!(unmatched_registry_options_warning(&config), None);
|
||||
}
|
||||
|
||||
/// A built-in named registry is a legitimate target even though the user never
|
||||
/// declared it, so an entry for it must not be reported as unmatched.
|
||||
#[test]
|
||||
fn no_warning_for_a_builtin_named_registry() {
|
||||
let config =
|
||||
config_with(&[("default", "https://npm.example.com/")], &["https://npm.pkg.github.com/"]);
|
||||
assert_eq!(unmatched_registry_options_warning(&config), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_about_an_entry_matching_no_configured_registry() {
|
||||
let config = config_with(
|
||||
&[("default", "https://npm.example.com/")],
|
||||
&["https://npm.example.com/", "https://typo.example.com/"],
|
||||
);
|
||||
let received = unmatched_registry_options_warning(&config).expect("a warning");
|
||||
assert!(
|
||||
received.contains(r#"were ignored: "https://typo.example.com/"."#),
|
||||
"the unmatched entry must be named: {received}",
|
||||
);
|
||||
assert!(
|
||||
received.contains(r#""https://npm.example.com/""#),
|
||||
"the configured registries must be listed: {received}",
|
||||
);
|
||||
}
|
||||
|
||||
/// A registry URL can carry `user:pass@` credentials, and this warning names
|
||||
/// every configured registry, so it must not echo one into a CI log.
|
||||
#[test]
|
||||
fn redacts_credentials_in_the_warning() {
|
||||
let config = config_with(
|
||||
&[("default", "https://ci-user-6e42:hunter2@npm.example.com/")],
|
||||
&["https://typo.example.com/"],
|
||||
);
|
||||
let received = unmatched_registry_options_warning(&config).expect("a warning");
|
||||
assert!(!received.contains("hunter2"), "the password must not be echoed: {received}");
|
||||
assert!(!received.contains("ci-user-6e42"), "the username must not be echoed: {received}");
|
||||
assert!(received.contains("npm.example.com"), "the host is still named: {received}");
|
||||
}
|
||||
@@ -4,14 +4,14 @@
|
||||
//! `buildDependenciesTree`.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use miette::{Context, IntoDiagnostic};
|
||||
use pnpm_fs::lexical_normalize;
|
||||
use pnpm_lockfile::{Lockfile, ProjectSnapshot};
|
||||
use pnpm_lockfile::{Lockfile, ProjectSnapshot, RegistryOptions};
|
||||
use pnpm_modules_yaml::{
|
||||
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, IncludedDependencies, Modules,
|
||||
read_modules_manifest,
|
||||
@@ -27,8 +27,6 @@ use super::{
|
||||
search::Searcher,
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org/";
|
||||
|
||||
/// The lockfiles and modules-manifest state one tree build runs
|
||||
/// against. Owns the loaded lockfiles; [`LoadedState::env`] borrows them.
|
||||
pub(crate) struct LoadedState {
|
||||
@@ -85,17 +83,14 @@ impl LoadedState {
|
||||
&'a self,
|
||||
lockfile_dir: &Path,
|
||||
virtual_store_dir_max_length: usize,
|
||||
registries_by_scope: &BTreeMap<String, String>,
|
||||
registry_options_by_url: BTreeMap<String, RegistryOptions>,
|
||||
) -> Option<PkgInfoEnv<'a>> {
|
||||
let lockfile = self.lockfile_to_use()?;
|
||||
let mut registries = HashMap::new();
|
||||
registries.insert("default".to_string(), DEFAULT_REGISTRY.to_string());
|
||||
if let Some(modules_registries) =
|
||||
self.modules.as_ref().and_then(|modules| modules.registries.as_ref())
|
||||
{
|
||||
for (key, url) in modules_registries {
|
||||
registries.insert(key.clone(), url.clone());
|
||||
}
|
||||
}
|
||||
let registries: HashMap<String, String> = registries_by_scope
|
||||
.iter()
|
||||
.map(|(scope, registry)| (scope.clone(), registry.clone()))
|
||||
.collect();
|
||||
let virtual_store_dir = match &self.modules {
|
||||
Some(modules) if !modules.virtual_store_dir.is_empty() => {
|
||||
let dir = PathBuf::from(&modules.virtual_store_dir);
|
||||
@@ -115,6 +110,7 @@ impl LoadedState {
|
||||
},
|
||||
),
|
||||
registries,
|
||||
registry_options_by_url,
|
||||
skipped: self
|
||||
.modules
|
||||
.as_ref()
|
||||
|
||||
@@ -67,6 +67,7 @@ fn mock_env<'a>(dir: &Path, lockfile: &'a Lockfile) -> PkgInfoEnv<'a> {
|
||||
modules_dir: dir.join("node_modules"),
|
||||
virtual_store_dir: dir.join("node_modules/.pnpm"),
|
||||
virtual_store_dir_max_length: 120,
|
||||
registry_options_by_url: std::collections::BTreeMap::new(),
|
||||
registries: HashMap::from([(
|
||||
"default".to_string(),
|
||||
"https://mock-registry-for-testing.example/".to_string(),
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
//! `resolvePackagePath`.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use pnpm_fs::{is_subdir, lexical_normalize};
|
||||
use pnpm_lockfile::{
|
||||
Lockfile, LockfileResolution, PkgNameVerPeer, npm_tarball_url, pick_registry_for_package,
|
||||
Lockfile, LockfileResolution, PkgNameVerPeer, RegistryOptions, TarballUrlOptions,
|
||||
npm_tarball_url, pick_registry_for_package, registry_server_type,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -32,6 +33,9 @@ pub(crate) struct PkgInfoEnv<'a> {
|
||||
pub virtual_store_dir_max_length: usize,
|
||||
/// Registry URLs keyed by `default` / `@scope`.
|
||||
pub registries: HashMap<String, String>,
|
||||
/// Per-registry tarball layouts from the `registries` setting, used
|
||||
/// when a lockfile entry's omitted tarball URL is rebuilt for display.
|
||||
pub registry_options_by_url: BTreeMap<String, RegistryOptions>,
|
||||
/// depPaths of packages skipped by the installer (unsupported
|
||||
/// platform optional deps).
|
||||
pub skipped: HashSet<String>,
|
||||
@@ -193,7 +197,14 @@ fn resolved_tarball_url(
|
||||
LockfileResolution::Tarball(tarball) => Some(tarball.tarball.clone()),
|
||||
LockfileResolution::Registry(_) => {
|
||||
let registry = pick_registry_for_package(&env.registries, name, None);
|
||||
Some(npm_tarball_url(name, version, ®istry))
|
||||
Some(npm_tarball_url(
|
||||
name,
|
||||
version,
|
||||
TarballUrlOptions {
|
||||
registry: ®istry,
|
||||
server_type: registry_server_type(&env.registry_options_by_url, ®istry),
|
||||
},
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fn get_pkg_info_handles_missing_pkg_snapshot_without_crashing() {
|
||||
modules_dir: PathBuf::new(),
|
||||
virtual_store_dir: PathBuf::from(".pnpm"),
|
||||
virtual_store_dir_max_length: 120,
|
||||
registry_options_by_url: std::collections::BTreeMap::new(),
|
||||
registries: HashMap::from([(
|
||||
"default".to_string(),
|
||||
"https://registry.npmjs.org/".to_string(),
|
||||
@@ -98,6 +99,7 @@ fn resolve_package_path_rejects_traversal_in_lockfile_derived_names() {
|
||||
modules_dir: dir.path().join("node_modules"),
|
||||
virtual_store_dir: virtual_store_dir.clone(),
|
||||
virtual_store_dir_max_length: 120,
|
||||
registry_options_by_url: std::collections::BTreeMap::new(),
|
||||
registries: HashMap::from([(
|
||||
"default".to_string(),
|
||||
"https://registry.npmjs.org/".to_string(),
|
||||
|
||||
@@ -596,7 +596,7 @@ async fn run_package_manager<Reporter: self::Reporter + 'static>(
|
||||
/// cache key.
|
||||
fn build_registries_map(config: &Config) -> BTreeMap<String, String> {
|
||||
let mut map = config.resolved_registries();
|
||||
for (name, url) in &config.named_registries {
|
||||
for (name, url) in &config.registries_by_prefix {
|
||||
map.insert(name.clone(), url.clone());
|
||||
}
|
||||
map
|
||||
|
||||
@@ -797,7 +797,8 @@ fn warn_global<Reporter: self::Reporter>(message: &str) {
|
||||
/// global cache key.
|
||||
fn registries_with_default(config: &Config) -> Vec<(String, String)> {
|
||||
let mut registries = vec![("default".to_string(), config.registry.clone())];
|
||||
registries.extend(config.registries.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
registries
|
||||
.extend(config.registries_by_scope.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
registries
|
||||
}
|
||||
|
||||
|
||||
@@ -921,7 +921,7 @@ async fn install_via_pnpr_inner<Reporter: self::Reporter + 'static>(
|
||||
} else {
|
||||
let verify_opts = VerifyLockfileOptions {
|
||||
registry: resolve_registry.clone(),
|
||||
named_registries: state.config.named_registries.clone(),
|
||||
registries: state.config.registry_declarations(),
|
||||
authorization: state.config.auth_headers.for_url(pnpr_server),
|
||||
overrides: overrides.clone(),
|
||||
lockfile: lockfile.clone(),
|
||||
@@ -1040,7 +1040,7 @@ async fn install_via_pnpr_inner<Reporter: self::Reporter + 'static>(
|
||||
let opts = ResolveProjectsOptions {
|
||||
projects,
|
||||
registry: resolve_registry,
|
||||
named_registries: state.config.named_registries.clone(),
|
||||
registries: state.config.registry_declarations(),
|
||||
// Only the caller's identity to pnpr is sent. Upstream registry
|
||||
// credentials are never forwarded: pnpr selects them from its own
|
||||
// route policy, so they stay out of the request body.
|
||||
|
||||
@@ -275,7 +275,12 @@ impl ListArgs {
|
||||
Some(config.modules_dir.as_path()),
|
||||
self.lockfile_only,
|
||||
)?;
|
||||
let env = state.env(lockfile_dir, config.virtual_store_dir_max_length as usize);
|
||||
let env = state.env(
|
||||
lockfile_dir,
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
&config.resolved_registries(),
|
||||
config.registry_options_by_url.clone(),
|
||||
);
|
||||
|
||||
let mut hierarchies: Vec<(PathBuf, DependenciesHierarchy)> = Vec::new();
|
||||
if self.depth == RecursionLimit::ProjectsOnly || env.is_none() {
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::{
|
||||
use crate::{
|
||||
State,
|
||||
cli_args::{
|
||||
config_warnings::warn_unmatched_registry_options,
|
||||
legacy_pnpm_field::warn_ignored_pnpm_manifest_fields,
|
||||
override_version_references::warn_deprecated_override_version_references,
|
||||
reporter::{ReporterType, reporter_emit},
|
||||
@@ -660,6 +661,7 @@ pub(crate) fn derive_config_root_and_package_manager_to_sync(
|
||||
// knows the root manifest's directory.
|
||||
warn_ignored_pnpm_manifest_fields(root_manifest.as_ref());
|
||||
warn_deprecated_override_version_references(cfg, reporter_emit(reporter));
|
||||
warn_unmatched_registry_options(cfg);
|
||||
let package_manager_to_sync = root_manifest
|
||||
.as_ref()
|
||||
.and_then(|manifest| package_manager_to_sync(manifest, &config_root, cfg.pm_on_fail));
|
||||
|
||||
@@ -333,7 +333,7 @@ impl PublishArgs {
|
||||
) -> PublishPackedPkgOptions {
|
||||
PublishPackedPkgOptions {
|
||||
default_registry: config.registry.clone(),
|
||||
scoped_registries: config.registries.clone(),
|
||||
scoped_registries: config.registries_by_scope.clone(),
|
||||
access: self.flags.access.as_deref().and_then(Access::parse),
|
||||
tag: self.flags.tag.clone().unwrap_or_else(|| "latest".to_owned()),
|
||||
otp,
|
||||
|
||||
@@ -169,9 +169,12 @@ async fn is_already_published(
|
||||
.get("publishConfig")
|
||||
.and_then(|publish_config| publish_config.get("registry"))
|
||||
.and_then(Value::as_str);
|
||||
let Ok(registry) =
|
||||
find_registry_info(name, &config.registry, &config.registries, publish_config_registry)
|
||||
else {
|
||||
let Ok(registry) = find_registry_info(
|
||||
name,
|
||||
&config.registry,
|
||||
&config.registries_by_scope,
|
||||
publish_config_registry,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let outcome = fetch_full_metadata(
|
||||
|
||||
@@ -341,7 +341,7 @@ mod tests;
|
||||
/// [`crate::config_deps`]'s `for_package_manager` context.
|
||||
fn apply_package_manager_bootstrap(cfg: &mut Config, bootstrap: &PackageManagerBootstrap) {
|
||||
cfg.registry.clone_from(&bootstrap.registry);
|
||||
cfg.registries.clone_from(&bootstrap.registries);
|
||||
cfg.registries_by_scope.clone_from(&bootstrap.registries);
|
||||
cfg.proxy.clone_from(&bootstrap.proxy);
|
||||
cfg.tls.clone_from(&bootstrap.tls);
|
||||
cfg.tls_by_uri.clone_from(&bootstrap.tls_by_uri);
|
||||
|
||||
@@ -108,9 +108,12 @@ impl WhyArgs {
|
||||
|
||||
let loaded =
|
||||
LoadedState::load(&lockfile_dir, Some(state.config.modules_dir.as_path()), false)?;
|
||||
let Some(env) =
|
||||
loaded.env(&lockfile_dir, state.config.virtual_store_dir_max_length as usize)
|
||||
else {
|
||||
let Some(env) = loaded.env(
|
||||
&lockfile_dir,
|
||||
state.config.virtual_store_dir_max_length as usize,
|
||||
&state.config.resolved_registries(),
|
||||
state.config.registry_options_by_url.clone(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let lockfile = env.current_lockfile;
|
||||
|
||||
@@ -347,7 +347,7 @@ impl EnvInstallerContext {
|
||||
};
|
||||
let resolver = NpmResolver {
|
||||
registries: registries.clone(),
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client: Arc::clone(&http_client),
|
||||
auth_headers: Arc::clone(&auth_headers),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
|
||||
@@ -197,7 +197,7 @@ impl ConfigOverrides {
|
||||
config.scope = Some(scope.clone());
|
||||
}
|
||||
for (scope, registry) in &self.registries {
|
||||
config.registries.insert(scope.clone(), registry.clone());
|
||||
config.registries_by_scope.insert(scope.clone(), registry.clone());
|
||||
config.package_manager_bootstrap.registries.insert(scope.clone(), registry.clone());
|
||||
}
|
||||
if let Some(value) = self.deploy_all_files {
|
||||
@@ -296,7 +296,7 @@ fn scoped_registry_key(key: &str) -> Option<&str> {
|
||||
pub(crate) fn apply_registry_override(config: &mut Config, registry: &str) {
|
||||
let registry = normalize_registry_url(registry);
|
||||
config.registry.clone_from(®istry);
|
||||
config.registries.insert("default".to_string(), registry.clone());
|
||||
config.registries_by_scope.insert("default".to_string(), registry.clone());
|
||||
config.package_manager_bootstrap.registry.clone_from(®istry);
|
||||
config.package_manager_bootstrap.registries.insert("default".to_string(), registry);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ fn extract_separates_config_tokens_from_argv() {
|
||||
overrides.apply(&mut config);
|
||||
assert_eq!(config.registry, "https://example.test/");
|
||||
assert_eq!(config.package_manager_bootstrap.registry, "https://example.test/");
|
||||
assert_eq!(config.registries.get("default").map(String::as_str), Some("https://example.test/"));
|
||||
assert_eq!(
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://example.test/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.package_manager_bootstrap.registries.get("default").map(String::as_str),
|
||||
Some("https://example.test/"),
|
||||
@@ -97,7 +100,10 @@ fn registry_cli_override_normalizes_and_sets_every_registry_slot() {
|
||||
// No trailing slash on the input; it is normalized on the way in.
|
||||
apply_registry_override(&mut config, "https://cli.example");
|
||||
assert_eq!(config.registry, "https://cli.example/");
|
||||
assert_eq!(config.registries.get("default").map(String::as_str), Some("https://cli.example/"));
|
||||
assert_eq!(
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://cli.example/"),
|
||||
);
|
||||
assert_eq!(config.package_manager_bootstrap.registry, "https://cli.example/");
|
||||
assert_eq!(
|
||||
config.package_manager_bootstrap.registries.get("default").map(String::as_str),
|
||||
@@ -116,7 +122,7 @@ fn extract_applies_scoped_registry_overrides() {
|
||||
let mut config = Config::default();
|
||||
overrides.apply(&mut config);
|
||||
assert_eq!(
|
||||
config.registries.get("@private").map(String::as_str),
|
||||
config.registries_by_scope.get("@private").map(String::as_str),
|
||||
Some("https://private.example/npm/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -130,14 +136,16 @@ fn scoped_registry_override_wins_over_existing_config() {
|
||||
let (overrides, _) =
|
||||
ConfigOverrides::extract(argv(["--config.@private:registry=https://cli.example/npm/"]));
|
||||
let mut config = Config::default();
|
||||
config.registries.insert("@private".to_string(), "https://workspace.example/npm/".to_string());
|
||||
config
|
||||
.registries_by_scope
|
||||
.insert("@private".to_string(), "https://workspace.example/npm/".to_string());
|
||||
config
|
||||
.package_manager_bootstrap
|
||||
.registries
|
||||
.insert("@private".to_string(), "https://json-env.example/npm/".to_string());
|
||||
overrides.apply(&mut config);
|
||||
assert_eq!(
|
||||
config.registries.get("@private").map(String::as_str),
|
||||
config.registries_by_scope.get("@private").map(String::as_str),
|
||||
Some("https://cli.example/npm/"),
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -16,6 +16,7 @@ pnpm-config-dir = { workspace = true }
|
||||
pnpm-detect-libc = { workspace = true }
|
||||
pnpm-env-replace = { workspace = true }
|
||||
pnpm-fs = { workspace = true }
|
||||
pnpm-lockfile = { workspace = true }
|
||||
pnpm-network = { workspace = true }
|
||||
pnpm-package-is-installable = { workspace = true }
|
||||
pnpm-package-manifest = { workspace = true }
|
||||
|
||||
@@ -23,6 +23,7 @@ pub use crate::{
|
||||
use crate::npmrc_auth::NpmrcAuth;
|
||||
use indexmap::IndexMap;
|
||||
use pipe_trait::Pipe;
|
||||
use pnpm_lockfile::RegistryOptions;
|
||||
use pnpm_patching::{
|
||||
CalcPatchHashError, PatchGroupRecord, ResolvePatchedDependenciesError, calc_patch_hashes,
|
||||
resolve_and_group,
|
||||
@@ -54,7 +55,9 @@ use crate::defaults::{
|
||||
pub use workspace_yaml::{
|
||||
AllowBuild, AuditSettings, GLOBAL_CONFIG_YAML_FILENAME, LoadWorkspaceYamlError,
|
||||
PackageExtension, PeerDependencyMeta, PeerDependencyRules, UpdateConfig, UpdateSettings,
|
||||
WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, decided_allow_builds, workspace_root_or,
|
||||
WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, decided_allow_builds,
|
||||
registries::{self, RegistryDeclaration, RegistryEntry, RegistryLookups},
|
||||
workspace_root_or,
|
||||
};
|
||||
|
||||
fn default_ci<Sys: EnvVar>(detect_ci: fn() -> bool) -> bool {
|
||||
@@ -1142,9 +1145,9 @@ pub struct Config {
|
||||
pub scope: Option<String>,
|
||||
|
||||
/// Scoped registry routes keyed by `@scope`, populated from
|
||||
/// `.npmrc` `@scope:registry=...` and
|
||||
/// `pnpm-workspace.yaml#registries`.
|
||||
pub registries: BTreeMap<String, String>,
|
||||
/// `.npmrc` `@scope:registry=...` and the scopes a
|
||||
/// `pnpm-workspace.yaml#registries` entry declares.
|
||||
pub registries_by_scope: BTreeMap<String, String>,
|
||||
|
||||
/// User-defined named-registry aliases from
|
||||
/// `pnpm-workspace.yaml#namedRegistries`. Maps each alias name
|
||||
@@ -1154,8 +1157,18 @@ pub struct Config {
|
||||
/// GitHub Packages) and rejects malformed URLs at construction
|
||||
/// time with `ERR_PNPM_INVALID_NAMED_REGISTRY_URL`.
|
||||
///
|
||||
/// The `namedRegistries` setting.
|
||||
pub named_registries: BTreeMap<String, String>,
|
||||
/// The `prefix` a `registries` entry declares, or the deprecated
|
||||
/// `namedRegistries` setting.
|
||||
pub registries_by_prefix: BTreeMap<String, String>,
|
||||
|
||||
/// Non-secret per-registry settings from
|
||||
/// `pnpm-workspace.yaml#registries`, keyed by registry URL with a
|
||||
/// trailing slash. Deliberately separate from the auth config: that one
|
||||
/// carries credentials, and the install and lockfile layers that need a
|
||||
/// registry's tarball layout must not be handed its secrets.
|
||||
///
|
||||
/// The `registries` setting.
|
||||
pub registry_options_by_url: BTreeMap<String, RegistryOptions>,
|
||||
|
||||
/// Resolved proxy configuration — `https-proxy`, `http-proxy`, and
|
||||
/// `no-proxy` (plus the legacy `proxy` key and env-var fallbacks),
|
||||
@@ -2081,6 +2094,21 @@ impl Config {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The registries this config declares, in the shape the `registries`
|
||||
/// setting is written in — what a pnpr server is told about them.
|
||||
///
|
||||
/// The default registry is not among them: it travels as the request's
|
||||
/// own `registry` field.
|
||||
#[must_use]
|
||||
pub fn registry_declarations(&self) -> BTreeMap<String, RegistryDeclaration> {
|
||||
registries::to_declarations(&RegistryLookups {
|
||||
registries_by_scope: self.registries_by_scope.clone(),
|
||||
default_registry: None,
|
||||
registries_by_prefix: self.registries_by_prefix.clone(),
|
||||
registry_options_by_url: self.registry_options_by_url.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Overlay the CLI's proxy flags onto the merged keys and re-resolve.
|
||||
///
|
||||
/// Only the empty string reads as unset here. A flag carries its value
|
||||
@@ -2172,7 +2200,7 @@ impl Config {
|
||||
/// configured scoped routes keyed by `@scope`.
|
||||
#[must_use]
|
||||
pub fn resolved_registries(&self) -> BTreeMap<String, String> {
|
||||
let mut registries = self.registries.clone();
|
||||
let mut registries = self.registries_by_scope.clone();
|
||||
registries.insert("default".to_string(), self.registry.clone());
|
||||
registries
|
||||
}
|
||||
@@ -2833,7 +2861,7 @@ impl Config {
|
||||
if let Some(registry) = env_registry_override {
|
||||
let normalized =
|
||||
if registry.ends_with('/') { registry } else { format!("{registry}/") };
|
||||
self.registries.insert("default".to_string(), normalized.clone());
|
||||
self.registries_by_scope.insert("default".to_string(), normalized.clone());
|
||||
self.package_manager_bootstrap.registry.clone_from(&normalized);
|
||||
self.package_manager_bootstrap.registries.insert("default".to_string(), normalized);
|
||||
}
|
||||
@@ -2991,7 +3019,7 @@ fn build_package_manager_bootstrap<Sys: EnvVar>(
|
||||
trusted_auth.build_auth_headers(&mut config)?;
|
||||
Ok(PackageManagerBootstrap {
|
||||
registry: config.registry,
|
||||
registries: config.registries,
|
||||
registries: config.registries_by_scope,
|
||||
proxy: config.proxy,
|
||||
proxy_keys: config.proxy_keys,
|
||||
tls: config.tls,
|
||||
|
||||
@@ -555,7 +555,7 @@ impl NpmrcAuth {
|
||||
config.registry =
|
||||
if registry.ends_with('/') { registry } else { format!("{registry}/") };
|
||||
}
|
||||
config.registries.append(&mut self.scoped_registries);
|
||||
config.registries_by_scope.append(&mut self.scoped_registries);
|
||||
for message in std::mem::take(&mut self.warnings) {
|
||||
tracing::warn!(target: "pacquet::npmrc", "{message}");
|
||||
}
|
||||
@@ -570,7 +570,7 @@ impl NpmrcAuth {
|
||||
if scope == "default" {
|
||||
config.registry.clone_from(&url);
|
||||
}
|
||||
config.registries.insert(scope, url);
|
||||
config.registries_by_scope.insert(scope, url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ fn parses_scoped_registry_and_applies() {
|
||||
let mut config = Config::new();
|
||||
auth.apply_to::<NoEnv>(&mut config);
|
||||
assert_eq!(
|
||||
config.registries.get("@private").map(String::as_str),
|
||||
config.registries_by_scope.get("@private").map(String::as_str),
|
||||
Some("https://private.example/npm/"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ namedRegistries:
|
||||
assert_eq!(config.registry, "https://trusted.example.com/npm/");
|
||||
assert_eq!(config.pnpr_server.as_deref(), Some("https://trusted.example.com/pnpr/"));
|
||||
assert_eq!(
|
||||
config.named_registries.get("work").map(String::as_str),
|
||||
config.registries_by_prefix.get("work").map(String::as_str),
|
||||
Some("https://trusted.example.com/work/"),
|
||||
);
|
||||
}
|
||||
@@ -700,7 +700,7 @@ pub fn json_env_default_scope_routes_default_registry() {
|
||||
|
||||
assert_eq!(config.registry, "https://my-npm-proxy.example/");
|
||||
assert_eq!(
|
||||
config.registries.get("default").map(String::as_str),
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://my-npm-proxy.example/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -723,7 +723,7 @@ pub fn json_env_scoped_entry_routes_that_scope() {
|
||||
let config = load_with_fake_env(project.path());
|
||||
|
||||
assert_eq!(
|
||||
config.registries.get("@org").map(String::as_str),
|
||||
config.registries_by_scope.get("@org").map(String::as_str),
|
||||
Some("https://npm.pkg.github.com/"),
|
||||
);
|
||||
}
|
||||
@@ -749,7 +749,7 @@ pub fn json_env_env_default_wins_over_workspace_yaml_default() {
|
||||
|
||||
assert_eq!(config.registry, "https://my-npm-proxy.example/");
|
||||
assert_eq!(
|
||||
config.registries.get("default").map(String::as_str),
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://my-npm-proxy.example/"),
|
||||
);
|
||||
}
|
||||
@@ -783,7 +783,7 @@ pub fn global_config_yaml_registries_cannot_redirect_json_env_token() {
|
||||
let config = load_with_fake_env(project.path());
|
||||
|
||||
assert_eq!(
|
||||
config.registries.get("@victim-scope").map(String::as_str),
|
||||
config.registries_by_scope.get("@victim-scope").map(String::as_str),
|
||||
Some("https://npm.pkg.github.com/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -833,7 +833,7 @@ pub fn global_config_yaml_auth_configures_registry_auth() {
|
||||
);
|
||||
assert_eq!(config.registry, "https://global-auth.example.com/");
|
||||
assert_eq!(
|
||||
config.registries.get("@org").map(String::as_str),
|
||||
config.registries_by_scope.get("@org").map(String::as_str),
|
||||
Some("https://global-auth.example.com/"),
|
||||
);
|
||||
}
|
||||
@@ -905,7 +905,7 @@ pub fn json_env_env_registry_flag_wins_over_json_env_default() {
|
||||
|
||||
assert_eq!(config.registry, "https://cli-registry.example/");
|
||||
assert_eq!(
|
||||
config.registries.get("default").map(String::as_str),
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://cli-registry.example/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -958,7 +958,7 @@ pub fn json_env_inferred_registries_flow_to_bootstrap() {
|
||||
/// End-to-end: a scoped env JSON entry overrides a repo-controlled
|
||||
/// `pnpm-workspace.yaml` scoped registry in the main cascade, and the
|
||||
/// token is pinned to the env-declared host. Asserts
|
||||
/// `config.registries["@scope"]` — not just auth headers — so a regression
|
||||
/// `config.registries_by_scope["@scope"]` — not just auth headers — so a regression
|
||||
/// that breaks routing while leaving auth-header pinning intact is caught.
|
||||
#[test]
|
||||
pub fn json_env_env_scoped_wins_over_workspace_yaml_scoped() {
|
||||
@@ -976,7 +976,7 @@ pub fn json_env_env_scoped_wins_over_workspace_yaml_scoped() {
|
||||
let config = load_with_fake_env(project.path());
|
||||
|
||||
assert_eq!(
|
||||
config.registries.get("@victim-scope").map(String::as_str),
|
||||
config.registries_by_scope.get("@victim-scope").map(String::as_str),
|
||||
Some("https://registry.npmjs.org/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1036,7 +1036,7 @@ pub fn json_env_overrides_user_bootstrap_scoped_registry() {
|
||||
Some("https://my-npm-proxy.example/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.registries.get("@org").map(String::as_str),
|
||||
config.registries_by_scope.get("@org").map(String::as_str),
|
||||
Some("https://my-npm-proxy.example/"),
|
||||
);
|
||||
}
|
||||
@@ -3253,7 +3253,7 @@ pub fn package_manager_bootstrap_ignores_workspace_yaml_registries() {
|
||||
"workspace yaml drives normal installs",
|
||||
);
|
||||
assert_eq!(
|
||||
config.registries.get("@evil").map(String::as_str),
|
||||
config.registries_by_scope.get("@evil").map(String::as_str),
|
||||
Some("https://attacker-scoped.example.com/"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -3310,7 +3310,7 @@ pub fn package_manager_bootstrap_honors_env_registry() {
|
||||
/// When `PNPM_CONFIG_REGISTRY` is set without a trailing slash, the env
|
||||
/// override normalizes it before storing — matching pnpm, which treats
|
||||
/// `https://r` and `https://r/` as the same registry. The slash must be
|
||||
/// appended consistently to `config.registry`, `config.registries`, and
|
||||
/// appended consistently to `config.registry`, `config.registries_by_scope`, and
|
||||
/// the bootstrap map so downstream lookups (auth-header pinning,
|
||||
/// `package_manager_bootstrap`) all key against the normalized form.
|
||||
#[test]
|
||||
@@ -3323,7 +3323,7 @@ pub fn env_registry_override_appends_missing_trailing_slash() {
|
||||
|
||||
assert_eq!(config.registry, "https://env.example.com/");
|
||||
assert_eq!(
|
||||
config.registries.get("default").map(String::as_str),
|
||||
config.registries_by_scope.get("default").map(String::as_str),
|
||||
Some("https://env.example.com/"),
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -11,9 +11,11 @@ use indexmap::IndexMap;
|
||||
use miette::Diagnostic;
|
||||
use pipe_trait::Pipe;
|
||||
use pnpm_env_replace::env_replace_lossy;
|
||||
use pnpm_network::redact_and_sanitize;
|
||||
use pnpm_package_is_installable::SupportedArchitectures;
|
||||
use pnpm_store_dir::StoreDir;
|
||||
use pnpm_workspace_state::ConfigDependency;
|
||||
use registries::RegistryEntry;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
@@ -37,6 +39,62 @@ where
|
||||
Option::<Value>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
/// Whether the authority of `url` carries a `user:pass@` prefix. The authority
|
||||
/// ends at the first `/`, `?`, or `#`, so a later `@` in the path is not one.
|
||||
///
|
||||
/// Both the full form and the scheme-less `//host/` form count. The latter is
|
||||
/// the shape `.npmrc` scopes settings with, so it is the one a user is most
|
||||
/// likely to reach for here.
|
||||
fn registry_url_has_userinfo(url: &str) -> bool {
|
||||
userinfo_end(url).is_some()
|
||||
}
|
||||
|
||||
/// The offset just past the `user:pass@` of `url`, or [`None`] when its
|
||||
/// authority carries none. Splitting it out keeps the detection and the
|
||||
/// redaction below agreeing on what the authority is.
|
||||
fn userinfo_end(url: &str) -> Option<usize> {
|
||||
let authority_start = authority_start_of(url)?;
|
||||
let authority = &url[authority_start..];
|
||||
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
|
||||
authority[..authority_end].rfind('@').map(|at| authority_start + at + 1)
|
||||
}
|
||||
|
||||
/// Where the authority of `url` begins, or [`None`] if it has none.
|
||||
///
|
||||
/// The scheme is anchored at the start rather than found by searching for the
|
||||
/// first `://`: a `://` inside the path (`//host/a://b`) would otherwise be
|
||||
/// taken for the separator, and the real authority — credentials and all —
|
||||
/// would go unexamined.
|
||||
fn authority_start_of(url: &str) -> Option<usize> {
|
||||
if let Some(scheme_end) = url.find("://") {
|
||||
let scheme = &url[..scheme_end];
|
||||
let mut chars = scheme.chars();
|
||||
let starts_with_letter = chars.next().is_some_and(|first| first.is_ascii_alphabetic());
|
||||
let rest_is_scheme = chars.all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '+' | '.' | '-')
|
||||
});
|
||||
if starts_with_letter && rest_is_scheme {
|
||||
return Some(scheme_end + "://".len());
|
||||
}
|
||||
}
|
||||
url.starts_with("//").then_some("//".len())
|
||||
}
|
||||
|
||||
/// `url` with any `user:pass@` removed, safe to put in a message.
|
||||
///
|
||||
/// [`redact_and_sanitize`] only recognizes an authority after a `://`, and
|
||||
/// deliberately so: it runs over arbitrary prose, where a bare `//` is more
|
||||
/// often a comment or a path than a URL. Here the string is known to be a
|
||||
/// registry URL, so the scheme-less `//host/` form can be handled too.
|
||||
fn redact_registry_url(url: &str) -> String {
|
||||
match (authority_start_of(url), userinfo_end(url)) {
|
||||
(Some(authority_start), Some(userinfo_end)) => {
|
||||
redact_and_sanitize(&format!("{}{}", &url[..authority_start], &url[userinfo_end..]))
|
||||
}
|
||||
_ => redact_and_sanitize(url),
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of an `allowBuilds` entry.
|
||||
///
|
||||
/// pnpm scaffolds an entry per ignored build with the placeholder string
|
||||
@@ -151,7 +209,10 @@ pub struct WorkspaceSettings {
|
||||
pub lockfile_include_tarball_url: Option<bool>,
|
||||
pub registry: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub registries: Option<BTreeMap<String, String>>,
|
||||
/// The registries the project declares. Keyed by registry URL, with the
|
||||
/// routes to each registry inside its entry; a map of plain strings is the
|
||||
/// older `<scope>: <url>` shape and is read as one.
|
||||
pub registries: Option<BTreeMap<String, RegistryEntry>>,
|
||||
pub pnpr_server: Option<String>,
|
||||
pub https_proxy: Option<String>,
|
||||
pub http_proxy: Option<String>,
|
||||
@@ -163,6 +224,10 @@ pub struct WorkspaceSettings {
|
||||
/// name (`gh`, `work`, ...); inner string is the registry URL the
|
||||
/// alias resolves against. Merged on top of pnpm's built-in
|
||||
/// defaults at resolver construction.
|
||||
///
|
||||
/// Deprecated in favor of the `prefix` field of a
|
||||
/// [`crate::RegistryDeclaration`],
|
||||
/// and only read for the prefixes `registries` does not declare.
|
||||
pub named_registries: Option<BTreeMap<String, String>>,
|
||||
|
||||
/// Structured registry auth (`_auth`). Honored **only** from the global
|
||||
@@ -745,6 +810,60 @@ pub enum LoadWorkspaceYamlError {
|
||||
#[error(source)]
|
||||
source: Box<serde_saphyr::Error>,
|
||||
},
|
||||
/// The registry URL is redacted before it reaches this variant.
|
||||
#[display("The \"registries\" key {registry} embeds credentials")]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help("Put them in an .npmrc file instead, so they are not committed.")
|
||||
)]
|
||||
CredentialsInRegistryKey { registry: String },
|
||||
/// The registry URL is redacted before it reaches this variant.
|
||||
#[display(
|
||||
r#"The "registries[{registry:?}].{field}" setting is not allowed in pnpm-workspace.yaml"#
|
||||
)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help("Set it in an .npmrc file instead, so it is not committed.")
|
||||
)]
|
||||
SecretInRegistryDeclaration { registry: String, field: String },
|
||||
/// The registry URL is redacted before it reaches this variant.
|
||||
#[display(r#"The "registries[{registry:?}].{field}" setting is not a known registry setting"#)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help(r#"A registry declares "serverType", "scopes", and "prefix"."#)
|
||||
)]
|
||||
UnknownRegistryDeclarationField { registry: String, field: String },
|
||||
#[display(r#"The "registries" setting mixes registry declarations with "<scope>: <url>" entries ({scopes})"#)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help(
|
||||
r#"Key every entry by registry URL and list the scopes routed to it under "scopes"."#
|
||||
)
|
||||
)]
|
||||
MixedRegistriesShapes { scopes: String },
|
||||
/// The registry URL is redacted before it reaches this variant.
|
||||
#[display(r#"The "registries[{registry:?}]" entry is a string"#)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help(
|
||||
r#"A registry URL keys a declaration, e.g. {{ serverType: "artifactory" }}. A string value routes a scope, and a URL is not a scope."#
|
||||
)
|
||||
)]
|
||||
StringValuedRegistryDeclaration { registry: String },
|
||||
/// The registry URL is redacted before it reaches this variant.
|
||||
#[display(r#"The "registries[{registry:?}].scopes" setting should list "@"-prefixed scopes, but got {scope:?}"#)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_INVALID_SETTING),
|
||||
help(r#"A bare "@" is the scope-less default registry."#)
|
||||
)]
|
||||
RegistryScopeWithoutAtSign { registry: String, scope: String },
|
||||
/// The registry URLs are redacted before they reach this variant.
|
||||
#[display("The scope {scope:?} is routed to two registries: {registries}")]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_SETTING))]
|
||||
ScopeRoutedTwice { scope: String, registries: String },
|
||||
#[display("The prefix {prefix:?} is declared by two registries")]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_SETTING))]
|
||||
PrefixDeclaredTwice { prefix: String },
|
||||
#[display("Invalid `_auth` setting: {source}")]
|
||||
InvalidJsonAuth {
|
||||
#[error(source)]
|
||||
@@ -810,10 +929,23 @@ impl WorkspaceSettings {
|
||||
let mut settings: WorkspaceSettings = serde_saphyr::from_str(&text)
|
||||
.map_err(Box::new)
|
||||
.map_err(|source| LoadWorkspaceYamlError::ParseYaml { path, source })?;
|
||||
settings.validate_registries()?;
|
||||
settings.clear_workspace_only_fields();
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
/// Reject a `registries` map pnpm would read as something other than what
|
||||
/// it says. See [`registries::validate`] for the rules.
|
||||
///
|
||||
/// Checked after parsing rather than in a `Deserialize` impl on purpose:
|
||||
/// `serde_saphyr` renders the offending source line verbatim under its
|
||||
/// errors, so rejecting at parse time would print the very credential
|
||||
/// being rejected into the terminal and any CI log.
|
||||
fn validate_registries(&self) -> Result<(), LoadWorkspaceYamlError> {
|
||||
let Some(entries) = self.registries.as_ref() else { return Ok(()) };
|
||||
registries::validate(entries)
|
||||
}
|
||||
|
||||
/// Zero out the release-age and trust policies for `self-update`.
|
||||
///
|
||||
/// `self-update` replaces the pnpm binary every later install runs
|
||||
@@ -849,6 +981,16 @@ impl WorkspaceSettings {
|
||||
/// hoisted` in `~/.config/pnpm/config.yaml` and pacquet would
|
||||
/// honor it while pnpm wouldn't — anti-parity.
|
||||
pub fn clear_workspace_only_fields(&mut self) {
|
||||
// Only the layout half of a registry declaration is workspace-only: it
|
||||
// decides which tarball URLs are omitted from the lockfile, so a
|
||||
// machine-local setting would make one developer write a lockfile
|
||||
// their collaborators read back with a different layout. The routes to
|
||||
// the registry are a legitimate global preference.
|
||||
for entry in self.registries.iter_mut().flat_map(BTreeMap::values_mut) {
|
||||
if let RegistryEntry::Declaration(declaration) = entry {
|
||||
declaration.server_type = None;
|
||||
}
|
||||
}
|
||||
self.versioning = None;
|
||||
self.hoist = None;
|
||||
self.hoist_pattern = None;
|
||||
@@ -909,10 +1051,11 @@ impl WorkspaceSettings {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => return Err(LoadWorkspaceYamlError::ReadFile { path, source }),
|
||||
};
|
||||
let settings = text
|
||||
let settings: WorkspaceSettings = text
|
||||
.pipe_as_ref(serde_saphyr::from_str)
|
||||
.map_err(Box::new)
|
||||
.map_err(|source| LoadWorkspaceYamlError::ParseYaml { path, source })?;
|
||||
settings.validate_registries()?;
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
@@ -943,7 +1086,7 @@ impl WorkspaceSettings {
|
||||
substitute_optional_string::<Sys>(&mut self.proxy);
|
||||
substitute_json_string::<Sys>(&mut self.no_proxy);
|
||||
substitute_json_string::<Sys>(&mut self.noproxy);
|
||||
substitute_optional_string_map::<Sys>(&mut self.registries);
|
||||
substitute_registry_entries::<Sys>(&mut self.registries);
|
||||
substitute_optional_string_map::<Sys>(&mut self.named_registries);
|
||||
}
|
||||
|
||||
@@ -963,11 +1106,12 @@ impl WorkspaceSettings {
|
||||
self.registry = None;
|
||||
}
|
||||
if let Some(registries) = self.registries.as_mut() {
|
||||
registries.retain(|_, value| !has_env_placeholder(value));
|
||||
registries::retain_without_env_placeholders(registries, has_env_placeholder);
|
||||
}
|
||||
if let Some(named_registries) = self.named_registries.as_mut() {
|
||||
named_registries.retain(|_, value| !has_env_placeholder(value));
|
||||
}
|
||||
|
||||
if self.pnpr_server.as_deref().is_some_and(has_env_placeholder) {
|
||||
self.pnpr_server = None;
|
||||
}
|
||||
@@ -1134,15 +1278,16 @@ impl WorkspaceSettings {
|
||||
if let Some(v) = self.store_dir {
|
||||
config.store_dir = StoreDir::from(resolve(base_dir, &v));
|
||||
}
|
||||
if let Some(registries) = self.registries {
|
||||
for (scope, registry) in registries {
|
||||
let registry = normalize_registry_url(®istry);
|
||||
if scope == "default" {
|
||||
config.registry = registry;
|
||||
} else {
|
||||
config.registries.insert(scope, registry);
|
||||
}
|
||||
let mut declared_prefixes = false;
|
||||
if let Some(entries) = self.registries {
|
||||
let lookups = registries::into_lookups(entries);
|
||||
declared_prefixes = !lookups.registries_by_prefix.is_empty();
|
||||
if let Some(registry) = lookups.default_registry {
|
||||
config.registry = registry;
|
||||
}
|
||||
config.registries_by_scope.extend(lookups.registries_by_scope);
|
||||
config.registries_by_prefix.extend(lookups.registries_by_prefix);
|
||||
config.registry_options_by_url.extend(lookups.registry_options_by_url);
|
||||
}
|
||||
if let Some(v) = self.registry {
|
||||
config.registry = normalize_registry_url(&v);
|
||||
@@ -1154,7 +1299,17 @@ impl WorkspaceSettings {
|
||||
config.pnpr_server = Some(v);
|
||||
}
|
||||
if let Some(v) = self.named_registries {
|
||||
config.named_registries = v;
|
||||
if declared_prefixes {
|
||||
tracing::warn!(
|
||||
target: "pacquet::config",
|
||||
r#"Both the "registries" and "namedRegistries" settings declare registry prefixes. The deprecated "namedRegistries" setting is only read for prefixes "registries" does not declare."#,
|
||||
);
|
||||
}
|
||||
// A prefix a `registries` entry declares wins: this is the
|
||||
// deprecated spelling of the same thing.
|
||||
for (name, registry) in v {
|
||||
config.registries_by_prefix.entry(name).or_insert(registry);
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor patch-file path resolution against the workspace dir
|
||||
@@ -1374,6 +1529,26 @@ fn substitute_optional_string_map<Sys: EnvVar>(value: &mut Option<BTreeMap<Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands `${VAR}` in the half of each `registries` entry that carries the
|
||||
/// request destination: the value of a scope route, the key of a declaration.
|
||||
fn substitute_registry_entries<Sys: EnvVar>(value: &mut Option<BTreeMap<String, RegistryEntry>>) {
|
||||
let Some(map) = value.take() else { return };
|
||||
*value = Some(
|
||||
map.into_iter()
|
||||
.map(|(key, entry)| match entry {
|
||||
RegistryEntry::ScopeRoute(url) => {
|
||||
let (substituted, _) = env_replace_lossy::<Sys>(&url);
|
||||
(key, RegistryEntry::ScopeRoute(substituted))
|
||||
}
|
||||
RegistryEntry::Declaration(declaration) => {
|
||||
let (substituted, _) = env_replace_lossy::<Sys>(&key);
|
||||
(substituted, RegistryEntry::Declaration(declaration))
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
fn substitute_optional_inner_string<Sys: EnvVar>(value: &mut Option<Option<String>>) {
|
||||
if let Some(Some(value)) = value {
|
||||
let (substituted, _) = env_replace_lossy::<Sys>(value);
|
||||
@@ -1413,5 +1588,7 @@ pub fn workspace_root_or(start: &Path) -> PathBuf {
|
||||
.unwrap_or_else(|| start.to_path_buf())
|
||||
}
|
||||
|
||||
pub mod registries;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,328 @@
|
||||
//! The `registries` setting: one entry per registry, keyed by its URL.
|
||||
//!
|
||||
//! Every fact in an entry is a fact about that server — how it lays out
|
||||
//! tarball URLs, and which routes reach it — so a registry is declared once
|
||||
//! and each fact is stated in one place. [`RegistryLookups`] is the inverse of
|
||||
//! the routes, because a scope resolves to exactly one registry while a
|
||||
//! registry serves many.
|
||||
|
||||
use super::LoadWorkspaceYamlError;
|
||||
use crate::workspace_yaml::{
|
||||
normalize_registry_url, redact_registry_url, registry_url_has_userinfo,
|
||||
};
|
||||
use pnpm_lockfile::{RegistryOptions, RegistryServerType};
|
||||
use serde::{
|
||||
Deserialize, Deserializer,
|
||||
de::{MapAccess, Visitor, value::MapAccessDeserializer},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt,
|
||||
};
|
||||
|
||||
/// The bare scope standing for the registry that packages resolve from when
|
||||
/// no scope matches — the one the `registry` setting names.
|
||||
pub const DEFAULT_REGISTRY_SCOPE: &str = "@";
|
||||
|
||||
/// Credentials and TLS material stay in `.npmrc`, which is not committed.
|
||||
/// `registries` lives in `pnpm-workspace.yaml`, which is, so accepting these
|
||||
/// would invite secrets into version control. Rejecting is better than
|
||||
/// ignoring: a silently dropped `_authToken` reads as configured.
|
||||
const SECRET_REGISTRY_FIELDS: &[&str] = &[
|
||||
"_auth",
|
||||
"_authToken",
|
||||
"_password",
|
||||
"username",
|
||||
"tokenHelper",
|
||||
"ca",
|
||||
"cafile",
|
||||
"cert",
|
||||
"certfile",
|
||||
"key",
|
||||
"keyfile",
|
||||
];
|
||||
|
||||
/// One entry of the `registries` map.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RegistryEntry {
|
||||
/// The older `<scope>: <url>` shape, which is the scope lookup already.
|
||||
ScopeRoute(String),
|
||||
Declaration(RegistryDeclaration),
|
||||
}
|
||||
|
||||
/// Everything a project declares about one registry.
|
||||
///
|
||||
/// Unknown fields are captured rather than refused by serde on purpose, for
|
||||
/// the same reason this module validates after parsing: a parse error renders
|
||||
/// the offending source line verbatim, so refusing an `_authToken` field at
|
||||
/// parse time would print the very credential being refused.
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegistryDeclaration {
|
||||
#[serde(default)]
|
||||
pub server_type: Option<RegistryServerType>,
|
||||
/// The scopes routed here, `@`-prefixed. A bare `@` is the scope-less
|
||||
/// default registry, the one the `registry` setting names.
|
||||
#[serde(default)]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// The bare-specifier prefix this registry answers to, as in
|
||||
/// `"foo": "work:^1.0.0"`.
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub unknown: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Eq for RegistryDeclaration {}
|
||||
|
||||
impl<'de> Deserialize<'de> for RegistryEntry {
|
||||
fn deserialize<Deser: Deserializer<'de>>(deserializer: Deser) -> Result<Self, Deser::Error> {
|
||||
deserializer.deserialize_any(RegistryEntryVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Written out rather than derived as `#[serde(untagged)]` so that the error
|
||||
/// from a malformed declaration survives: an untagged enum reports only that
|
||||
/// no variant matched, which would hide, say, a `scopes` that is not a list.
|
||||
struct RegistryEntryVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for RegistryEntryVisitor {
|
||||
type Value = RegistryEntry;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a registry URL or a registry declaration")
|
||||
}
|
||||
|
||||
fn visit_str<Err: serde::de::Error>(self, value: &str) -> Result<Self::Value, Err> {
|
||||
Ok(RegistryEntry::ScopeRoute(value.to_owned()))
|
||||
}
|
||||
|
||||
fn visit_map<Map: MapAccess<'de>>(self, map: Map) -> Result<Self::Value, Map::Error> {
|
||||
RegistryDeclaration::deserialize(MapAccessDeserializer::new(map))
|
||||
.map(RegistryEntry::Declaration)
|
||||
}
|
||||
}
|
||||
|
||||
/// The three lookups the rest of pnpm reads, split out of the declarations.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct RegistryLookups {
|
||||
/// Scope-routed URLs, `@`-prefixed and normalized.
|
||||
pub registries_by_scope: BTreeMap<String, String>,
|
||||
/// The registry a bare `@` routed to, if any.
|
||||
pub default_registry: Option<String>,
|
||||
/// Prefix-addressed URLs, deliberately kept as written: a named
|
||||
/// registry's URL is what a lockfile's recorded tarball URLs are matched
|
||||
/// against, so normalizing it would change what an existing lockfile
|
||||
/// verifies against.
|
||||
pub registries_by_prefix: BTreeMap<String, String>,
|
||||
pub registry_options_by_url: BTreeMap<String, RegistryOptions>,
|
||||
}
|
||||
|
||||
/// Reject a `registries` map pnpm would otherwise read as something other
|
||||
/// than what it says.
|
||||
pub fn validate(entries: &BTreeMap<String, RegistryEntry>) -> Result<(), LoadWorkspaceYamlError> {
|
||||
let scope_routes: Vec<&String> = entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| matches!(entry, RegistryEntry::ScopeRoute(_)))
|
||||
.map(|(scope, _)| scope)
|
||||
.collect();
|
||||
if scope_routes.len() == entries.len() {
|
||||
for scope in scope_routes {
|
||||
// A scope routes to a registry, so a URL in that position routes
|
||||
// nothing and would sit there inert. It is the declaration shape,
|
||||
// half-written.
|
||||
if looks_like_registry_url(scope) {
|
||||
return Err(LoadWorkspaceYamlError::StringValuedRegistryDeclaration {
|
||||
registry: redact_registry_url(scope),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !scope_routes.is_empty() {
|
||||
return Err(LoadWorkspaceYamlError::MixedRegistriesShapes {
|
||||
scopes: quote_and_join(scope_routes.into_iter().map(String::as_str)),
|
||||
});
|
||||
}
|
||||
|
||||
validate_declarations(entries.iter().filter_map(|(registry, entry)| match entry {
|
||||
RegistryEntry::Declaration(declaration) => Some((registry, declaration)),
|
||||
RegistryEntry::ScopeRoute(_) => None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The per-declaration half of [`validate`], over declarations alone.
|
||||
///
|
||||
/// A pnpr request carries this shape and no other: its `registries` map is
|
||||
/// always keyed by URL, which is what lets the server's boundary checks read
|
||||
/// a key as a fetch target.
|
||||
pub fn validate_declarations<'a>(
|
||||
entries: impl IntoIterator<Item = (&'a String, &'a RegistryDeclaration)>,
|
||||
) -> Result<(), LoadWorkspaceYamlError> {
|
||||
let mut routed_scopes: BTreeMap<&str, String> = BTreeMap::new();
|
||||
let mut declared_prefixes: BTreeSet<&str> = BTreeSet::new();
|
||||
for (registry, declaration) in entries {
|
||||
let redacted = redact_registry_url(registry);
|
||||
if let Some(field) = declaration
|
||||
.unknown
|
||||
.keys()
|
||||
.find(|field| SECRET_REGISTRY_FIELDS.contains(&field.as_str()))
|
||||
{
|
||||
return Err(LoadWorkspaceYamlError::SecretInRegistryDeclaration {
|
||||
registry: redacted,
|
||||
field: field.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(field) = declaration.unknown.keys().next() {
|
||||
return Err(LoadWorkspaceYamlError::UnknownRegistryDeclarationField {
|
||||
registry: redacted,
|
||||
field: field.clone(),
|
||||
});
|
||||
}
|
||||
// The map lives in the committed pnpm-workspace.yaml, and it already
|
||||
// refuses credential fields for that reason; a credential in the key
|
||||
// is the same secret in the same file.
|
||||
if registry_url_has_userinfo(registry) {
|
||||
return Err(LoadWorkspaceYamlError::CredentialsInRegistryKey { registry: redacted });
|
||||
}
|
||||
let normalized = normalize_registry_url(registry);
|
||||
for scope in declaration.scopes.iter().flatten() {
|
||||
if !scope.starts_with(DEFAULT_REGISTRY_SCOPE) {
|
||||
return Err(LoadWorkspaceYamlError::RegistryScopeWithoutAtSign {
|
||||
registry: redacted,
|
||||
scope: scope.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(other) = routed_scopes.get(scope.as_str())
|
||||
&& other != &normalized
|
||||
{
|
||||
return Err(LoadWorkspaceYamlError::ScopeRoutedTwice {
|
||||
scope: scope.clone(),
|
||||
registries: quote_and_join([other.as_str(), normalized.as_str()]),
|
||||
});
|
||||
}
|
||||
routed_scopes.insert(scope, normalized.clone());
|
||||
}
|
||||
if let Some(prefix) = declaration.prefix.as_deref()
|
||||
&& !declared_prefixes.insert(prefix)
|
||||
{
|
||||
return Err(LoadWorkspaceYamlError::PrefixDeclaredTwice { prefix: prefix.to_owned() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split validated declarations into the lookups. Infallible: every rejection
|
||||
/// happens in [`validate`], at load time, where the offending file is known.
|
||||
#[must_use]
|
||||
pub fn into_lookups(entries: BTreeMap<String, RegistryEntry>) -> RegistryLookups {
|
||||
let mut lookups = RegistryLookups::default();
|
||||
let mut declarations = BTreeMap::new();
|
||||
for (registry, entry) in entries {
|
||||
match entry {
|
||||
RegistryEntry::ScopeRoute(url) => {
|
||||
let url = normalize_registry_url(&url);
|
||||
if registry == "default" {
|
||||
lookups.default_registry = Some(url);
|
||||
} else {
|
||||
lookups.registries_by_scope.insert(registry, url);
|
||||
}
|
||||
}
|
||||
RegistryEntry::Declaration(declaration) => {
|
||||
declarations.insert(registry, declaration);
|
||||
}
|
||||
}
|
||||
}
|
||||
extend_lookups_with_declarations(&mut lookups, declarations);
|
||||
lookups
|
||||
}
|
||||
|
||||
/// The declarations-only half of [`into_lookups`], for a pnpr request, whose
|
||||
/// `registries` map is always keyed by URL.
|
||||
#[must_use]
|
||||
pub fn declarations_into_lookups(
|
||||
entries: BTreeMap<String, RegistryDeclaration>,
|
||||
) -> RegistryLookups {
|
||||
let mut lookups = RegistryLookups::default();
|
||||
extend_lookups_with_declarations(&mut lookups, entries);
|
||||
lookups
|
||||
}
|
||||
|
||||
fn extend_lookups_with_declarations(
|
||||
lookups: &mut RegistryLookups,
|
||||
entries: BTreeMap<String, RegistryDeclaration>,
|
||||
) {
|
||||
for (registry, declaration) in entries {
|
||||
let normalized = normalize_registry_url(®istry);
|
||||
if let Some(server_type) = declaration.server_type {
|
||||
lookups
|
||||
.registry_options_by_url
|
||||
.insert(normalized.clone(), RegistryOptions { server_type: Some(server_type) });
|
||||
}
|
||||
for scope in declaration.scopes.into_iter().flatten() {
|
||||
if scope == DEFAULT_REGISTRY_SCOPE {
|
||||
lookups.default_registry = Some(normalized.clone());
|
||||
} else {
|
||||
lookups.registries_by_scope.insert(scope, normalized.clone());
|
||||
}
|
||||
}
|
||||
if let Some(prefix) = declaration.prefix {
|
||||
lookups.registries_by_prefix.insert(prefix, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the declarations from the lookups they were split into, for a
|
||||
/// client that has to describe its registries to a pnpr server.
|
||||
///
|
||||
/// The inverse of [`into_lookups`], minus the default registry: that one
|
||||
/// travels as the request's own `registry` field, so a bare
|
||||
/// [`DEFAULT_REGISTRY_SCOPE`] is not re-emitted here.
|
||||
///
|
||||
/// Entries are keyed by the URL each lookup holds rather than by a normalized
|
||||
/// one, so a registry a prefix addresses without a trailing slash stays the
|
||||
/// URL the client resolves against.
|
||||
#[must_use]
|
||||
pub fn to_declarations(lookups: &RegistryLookups) -> BTreeMap<String, RegistryDeclaration> {
|
||||
let mut declarations: BTreeMap<String, RegistryDeclaration> = BTreeMap::new();
|
||||
for (scope, registry) in &lookups.registries_by_scope {
|
||||
if scope == "default" {
|
||||
continue;
|
||||
}
|
||||
declarations
|
||||
.entry(registry.clone())
|
||||
.or_default()
|
||||
.scopes
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(scope.clone());
|
||||
}
|
||||
for (prefix, registry) in &lookups.registries_by_prefix {
|
||||
declarations.entry(registry.clone()).or_default().prefix = Some(prefix.clone());
|
||||
}
|
||||
for (registry, options) in &lookups.registry_options_by_url {
|
||||
declarations.entry(registry.clone()).or_default().server_type = options.server_type;
|
||||
}
|
||||
declarations
|
||||
}
|
||||
|
||||
/// Drop the entries whose request destination carries an unexpanded `${VAR}`.
|
||||
/// The destination is the value of a scope route and the key of a
|
||||
/// declaration, so which half is gated follows the entry's shape.
|
||||
pub fn retain_without_env_placeholders(
|
||||
entries: &mut BTreeMap<String, RegistryEntry>,
|
||||
has_env_placeholder: impl Fn(&str) -> bool,
|
||||
) {
|
||||
entries.retain(|registry, entry| match entry {
|
||||
RegistryEntry::ScopeRoute(url) => !has_env_placeholder(url),
|
||||
RegistryEntry::Declaration(_) => !has_env_placeholder(registry),
|
||||
});
|
||||
}
|
||||
|
||||
fn looks_like_registry_url(key: &str) -> bool {
|
||||
key.contains("://") || key.starts_with("//")
|
||||
}
|
||||
|
||||
fn quote_and_join<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
|
||||
values.into_iter().map(|value| format!("{value:?}")).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
use super::{AllowBuild, LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings};
|
||||
use super::{
|
||||
AllowBuild, LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings,
|
||||
registries::{RegistryDeclaration, RegistryEntry},
|
||||
};
|
||||
use crate::{
|
||||
AuditLevel, CatalogMode, Config, GlobalShims, GlobalShimsSetting, HoistingLimits,
|
||||
LinkWorkspacePackages, NodeLinker, NodePackageMapType, ResolutionMode, ScriptsPrependNodePath,
|
||||
ShimPolicy, TrustPolicy, api::EnvVar,
|
||||
};
|
||||
use pipe_trait::Pipe;
|
||||
use pnpm_lockfile::{RegistryOptions, RegistryServerType};
|
||||
use pnpm_store_dir::StoreDir;
|
||||
use pnpm_workspace_state::{ConfigDependency, ConfigDependencyDetail};
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::{fs, path::Path};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
#[test]
|
||||
fn parses_common_settings_from_yaml() {
|
||||
@@ -274,10 +278,9 @@ userAgent: my-agent/2.0
|
||||
assert_eq!(config.user_agent, "my-agent/2.0");
|
||||
}
|
||||
|
||||
/// `namedRegistries` is the per-alias registry-URL map from
|
||||
/// `pnpm-workspace.yaml`. The deserializer reads the camelCase key
|
||||
/// and `apply_to` writes the map onto [`Config::named_registries`]
|
||||
/// verbatim.
|
||||
/// `namedRegistries` is the deprecated spelling of a registry's `prefix`. The
|
||||
/// deserializer reads the camelCase key it still carries, and `apply_to`
|
||||
/// writes the map onto [`Config::registries_by_prefix`] verbatim.
|
||||
#[test]
|
||||
fn parses_named_registries_from_yaml_and_applies() {
|
||||
let yaml = r"
|
||||
@@ -286,18 +289,18 @@ namedRegistries:
|
||||
work: https://npm.work.example.com/
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let named = settings.named_registries.as_ref().expect("named_registries present");
|
||||
let named = settings.named_registries.as_ref().expect("namedRegistries present");
|
||||
assert_eq!(named.get("gh").map(String::as_str), Some("https://npm.pkg.ghes.example.com/"));
|
||||
assert_eq!(named.get("work").map(String::as_str), Some("https://npm.work.example.com/"));
|
||||
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(
|
||||
config.named_registries.get("gh").map(String::as_str),
|
||||
config.registries_by_prefix.get("gh").map(String::as_str),
|
||||
Some("https://npm.pkg.ghes.example.com/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.named_registries.get("work").map(String::as_str),
|
||||
config.registries_by_prefix.get("work").map(String::as_str),
|
||||
Some("https://npm.work.example.com/"),
|
||||
);
|
||||
}
|
||||
@@ -312,19 +315,19 @@ registries:
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let registries = settings.registries.as_ref().expect("registries present");
|
||||
assert_eq!(
|
||||
registries.get("default").map(String::as_str),
|
||||
Some("https://default.example.com/npm"),
|
||||
registries.get("default"),
|
||||
Some(&RegistryEntry::ScopeRoute("https://default.example.com/npm".to_owned())),
|
||||
);
|
||||
assert_eq!(
|
||||
registries.get("@private").map(String::as_str),
|
||||
Some("https://private.example.com/npm"),
|
||||
registries.get("@private"),
|
||||
Some(&RegistryEntry::ScopeRoute("https://private.example.com/npm".to_owned())),
|
||||
);
|
||||
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(config.registry, "https://default.example.com/npm/");
|
||||
assert_eq!(
|
||||
config.registries.get("@private").map(String::as_str),
|
||||
config.registries_by_scope.get("@private").map(String::as_str),
|
||||
Some("https://private.example.com/npm/"),
|
||||
);
|
||||
}
|
||||
@@ -365,19 +368,19 @@ namedRegistries:
|
||||
assert_eq!(config.registry, "https://registry.npmjs.org/");
|
||||
assert_eq!(config.proxy, pnpm_network::ProxyConfig::default());
|
||||
assert_eq!(
|
||||
config.registries.get("@safe").map(String::as_str),
|
||||
config.registries_by_scope.get("@safe").map(String::as_str),
|
||||
Some("https://safe.example.com/npm/"),
|
||||
);
|
||||
assert_eq!(config.registries.get("@work"), None);
|
||||
assert_eq!(config.registries_by_scope.get("@work"), None);
|
||||
assert_eq!(
|
||||
config.named_registries.get("stable").map(String::as_str),
|
||||
config.registries_by_prefix.get("stable").map(String::as_str),
|
||||
Some("https://registry.example.com/npm/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.named_registries.get("literal").map(String::as_str),
|
||||
config.registries_by_prefix.get("literal").map(String::as_str),
|
||||
Some("https://registry.example.com/${/npm/"),
|
||||
);
|
||||
assert_eq!(config.named_registries.get("work"), None);
|
||||
assert_eq!(config.registries_by_prefix.get("work"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -449,11 +452,11 @@ namedRegistries:
|
||||
Some(pnpm_network::NoProxySetting::List(vec!["internal.example.com".to_string()])),
|
||||
);
|
||||
assert_eq!(
|
||||
config.named_registries.get("stable").map(String::as_str),
|
||||
config.registries_by_prefix.get("stable").map(String::as_str),
|
||||
Some("https://registry.example.com/npm/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.named_registries.get("work").map(String::as_str),
|
||||
config.registries_by_prefix.get("work").map(String::as_str),
|
||||
Some("https://internal.example.com/work/"),
|
||||
);
|
||||
}
|
||||
@@ -1869,3 +1872,419 @@ fn parses_save_settings_from_yaml_and_applies() {
|
||||
assert_eq!(global.save_peer, None);
|
||||
assert_eq!(global.save_catalog_name, None);
|
||||
}
|
||||
|
||||
/// `apply_to` keys the per-registry options by registry URL with a trailing
|
||||
/// slash so a lookup by the registry a package resolved from matches.
|
||||
#[test]
|
||||
fn parses_registry_declarations_from_yaml_and_normalizes_the_keys() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://artifactory.example/artifactory/api/npm/npm-virtual: {serverType: artifactory}
|
||||
https://npm.example.com/: {serverType: npm}
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(
|
||||
config
|
||||
.registry_options_by_url
|
||||
.get("https://artifactory.example/artifactory/api/npm/npm-virtual/")
|
||||
.map(|options| options.server_type),
|
||||
Some(Some(RegistryServerType::Artifactory)),
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.registry_options_by_url
|
||||
.get("https://npm.example.com/")
|
||||
.map(|options| options.server_type),
|
||||
Some(Some(RegistryServerType::Npm)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Credentials belong in `.npmrc`, which is not committed. Refused after
|
||||
/// parsing, not by `deny_unknown_fields`: a parse error renders the offending
|
||||
/// source line verbatim, which would print the very token being refused.
|
||||
#[test]
|
||||
fn rejects_credentials_in_a_registry_declaration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.example.com/: {_authToken: hunter2}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("credentials in a declaration must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("_authToken"), "the field is named: {error}");
|
||||
assert!(!error.contains("hunter2"), "the token must not be echoed: {error}");
|
||||
}
|
||||
|
||||
/// A misspelled field would otherwise sit there doing nothing.
|
||||
#[test]
|
||||
fn rejects_an_unknown_registry_declaration_field() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.example.com/: {scope: '@acme'}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("an unknown declaration field must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("scope"), "the field is named: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_registry_server_type() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://npm.example.com/: {serverType: nexus}
|
||||
";
|
||||
let received = serde_saphyr::from_str::<WorkspaceSettings>(yaml);
|
||||
assert!(received.is_err(), "an unknown serverType must not parse");
|
||||
}
|
||||
|
||||
/// The registry URL is the key here, so the untrusted-environment gate has to
|
||||
/// drop the entry rather than expanding a placeholder into a request URL. The
|
||||
/// variable resolves, so a dropped entry is distinguishable from one expanded
|
||||
/// to an empty string — expanding it is how a token would reach an
|
||||
/// attacker-chosen host from a committed pnpm-workspace.yaml.
|
||||
#[test]
|
||||
fn drops_a_registry_declaration_whose_url_has_an_env_placeholder() {
|
||||
struct EnvWithToken;
|
||||
impl EnvVar for EnvWithToken {
|
||||
fn var(name: &str) -> Option<String> {
|
||||
(name == "PNPM_TEST_REGISTRY_TOKEN").then(|| "super-secret-token".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://evil.example.com/${PNPM_TEST_REGISTRY_TOKEN}/: {serverType: artifactory}
|
||||
https://npm.example.com/: {serverType: artifactory}
|
||||
";
|
||||
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
settings.substitute_env_untrusted::<EnvWithToken>();
|
||||
let entries = settings.registries.as_ref().expect("registries present");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries.contains_key("https://npm.example.com/"));
|
||||
assert!(
|
||||
!entries.keys().any(|registry| registry.contains("super-secret-token")),
|
||||
"the token must never be expanded into a registry URL: {entries:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// A declared `serverType` is workspace-only: it decides which tarball URLs
|
||||
/// are omitted from the lockfile, so a user's global `config.yaml` must not
|
||||
/// shape a lockfile their collaborators read back with a different layout. The
|
||||
/// routes the same entry declares are a legitimate global preference and stay.
|
||||
#[test]
|
||||
fn registry_server_type_cleared_as_workspace_only_field() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://artifactory.example/artifactory/api/npm/npm-virtual/:
|
||||
serverType: artifactory
|
||||
scopes: ['@acme']
|
||||
";
|
||||
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
settings.clear_workspace_only_fields();
|
||||
let entries = settings.registries.as_ref().expect("registries present");
|
||||
let RegistryEntry::Declaration(declaration) = entries.values().next().expect("one declaration")
|
||||
else {
|
||||
panic!("expected a declaration: {entries:?}")
|
||||
};
|
||||
assert_eq!(declaration.server_type, None);
|
||||
assert_eq!(declaration.scopes.as_deref(), Some(["@acme".to_owned()].as_slice()));
|
||||
}
|
||||
|
||||
/// A credential in the key is the same secret in the same committed file as a
|
||||
/// credential in a field, so both are refused. The check runs after parsing so
|
||||
/// the error carries a redacted URL instead of serde's verbatim source line.
|
||||
#[test]
|
||||
fn rejects_a_registry_key_that_embeds_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://ci-user-6e42:hunter2@npm.example.com/: {serverType: artifactory}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a key with credentials must not load")
|
||||
.to_string();
|
||||
assert!(!error.contains("hunter2"), "the password must not be echoed: {error}");
|
||||
assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}");
|
||||
assert!(error.contains("npm.example.com"), "the host is still named: {error}");
|
||||
}
|
||||
|
||||
/// `.npmrc` scopes settings with a scheme-less `//host/`, and this setting's
|
||||
/// own error points users at that syntax, so it is the form they are most
|
||||
/// likely to write — and it must not slip past the check.
|
||||
#[test]
|
||||
fn rejects_a_scheme_less_registry_key_that_embeds_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n //ci-user-6e42:hunter2@npm.example.com/: {serverType: artifactory}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a scheme-less key with credentials must not load")
|
||||
.to_string();
|
||||
assert!(!error.contains("hunter2"), "the password must not be echoed: {error}");
|
||||
assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}");
|
||||
}
|
||||
|
||||
/// A later `@` in the path is not userinfo.
|
||||
#[test]
|
||||
fn accepts_a_registry_key_with_an_at_sign_in_the_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.example.com/scope@1/: {serverType: artifactory}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = WorkspaceSettings::load_at(dir.path()).unwrap().expect("settings");
|
||||
assert!(settings.registries.is_some());
|
||||
}
|
||||
|
||||
/// Searching for the first `://` would find the one in the path and parse the
|
||||
/// authority from there, leaving the real credentials unexamined.
|
||||
#[test]
|
||||
fn rejects_a_scheme_less_key_whose_path_contains_a_scheme_separator() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n '//ci-user-6e42:hunter2@npm.example.com/a://b': {serverType: artifactory}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("credentials must not slip past a path scheme separator")
|
||||
.to_string();
|
||||
assert!(!error.contains("hunter2"), "the password must not be echoed: {error}");
|
||||
assert!(!error.contains("ci-user-6e42"), "the username must not be echoed: {error}");
|
||||
}
|
||||
|
||||
/// A scope resolves to one registry while a registry serves many, so the
|
||||
/// declaration reads the way it is written and the lookup is its inverse. A
|
||||
/// bare `@` is the scope-less default registry.
|
||||
#[test]
|
||||
fn routes_the_scopes_a_registry_declares() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://npm.corp.example:
|
||||
serverType: npm
|
||||
scopes: ['@', '@foo', '@bar']
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(config.registry, "https://npm.corp.example/");
|
||||
assert_eq!(
|
||||
config.registries_by_scope.get("@foo").map(String::as_str),
|
||||
Some("https://npm.corp.example/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.registries_by_scope.get("@bar").map(String::as_str),
|
||||
Some("https://npm.corp.example/"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_scope_declared_without_its_at_sign() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.corp.example/: {scopes: [foo]}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a scope without its @ must not load")
|
||||
.to_string();
|
||||
assert!(error.contains(r#""foo""#), "the scope is named: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_one_scope_routed_to_two_registries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.corp.example/: {scopes: ['@foo']}\n https://artifactory.example/: {scopes: ['@foo']}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a scope routed twice must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("routed to two registries"), "{error}");
|
||||
}
|
||||
|
||||
/// Keyed by the URL as written: a named registry's URL is what a lockfile's
|
||||
/// recorded tarball URLs are matched against, so normalizing it here would
|
||||
/// change what an existing lockfile verifies against.
|
||||
#[test]
|
||||
fn reads_a_declared_prefix_as_a_named_registry() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://npm.corp.example: {prefix: work, serverType: artifactory}
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(
|
||||
config.registries_by_prefix.get("work").map(String::as_str),
|
||||
Some("https://npm.corp.example"),
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.registry_options_by_url
|
||||
.get("https://npm.corp.example/")
|
||||
.map(|options| options.server_type),
|
||||
Some(Some(RegistryServerType::Artifactory)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_one_prefix_declared_by_two_registries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.corp.example/: {prefix: work}\n https://artifactory.example/: {prefix: work}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a prefix declared twice must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("declared by two registries"), "{error}");
|
||||
}
|
||||
|
||||
/// The deprecated spelling of the same thing, so a declared prefix wins.
|
||||
#[test]
|
||||
fn a_declared_prefix_wins_over_named_registries() {
|
||||
let yaml = r"
|
||||
namedRegistries:
|
||||
work: https://stale.example/
|
||||
other: https://other.example/
|
||||
registries:
|
||||
https://npm.corp.example/: {prefix: work}
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(
|
||||
config.registries_by_prefix.get("work").map(String::as_str),
|
||||
Some("https://npm.corp.example/"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.registries_by_prefix.get("other").map(String::as_str),
|
||||
Some("https://other.example/"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_registries_map_that_mixes_both_shapes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n '@acme': https://npm.example.com/\n https://artifactory.example/: {serverType: artifactory}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a mixed registries map must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("mixes registry declarations"), "{error}");
|
||||
assert!(error.contains(r#""@acme""#), "the scope-routed entry is named: {error}");
|
||||
}
|
||||
|
||||
/// A scope routes to a registry, so a URL in that position routes nothing and
|
||||
/// would sit there inert. It is the declaration shape, half-written.
|
||||
#[test]
|
||||
fn rejects_a_url_keyed_registries_entry_written_as_a_string() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"registries:\n https://npm.example.com/: artifactory\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = WorkspaceSettings::load_at(dir.path())
|
||||
.expect_err("a URL-keyed string entry must not load")
|
||||
.to_string();
|
||||
assert!(error.contains("is a string"), "{error}");
|
||||
}
|
||||
|
||||
/// The inverse of the split a `registries` setting goes through, for the
|
||||
/// request a pnpr server reads. Every route to one registry lands in its entry.
|
||||
#[test]
|
||||
fn rebuilds_the_declarations_from_the_lookups() {
|
||||
let mut config = Config::new();
|
||||
config.registries_by_scope = BTreeMap::from([
|
||||
("default".to_owned(), "https://registry.npmjs.org/".to_owned()),
|
||||
("@acme".to_owned(), "https://npm.corp.example/".to_owned()),
|
||||
("@acme-internal".to_owned(), "https://npm.corp.example/".to_owned()),
|
||||
("@other".to_owned(), "https://npm.other.example/".to_owned()),
|
||||
]);
|
||||
config.registries_by_prefix =
|
||||
BTreeMap::from([("work".to_owned(), "https://npm.corp.example/".to_owned())]);
|
||||
config.registry_options_by_url = BTreeMap::from([(
|
||||
"https://npm.corp.example/".to_owned(),
|
||||
RegistryOptions { server_type: Some(RegistryServerType::Artifactory) },
|
||||
)]);
|
||||
|
||||
let declarations = config.registry_declarations();
|
||||
assert_eq!(
|
||||
declarations.get("https://npm.corp.example/"),
|
||||
Some(&RegistryDeclaration {
|
||||
scopes: Some(vec!["@acme".to_owned(), "@acme-internal".to_owned()]),
|
||||
prefix: Some("work".to_owned()),
|
||||
server_type: Some(RegistryServerType::Artifactory),
|
||||
unknown: BTreeMap::new(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
declarations.get("https://npm.other.example/"),
|
||||
Some(&RegistryDeclaration {
|
||||
scopes: Some(vec!["@other".to_owned()]),
|
||||
..RegistryDeclaration::default()
|
||||
}),
|
||||
);
|
||||
// The default registry travels as the request's own `registry` field.
|
||||
assert!(!declarations.contains_key("https://registry.npmjs.org/"), "{declarations:?}");
|
||||
}
|
||||
|
||||
/// A declaration map survives the round trip through the lookups it is split
|
||||
/// into, which is what makes it safe to rebuild one for a pnpr request.
|
||||
#[test]
|
||||
fn declarations_round_trip_through_the_lookups() {
|
||||
let yaml = r"
|
||||
registries:
|
||||
https://npm.corp.example/:
|
||||
serverType: artifactory
|
||||
scopes: ['@acme']
|
||||
prefix: work
|
||||
https://npm.other.example/:
|
||||
scopes: ['@other']
|
||||
";
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let entries = settings.registries.clone().expect("registries present");
|
||||
let mut config = Config::new();
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
|
||||
let rebuilt = config.registry_declarations();
|
||||
let original: std::collections::BTreeMap<String, RegistryDeclaration> = entries
|
||||
.into_iter()
|
||||
.map(|(registry, entry)| match entry {
|
||||
RegistryEntry::Declaration(declaration) => (registry, declaration),
|
||||
RegistryEntry::ScopeRoute(_) => panic!("declarations only"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(rebuilt, original);
|
||||
}
|
||||
@@ -766,27 +766,28 @@ pub fn tarball_url_and_integrity<'a>(
|
||||
// A registry-qualified key (`<name>@<registryName>:<version>`)
|
||||
// reconstructs its tarball from its named registry; everything
|
||||
// else routes by scope.
|
||||
let (registry, version) = if let Some((registry_name, version)) =
|
||||
package_key.suffix.registry_qualified()
|
||||
{
|
||||
let registry = pnpm_resolving_npm_resolver::BUILTIN_NAMED_REGISTRIES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == registry_name)
|
||||
.map(|(_, url)| (*url).to_string())
|
||||
.pipe(|builtin| config.named_registries.get(registry_name).cloned().or(builtin))
|
||||
.ok_or_else(|| InstallPackageBySnapshotError::MissingNamedRegistry {
|
||||
package_key: package_key.to_string(),
|
||||
registry_name: registry_name.to_string(),
|
||||
})?;
|
||||
(registry, version.to_string())
|
||||
} else {
|
||||
let registries: HashMap<String, String> =
|
||||
config.resolved_registries().into_iter().collect();
|
||||
(
|
||||
pick_registry_for_package(®istries, &name, None),
|
||||
package_key.suffix.version().to_string(),
|
||||
)
|
||||
};
|
||||
let (registry, version) =
|
||||
if let Some((registry_name, version)) = package_key.suffix.registry_qualified() {
|
||||
let registry = pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX
|
||||
.iter()
|
||||
.find(|(name, _)| *name == registry_name)
|
||||
.map(|(_, url)| (*url).to_string())
|
||||
.pipe(|builtin| {
|
||||
config.registries_by_prefix.get(registry_name).cloned().or(builtin)
|
||||
})
|
||||
.ok_or_else(|| InstallPackageBySnapshotError::MissingNamedRegistry {
|
||||
package_key: package_key.to_string(),
|
||||
registry_name: registry_name.to_string(),
|
||||
})?;
|
||||
(registry, version.to_string())
|
||||
} else {
|
||||
let registries: HashMap<String, String> =
|
||||
config.resolved_registries().into_iter().collect();
|
||||
(
|
||||
pick_registry_for_package(®istries, &name, None),
|
||||
package_key.suffix.version().to_string(),
|
||||
)
|
||||
};
|
||||
let registry = registry.strip_suffix('/').unwrap_or(®istry);
|
||||
let bare_name = package_key.name.bare.as_str();
|
||||
let tarball_url = format!("{registry}/{name}/-/{bare_name}-{version}.tgz");
|
||||
|
||||
@@ -51,7 +51,9 @@ fn emits_resolved_with_supplied_identifiers() {
|
||||
fn registry_resolution_uses_scoped_registry_tarball_base() {
|
||||
let mut config = Config::new();
|
||||
config.registry = "https://default.example/npm/".to_string();
|
||||
config.registries.insert("@private".to_string(), "https://private.example/npm/".to_string());
|
||||
config
|
||||
.registries_by_scope
|
||||
.insert("@private".to_string(), "https://private.example/npm/".to_string());
|
||||
|
||||
let integrity = DUMMY_SHA512.parse().expect("parse integrity");
|
||||
let resolution = LockfileResolution::Registry(RegistryResolution { integrity });
|
||||
|
||||
@@ -75,9 +75,10 @@ fn create_config(
|
||||
lockfile_include_tarball_url: false,
|
||||
registry: "https://registry.npmjs.com/".to_string(),
|
||||
scope: None,
|
||||
registries: Default::default(),
|
||||
registries_by_scope: Default::default(),
|
||||
pnpr_server: None,
|
||||
named_registries: Default::default(),
|
||||
registries_by_prefix: Default::default(),
|
||||
registry_options_by_url: Default::default(),
|
||||
auto_install_peers: false,
|
||||
auto_install_peers_from_highest_match: false,
|
||||
exclude_links_from_lockfile: false,
|
||||
@@ -193,7 +194,7 @@ async fn resolve_via_mock(
|
||||
registries.insert("default".to_string(), registry.to_string());
|
||||
let resolver = NpmResolver {
|
||||
registries,
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client,
|
||||
auth_headers: Default::default(),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
|
||||
@@ -14,7 +14,7 @@ use pnpm_graph_hasher::{
|
||||
calc_global_virtual_store_path_with_subdeps, calc_leaf_global_virtual_store_path,
|
||||
join_global_virtual_store_path,
|
||||
};
|
||||
use pnpm_lockfile::{EnvLockfile, LockfileResolution, npm_tarball_url};
|
||||
use pnpm_lockfile::{EnvLockfile, LockfileResolution, TarballUrlOptions, npm_tarball_url};
|
||||
use pnpm_package_is_installable::{
|
||||
InstallabilityOptions, PackageInstallabilityManifest, check_package,
|
||||
};
|
||||
@@ -463,9 +463,10 @@ fn integrity_and_tarball(
|
||||
registry: &str,
|
||||
) -> Option<(Integrity, String)> {
|
||||
match resolution {
|
||||
LockfileResolution::Registry(registry_resolution) => {
|
||||
Some((registry_resolution.integrity.clone(), npm_tarball_url(name, version, registry)))
|
||||
}
|
||||
LockfileResolution::Registry(registry_resolution) => Some((
|
||||
registry_resolution.integrity.clone(),
|
||||
npm_tarball_url(name, version, TarballUrlOptions { registry, server_type: None }),
|
||||
)),
|
||||
LockfileResolution::Tarball(tarball) => {
|
||||
let integrity = tarball.integrity.clone()?;
|
||||
Some((integrity, tarball.tarball.clone()))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use pnpm_lockfile::{BundledDependencies, PackageMetadata, PeerDependencyMeta, StringOrList};
|
||||
use pnpm_lockfile::{
|
||||
BundledDependencies, LockfileFormOptions, PackageMetadata, PeerDependencyMeta, StringOrList,
|
||||
};
|
||||
use pnpm_resolving_resolver_base::ResolveResult;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -15,8 +17,11 @@ pub(crate) fn package_metadata(
|
||||
resolution: result.resolution.to_lockfile_form(
|
||||
name,
|
||||
version,
|
||||
registry,
|
||||
lockfile_include_tarball_url,
|
||||
LockfileFormOptions {
|
||||
registry,
|
||||
server_type: None,
|
||||
include_tarball_url: lockfile_include_tarball_url,
|
||||
},
|
||||
),
|
||||
version: None,
|
||||
engines: read_engines(manifest),
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::{
|
||||
verify_env_lockfile::write_verified_env_lockfile,
|
||||
};
|
||||
use pnpm_lockfile::{
|
||||
EnvLockfile, LockfileResolution, PackageKey, PackageMetadata, SnapshotEntry,
|
||||
SpecifierAndResolution, TarballResolution, npm_tarball_url,
|
||||
EnvLockfile, LockfileFormOptions, LockfileResolution, PackageKey, PackageMetadata,
|
||||
SnapshotEntry, SpecifierAndResolution, TarballResolution, TarballUrlOptions, npm_tarball_url,
|
||||
};
|
||||
use pnpm_reporter::Reporter;
|
||||
use pnpm_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency};
|
||||
@@ -24,6 +24,13 @@ use pnpm_workspace_state::ConfigDependency;
|
||||
use ssri::Integrity;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Config deps keep the npm tarball layout: `registries` is a workspace
|
||||
/// setting, and config deps are resolved before workspace settings apply. The
|
||||
/// writer and the reader here agree because both use this same default.
|
||||
fn npm_lockfile_form(registry: &str) -> LockfileFormOptions<'_> {
|
||||
LockfileFormOptions { registry, server_type: None, include_tarball_url: false }
|
||||
}
|
||||
|
||||
/// Resolve + install the config dependencies declared in
|
||||
/// `pnpm-workspace.yaml` (`config_deps`).
|
||||
pub async fn resolve_and_install_config_deps<Reporter: self::Reporter>(
|
||||
@@ -55,10 +62,13 @@ pub async fn resolve_and_install_config_deps<Reporter: self::Reporter>(
|
||||
if !has_config_dep(&env_lockfile, name) {
|
||||
let (version, integrity) = parse_integrity(name, &detail.integrity)?;
|
||||
let registry = opts.pick_registry(name);
|
||||
let tarball = detail
|
||||
.tarball
|
||||
.clone()
|
||||
.unwrap_or_else(|| npm_tarball_url(name, &version, registry));
|
||||
let tarball = detail.tarball.clone().unwrap_or_else(|| {
|
||||
npm_tarball_url(
|
||||
name,
|
||||
&version,
|
||||
TarballUrlOptions { registry, server_type: None },
|
||||
)
|
||||
});
|
||||
migrate_into_lockfile(
|
||||
&mut env_lockfile,
|
||||
name,
|
||||
@@ -74,7 +84,11 @@ pub async fn resolve_and_install_config_deps<Reporter: self::Reporter>(
|
||||
if !has_config_dep(&env_lockfile, name) {
|
||||
let (version, integrity) = parse_integrity(name, value)?;
|
||||
let registry = opts.pick_registry(name);
|
||||
let tarball = npm_tarball_url(name, &version, registry);
|
||||
let tarball = npm_tarball_url(
|
||||
name,
|
||||
&version,
|
||||
TarballUrlOptions { registry, server_type: None },
|
||||
);
|
||||
migrate_into_lockfile(
|
||||
&mut env_lockfile,
|
||||
name,
|
||||
@@ -166,9 +180,11 @@ async fn resolve_one(
|
||||
);
|
||||
env_lockfile.packages.insert(
|
||||
key.clone(),
|
||||
registry_package_metadata(
|
||||
result.resolution.to_lockfile_form(name, &version, registry, false),
|
||||
),
|
||||
registry_package_metadata(result.resolution.to_lockfile_form(
|
||||
name,
|
||||
&version,
|
||||
npm_lockfile_form(registry),
|
||||
)),
|
||||
);
|
||||
|
||||
let optional_subdeps = match result.manifest.as_deref() {
|
||||
@@ -205,7 +221,7 @@ fn migrate_into_lockfile(
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
})
|
||||
.to_lockfile_form(name, version, registry, false);
|
||||
.to_lockfile_form(name, version, npm_lockfile_form(registry));
|
||||
env_lockfile.packages.insert(key.clone(), registry_package_metadata(resolution));
|
||||
env_lockfile.snapshots.insert(key, SnapshotEntry::default());
|
||||
Ok(())
|
||||
|
||||
@@ -55,7 +55,7 @@ fn build_resolver(registry: &str) -> (NpmResolver<InMemoryPackageMetaCache>, Tem
|
||||
registries.insert("default".to_string(), registry.to_string());
|
||||
let resolver = NpmResolver {
|
||||
registries,
|
||||
named_registries: std::collections::HashMap::new(),
|
||||
registries_by_prefix: std::collections::HashMap::new(),
|
||||
http_client: Arc::new(ThrottledClient::default()),
|
||||
auth_headers: Arc::new(AuthHeaders::default()),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
|
||||
@@ -2,7 +2,10 @@ use derive_more::{Display, From, TryInto};
|
||||
use pipe_trait::Pipe;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ssri::Integrity;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{BTreeMap, HashMap},
|
||||
};
|
||||
|
||||
/// For tarball hosted remotely or locally.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -351,20 +354,21 @@ impl LockfileResolution {
|
||||
/// Convert an in-memory resolution into the form written to the lockfile.
|
||||
///
|
||||
/// For a registry tarball whose URL is reconstructible from `name`,
|
||||
/// `version`, and `registry`, the URL is dropped and only `{integrity}` is
|
||||
/// kept — pnpm derives the tarball URL on demand. The URL is preserved when
|
||||
/// `include_tarball_url` is set, when it is a `file:` tarball, when it is
|
||||
/// git-hosted, or when it does not match the derived URL (e.g. private
|
||||
/// registries with non-standard tarball paths). Non-tarball resolutions and
|
||||
/// integrity-less tarballs pass through unchanged.
|
||||
/// `version`, and the registry, the URL is dropped and only `{integrity}`
|
||||
/// is kept — pnpm derives the tarball URL on demand. The URL is preserved
|
||||
/// when [`LockfileFormOptions::include_tarball_url`] is set, when it is a
|
||||
/// `file:` tarball, when it is git-hosted, or when it does not match the
|
||||
/// derived URL (e.g. private registries with non-standard tarball paths).
|
||||
/// Non-tarball resolutions and integrity-less tarballs pass through
|
||||
/// unchanged.
|
||||
#[must_use]
|
||||
pub fn to_lockfile_form(
|
||||
&self,
|
||||
name: &str,
|
||||
version: &str,
|
||||
registry: &str,
|
||||
include_tarball_url: bool,
|
||||
opts: LockfileFormOptions<'_>,
|
||||
) -> LockfileResolution {
|
||||
let LockfileFormOptions { registry, server_type, include_tarball_url } = opts;
|
||||
let LockfileResolution::Tarball(tarball) = self else { return self.clone() };
|
||||
let Some(integrity) = tarball.integrity.as_ref() else { return self.clone() };
|
||||
|
||||
@@ -378,7 +382,12 @@ impl LockfileResolution {
|
||||
if !include_tarball_url
|
||||
&& !git_hosted
|
||||
&& !tarball.tarball.starts_with("file:")
|
||||
&& is_canonical_registry_tarball_url(&tarball.tarball, name, version, registry)
|
||||
&& is_canonical_registry_tarball_url(
|
||||
&tarball.tarball,
|
||||
name,
|
||||
version,
|
||||
TarballUrlOptions { registry, server_type },
|
||||
)
|
||||
{
|
||||
return LockfileResolution::Registry(RegistryResolution {
|
||||
integrity: integrity.clone(),
|
||||
@@ -397,48 +406,163 @@ impl LockfileResolution {
|
||||
}
|
||||
}
|
||||
|
||||
/// The software serving a registry, declared through the `registries`
|
||||
/// setting. Modeled as an [`Option`] everywhere it is threaded, because
|
||||
/// "behaves like the npm registry" is a claim only the operator can make:
|
||||
///
|
||||
/// - [`None`] — strict. Only the exact canonical URL is reconstructible. This
|
||||
/// is how every registry but registry.npmjs.org is read by default.
|
||||
/// - [`RegistryServerType::Npm`] — behaves like registry.npmjs.org, which
|
||||
/// serves a scoped package from the percent-encoded path as well as the
|
||||
/// unencoded one. A faithful mirror or caching proxy of it is this.
|
||||
/// - [`RegistryServerType::Artifactory`] — repeats the scope in a scoped
|
||||
/// package's tarball filename.
|
||||
///
|
||||
/// Only layouts pnpm can rebuild a URL for belong here. A registry that serves
|
||||
/// tarballs from a content-derived path (GitHub Packages
|
||||
/// `/download/<scope>/<name>/<version>/<sha256>`) has no variant: the digest is
|
||||
/// a fact about the bytes rather than about the package's identity, so its URLs
|
||||
/// are kept in the lockfile instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RegistryServerType {
|
||||
Npm,
|
||||
Artifactory,
|
||||
}
|
||||
|
||||
/// Non-secret, per-registry settings from the `registries` setting.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct RegistryOptions {
|
||||
#[serde(default)]
|
||||
pub server_type: Option<RegistryServerType>,
|
||||
}
|
||||
|
||||
/// registry.npmjs.org is the one registry whose layout pnpm knows without
|
||||
/// being told, so it is a row of data rather than a hostname comparison. A
|
||||
/// declared server type wins over it.
|
||||
const DEFAULT_REGISTRY_SERVER_TYPES: &[(&str, RegistryServerType)] =
|
||||
&[("https://registry.npmjs.org/", RegistryServerType::Npm)];
|
||||
|
||||
/// The layout the user declared for `registry`, or [`None`] for none.
|
||||
///
|
||||
/// Built-in layouts are deliberately not applied here — they belong with the
|
||||
/// predicate that acts on them, so that a code path which never threads
|
||||
/// `registryOptions` still gets them.
|
||||
///
|
||||
/// `registry_options_by_url` is keyed by registry URL with a trailing slash, the way
|
||||
/// the config reader normalizes it, so the lookup normalizes its query to
|
||||
/// match.
|
||||
#[must_use]
|
||||
pub fn registry_server_type(
|
||||
registry_options_by_url: &BTreeMap<String, RegistryOptions>,
|
||||
registry: &str,
|
||||
) -> Option<RegistryServerType> {
|
||||
let key = if registry.ends_with('/') {
|
||||
Cow::Borrowed(registry)
|
||||
} else {
|
||||
Cow::Owned(format!("{registry}/"))
|
||||
};
|
||||
registry_options_by_url.get(key.as_ref()).copied().unwrap_or_default().server_type
|
||||
}
|
||||
|
||||
/// A declared server type wins; otherwise the built-in layout of a known
|
||||
/// registry applies, and an unknown registry is read strictly.
|
||||
fn effective_server_type(opts: TarballUrlOptions<'_>) -> Option<RegistryServerType> {
|
||||
opts.server_type.or_else(|| {
|
||||
let registry = if opts.registry.ends_with('/') {
|
||||
Cow::Borrowed(opts.registry)
|
||||
} else {
|
||||
Cow::Owned(format!("{}/", opts.registry))
|
||||
};
|
||||
DEFAULT_REGISTRY_SERVER_TYPES
|
||||
.iter()
|
||||
.find(|(default_registry, _)| *default_registry == registry.as_ref())
|
||||
.map(|(_, server_type)| *server_type)
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything needed to decide which registry a package came from and what
|
||||
/// that registry does: the scope-routed URLs, the `<name>:`-addressed aliases,
|
||||
/// and the declared per-registry settings.
|
||||
///
|
||||
/// Threaded as one value rather than as three parameters so a consumer cannot
|
||||
/// be handed the routing without the settings — dropping the settings is
|
||||
/// silent, the tarball URL is simply rebuilt in the wrong layout — and so a
|
||||
/// new per-registry setting reaches every consumer by being added here.
|
||||
///
|
||||
/// The counterpart of the TypeScript CLI's [`RegistryContext`].
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RegistryContext {
|
||||
pub registries: HashMap<String, String>,
|
||||
/// As the user wrote it; built-in aliases are merged in at lookup.
|
||||
pub registries_by_prefix: HashMap<String, String>,
|
||||
pub registry_options_by_url: BTreeMap<String, RegistryOptions>,
|
||||
}
|
||||
|
||||
/// Where a package's tarball lives: the registry it resolved from, and the URL
|
||||
/// layout that registry serves.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TarballUrlOptions<'a> {
|
||||
pub registry: &'a str,
|
||||
pub server_type: Option<RegistryServerType>,
|
||||
}
|
||||
|
||||
/// Inputs to [`LockfileResolution::to_lockfile_form`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LockfileFormOptions<'a> {
|
||||
pub registry: &'a str,
|
||||
pub server_type: Option<RegistryServerType>,
|
||||
/// Keep the tarball URL even when it is reconstructible.
|
||||
pub include_tarball_url: bool,
|
||||
}
|
||||
|
||||
/// Derive the canonical npm registry tarball URL for `name@version`. Port of
|
||||
/// the [`get-npm-tarball-url`](https://www.npmjs.com/package/get-npm-tarball-url)
|
||||
/// package pnpm uses.
|
||||
///
|
||||
/// This is the single source of the URL shape: the lockfile writer drops a
|
||||
/// tarball URL only when this function rebuilds it, and the lockfile reader
|
||||
/// rebuilds it with this function. Both sides therefore agree by construction,
|
||||
/// including under a non-npm [`RegistryServerType`].
|
||||
#[must_use]
|
||||
pub fn npm_tarball_url(name: &str, version: &str, registry: &str) -> String {
|
||||
pub fn npm_tarball_url(name: &str, version: &str, opts: TarballUrlOptions<'_>) -> String {
|
||||
let TarballUrlOptions { registry, server_type } = opts;
|
||||
let registry =
|
||||
if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") };
|
||||
let scopeless = match name.strip_prefix('@') {
|
||||
Some(scoped) => scoped.split_once('/').map_or(name, |(_, bare)| bare),
|
||||
None => name,
|
||||
// Artifactory keeps the scope in the filename of a scoped package's tarball
|
||||
// (`@acme/widget/-/@acme/widget-1.0.0.tgz`); the npm layout strips it.
|
||||
let filename_name = match server_type {
|
||||
Some(RegistryServerType::Artifactory) => name,
|
||||
Some(RegistryServerType::Npm) | None => match name.strip_prefix('@') {
|
||||
Some(scoped) => scoped.split_once('/').map_or(name, |(_, bare)| bare),
|
||||
None => name,
|
||||
},
|
||||
};
|
||||
let version = version.split_once('+').map_or(version, |(base, _)| base);
|
||||
format!("{registry}{name}/-/{scopeless}-{version}.tgz")
|
||||
format!("{registry}{name}/-/{filename_name}-{version}.tgz")
|
||||
}
|
||||
|
||||
/// Whether `tarball` is the canonical npm registry URL derived from `name`,
|
||||
/// `version`, and `registry` — i.e. it can be dropped from the lockfile and
|
||||
/// rebuilt on demand.
|
||||
/// Whether `tarball` is the URL [`npm_tarball_url`] rebuilds for `name` and
|
||||
/// `version` — i.e. it can be dropped from the lockfile and rebuilt on demand.
|
||||
fn is_canonical_registry_tarball_url(
|
||||
tarball: &str,
|
||||
name: &str,
|
||||
version: &str,
|
||||
registry: &str,
|
||||
opts: TarballUrlOptions<'_>,
|
||||
) -> bool {
|
||||
let expected = npm_tarball_url(name, version, registry);
|
||||
let expected = npm_tarball_url(name, version, opts);
|
||||
let expected = remove_protocol(&expected);
|
||||
let actual = remove_protocol(tarball);
|
||||
// registry.npmjs.org serves a scoped package from both the encoded and the
|
||||
// unencoded path; elsewhere only the encoded one may work.
|
||||
// See <https://github.com/pnpm/pnpm/issues/13534>.
|
||||
// A registry behaving like registry.npmjs.org serves a scoped package from
|
||||
// both the encoded and the unencoded path. A registry that has not been
|
||||
// declared to behave like it may serve only the encoded one, so its URL is
|
||||
// kept. See <https://github.com/pnpm/pnpm/issues/13534>.
|
||||
expected == actual
|
||||
|| (is_public_npm_registry(registry)
|
||||
|| (effective_server_type(opts) == Some(RegistryServerType::Npm)
|
||||
&& expected == actual.replace("%2f", "/").replace("%2F", "/"))
|
||||
}
|
||||
|
||||
const PUBLIC_NPM_REGISTRY_HOST: &str = "registry.npmjs.org";
|
||||
|
||||
fn is_public_npm_registry(registry: &str) -> bool {
|
||||
let registry = remove_protocol(registry);
|
||||
registry.strip_suffix('/').unwrap_or(registry) == PUBLIC_NPM_REGISTRY_HOST
|
||||
}
|
||||
|
||||
/// Default-vs-scope routing for an npm package.
|
||||
///
|
||||
/// Routing rules:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::{
|
||||
BinaryArchive, BinaryResolution, BinarySpec, DirectoryResolution, GitResolution,
|
||||
LockfileResolution, PlatformAssetResolution, PlatformAssetTarget, PlatformSelector,
|
||||
RegistryResolution, TarballResolution, VariationsResolution, is_git_hosted_tarball_url,
|
||||
libc_matches, select_platform_variant,
|
||||
LockfileFormOptions, LockfileResolution, PlatformAssetResolution, PlatformAssetTarget,
|
||||
PlatformSelector, RegistryOptions, RegistryResolution, RegistryServerType, TarballResolution,
|
||||
TarballUrlOptions, VariationsResolution, is_git_hosted_tarball_url, libc_matches,
|
||||
npm_tarball_url, registry_server_type, select_platform_variant,
|
||||
};
|
||||
use crate::serialize_yaml;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -12,6 +13,12 @@ use text_block_macros::text_block;
|
||||
|
||||
const GIT_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
/// No declared server type — the strict default every registry but
|
||||
/// registry.npmjs.org is read with.
|
||||
fn undeclared_form(registry: &str, include_tarball_url: bool) -> LockfileFormOptions<'_> {
|
||||
LockfileFormOptions { registry, server_type: None, include_tarball_url }
|
||||
}
|
||||
|
||||
fn integrity(integrity_str: &str) -> Integrity {
|
||||
integrity_str.parse().expect("parse integrity string")
|
||||
}
|
||||
@@ -754,7 +761,11 @@ fn to_lockfile_form_drops_reconstructible_registry_tarball() {
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false);
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"foo",
|
||||
"1.0.0",
|
||||
undeclared_form("https://registry.npmjs.org/", false),
|
||||
);
|
||||
assert_eq!(
|
||||
actual,
|
||||
LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }),
|
||||
@@ -773,7 +784,11 @@ fn to_lockfile_form_keeps_git_hosted_subdirectory_path() {
|
||||
git_hosted: Some(true),
|
||||
path: Some("/packages/foo".to_string()),
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false);
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"foo",
|
||||
"1.0.0",
|
||||
undeclared_form("https://registry.npmjs.org/", false),
|
||||
);
|
||||
assert_eq!(actual, resolution);
|
||||
}
|
||||
|
||||
@@ -787,7 +802,11 @@ fn to_lockfile_form_keeps_git_hosted_subdirectory_path_when_including_tarball_ur
|
||||
git_hosted: Some(true),
|
||||
path: Some("/packages/foo".to_string()),
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", true);
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"foo",
|
||||
"1.0.0",
|
||||
undeclared_form("https://registry.npmjs.org/", true),
|
||||
);
|
||||
assert_eq!(actual, resolution);
|
||||
}
|
||||
|
||||
@@ -803,8 +822,11 @@ fn to_lockfile_form_keeps_scoped_tarball_with_percent_encoded_scope_separator()
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual =
|
||||
resolution.to_lockfile_form("@babel/core", "7.0.0", "https://npm.example.com/", false);
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"@babel/core",
|
||||
"7.0.0",
|
||||
undeclared_form("https://npm.example.com/", false),
|
||||
);
|
||||
assert_eq!(actual, resolution, "{tarball_url} must survive verbatim");
|
||||
}
|
||||
}
|
||||
@@ -824,8 +846,7 @@ fn to_lockfile_form_drops_scoped_tarball_with_percent_encoding_on_the_public_reg
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"@babel/core",
|
||||
"7.0.0",
|
||||
"https://registry.npmjs.org/",
|
||||
false,
|
||||
undeclared_form("https://registry.npmjs.org/", false),
|
||||
);
|
||||
assert_eq!(
|
||||
actual,
|
||||
@@ -848,7 +869,11 @@ fn to_lockfile_form_keeps_tarball_with_trailing_scheme_separator() {
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("foo", "1.0.0", "https://registry.npmjs.org/", false);
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"foo",
|
||||
"1.0.0",
|
||||
undeclared_form("https://registry.npmjs.org/", false),
|
||||
);
|
||||
assert_eq!(
|
||||
actual,
|
||||
LockfileResolution::Tarball(TarballResolution {
|
||||
@@ -929,3 +954,151 @@ fn deserialize_rejects_malformed_builtin_resolution() {
|
||||
dbg!(&received);
|
||||
assert!(received.is_err(), "a git resolution without a commit must not parse");
|
||||
}
|
||||
|
||||
const ARTIFACTORY_REGISTRY: &str = "https://artifactory.example/artifactory/api/npm/npm-virtual/";
|
||||
|
||||
/// The npm and Artifactory layouts differ only in a scoped package's filename.
|
||||
#[test]
|
||||
fn npm_tarball_url_keeps_the_scope_in_the_artifactory_filename() {
|
||||
for (name, version, expected) in [
|
||||
("@acme/widget", "1.2.3", "@acme/widget/-/@acme/widget-1.2.3.tgz"),
|
||||
("@acme/widget", "1.2.3+build.4", "@acme/widget/-/@acme/widget-1.2.3.tgz"),
|
||||
("@acme/widget", "1.2.3-beta.1", "@acme/widget/-/@acme/widget-1.2.3-beta.1.tgz"),
|
||||
("widget", "1.2.3", "widget/-/widget-1.2.3.tgz"),
|
||||
] {
|
||||
let received = npm_tarball_url(
|
||||
name,
|
||||
version,
|
||||
TarballUrlOptions {
|
||||
registry: ARTIFACTORY_REGISTRY,
|
||||
server_type: Some(RegistryServerType::Artifactory),
|
||||
},
|
||||
);
|
||||
assert_eq!(received, format!("{ARTIFACTORY_REGISTRY}{expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_tarball_url_matches_the_npm_layout_for_an_unscoped_package() {
|
||||
for server_type in [None, Some(RegistryServerType::Npm), Some(RegistryServerType::Artifactory)]
|
||||
{
|
||||
let received = npm_tarball_url(
|
||||
"widget",
|
||||
"1.2.3",
|
||||
TarballUrlOptions { registry: ARTIFACTORY_REGISTRY, server_type },
|
||||
);
|
||||
assert_eq!(received, format!("{ARTIFACTORY_REGISTRY}widget/-/widget-1.2.3.tgz"));
|
||||
}
|
||||
}
|
||||
|
||||
fn artifactory_form(include_tarball_url: bool) -> LockfileFormOptions<'static> {
|
||||
LockfileFormOptions {
|
||||
registry: ARTIFACTORY_REGISTRY,
|
||||
server_type: Some(RegistryServerType::Artifactory),
|
||||
include_tarball_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_lockfile_form_drops_the_artifactory_url_of_a_scoped_package() {
|
||||
let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz");
|
||||
let resolution = LockfileResolution::Tarball(TarballResolution {
|
||||
tarball,
|
||||
integrity: Some(integrity(SHA512)),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(false));
|
||||
assert_eq!(
|
||||
actual,
|
||||
LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Under the Artifactory layout the npm-layout URL is the one pnpm cannot
|
||||
/// rebuild, so it has to survive verbatim — the inverse of the default.
|
||||
#[test]
|
||||
fn to_lockfile_form_keeps_the_npm_layout_url_on_an_artifactory_registry() {
|
||||
let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/widget-1.2.3.tgz");
|
||||
let resolution = LockfileResolution::Tarball(TarballResolution {
|
||||
tarball,
|
||||
integrity: Some(integrity(SHA512)),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(false));
|
||||
assert_eq!(actual, resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_lockfile_form_keeps_the_artifactory_url_on_a_registry_left_on_the_npm_layout() {
|
||||
let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz");
|
||||
let resolution = LockfileResolution::Tarball(TarballResolution {
|
||||
tarball,
|
||||
integrity: Some(integrity(SHA512)),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form(
|
||||
"@acme/widget",
|
||||
"1.2.3",
|
||||
undeclared_form(ARTIFACTORY_REGISTRY, false),
|
||||
);
|
||||
assert_eq!(actual, resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_lockfile_form_keeps_the_artifactory_url_when_include_tarball_url_is_set() {
|
||||
let tarball = format!("{ARTIFACTORY_REGISTRY}@acme/widget/-/@acme/widget-1.2.3.tgz");
|
||||
let resolution = LockfileResolution::Tarball(TarballResolution {
|
||||
tarball,
|
||||
integrity: Some(integrity(SHA512)),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
let actual = resolution.to_lockfile_form("@acme/widget", "1.2.3", artifactory_form(true));
|
||||
assert_eq!(actual, resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_server_type_is_undeclared_by_default_and_tolerates_a_missing_trailing_slash() {
|
||||
let options = BTreeMap::from([(
|
||||
ARTIFACTORY_REGISTRY.to_string(),
|
||||
RegistryOptions { server_type: Some(RegistryServerType::Artifactory) },
|
||||
)]);
|
||||
assert_eq!(
|
||||
registry_server_type(&options, ARTIFACTORY_REGISTRY.trim_end_matches('/')),
|
||||
Some(RegistryServerType::Artifactory),
|
||||
);
|
||||
assert_eq!(registry_server_type(&options, "https://npm.example.com/"), None);
|
||||
assert_eq!(registry_server_type(&BTreeMap::new(), ARTIFACTORY_REGISTRY), None);
|
||||
}
|
||||
|
||||
/// A registry declared to behave like registry.npmjs.org gets its leniency:
|
||||
/// the percent-encoded scoped path is reconstructible there too. Undeclared,
|
||||
/// the same URL must survive — the registry may serve only the encoded path.
|
||||
/// See <https://github.com/pnpm/pnpm/issues/13534>.
|
||||
#[test]
|
||||
fn to_lockfile_form_drops_the_encoded_scoped_path_only_when_the_registry_is_declared_npm() {
|
||||
let registry = "https://npm.example.com/";
|
||||
let resolution = LockfileResolution::Tarball(TarballResolution {
|
||||
tarball: format!("{registry}@babel%2Fcore/-/core-7.0.0.tgz"),
|
||||
integrity: Some(integrity(SHA512)),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
});
|
||||
|
||||
let declared_npm = LockfileFormOptions {
|
||||
registry,
|
||||
server_type: Some(RegistryServerType::Npm),
|
||||
include_tarball_url: false,
|
||||
};
|
||||
assert_eq!(
|
||||
resolution.to_lockfile_form("@babel/core", "7.0.0", declared_npm),
|
||||
LockfileResolution::Registry(RegistryResolution { integrity: integrity(SHA512) }),
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.to_lockfile_form("@babel/core", "7.0.0", undeclared_form(registry, false)),
|
||||
resolution,
|
||||
);
|
||||
}
|
||||
@@ -165,12 +165,6 @@ pub struct Modules {
|
||||
#[serde(default)]
|
||||
pub pruned_at: String,
|
||||
|
||||
// TODO: the strict manifest shape that the write path takes tightens
|
||||
// this to a required `Registries`. Revisit when the install-pipeline
|
||||
// port supplies a producer that always populates `default`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub registries: Option<BTreeMap<String, String>>,
|
||||
|
||||
/// Legacy: the v5-era flag used to mean "hoist everything publicly."
|
||||
/// Replaced by [`Self::public_hoist_pattern`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -244,8 +238,6 @@ pub struct ModulesLayout {
|
||||
#[serde(default)]
|
||||
pub pruned_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub registries: Option<BTreeMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub public_hoist_pattern: Option<Vec<String>>,
|
||||
/// Legacy: the v5-era flag used to mean "hoist everything publicly."
|
||||
/// Needed by [`read_modules_layout`] to apply the same normalization as
|
||||
|
||||
@@ -260,11 +260,11 @@ fn build_config(dir: &Path, overlay: &ConfigOverlay) -> Result<Config, LoadWorks
|
||||
}
|
||||
if let Some(registry) = &overlay.registry {
|
||||
config.registry.clone_from(registry);
|
||||
config.registries.insert("default".to_string(), registry.clone());
|
||||
config.registries_by_scope.insert("default".to_string(), registry.clone());
|
||||
}
|
||||
if let Some(registries) = &overlay.registries {
|
||||
for (scope, url) in registries {
|
||||
config.registries.insert(scope.clone(), url.clone());
|
||||
config.registries_by_scope.insert(scope.clone(), url.clone());
|
||||
if scope == "default" {
|
||||
config.registry.clone_from(url);
|
||||
}
|
||||
|
||||
@@ -118,12 +118,12 @@ fn project_config(config: &pnpm_config::Config) -> ResolvedConfig {
|
||||
|
||||
// The default registry lives in `config.registry`; the `registries`
|
||||
// map carries it only when something set a `default` key explicitly.
|
||||
let default_entry = (!config.registries.contains_key("default"))
|
||||
let default_entry = (!config.registries_by_scope.contains_key("default"))
|
||||
.then(|| ("default".to_string(), config.registry.clone()));
|
||||
let registries = default_entry
|
||||
.iter()
|
||||
.map(|(name, url)| (name, url))
|
||||
.chain(config.registries.iter())
|
||||
.chain(config.registries_by_scope.iter())
|
||||
.map(|(name, url)| {
|
||||
let uri = nerf_dart(url);
|
||||
let scoped = by_scope.get(&uri);
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{import_method_name, project_config};
|
||||
fn config_with_auth(by_scope: AuthHeadersByScope) -> Config {
|
||||
Config {
|
||||
registry: "https://reg.example/npm/".to_string(),
|
||||
registries: BTreeMap::from([(
|
||||
registries_by_scope: BTreeMap::from([(
|
||||
"@scope".to_string(),
|
||||
"https://reg.example/scoped/".to_string(),
|
||||
)]),
|
||||
|
||||
@@ -158,7 +158,7 @@ fn run_resolve_blocking(
|
||||
// `config.registries` alone omits it, which would leave the picker with a
|
||||
// host-less `/pkg` URL.
|
||||
registries: config.resolved_registries().into_iter().collect(),
|
||||
named_registries: config.named_registries.clone().into_iter().collect(),
|
||||
registries_by_prefix: config.registries_by_prefix.clone().into_iter().collect(),
|
||||
http_client: Arc::clone(&http_client),
|
||||
auth_headers: Arc::clone(&config.auth_headers),
|
||||
meta_cache: shared_in_memory_cache(),
|
||||
@@ -206,13 +206,14 @@ fn run_resolve_blocking(
|
||||
// built-ins (today: `gh:` → GitHub Packages). A malformed URL here
|
||||
// fails fast with `ERR_PNPM_INVALID_NAMED_REGISTRY_URL`, matching the
|
||||
// install path.
|
||||
let user_named_registries: HashMap<String, String> =
|
||||
config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone())).collect();
|
||||
let merged_named_registries =
|
||||
merge_named_registries(&user_named_registries).map_err(|error| to_napi_error(&error))?;
|
||||
let named_registry_aliases: HashSet<String> = merged_named_registries.keys().cloned().collect();
|
||||
let user_registries_by_prefix: HashMap<String, String> =
|
||||
config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())).collect();
|
||||
let merged_registries_by_prefix = merge_named_registries(&user_registries_by_prefix)
|
||||
.map_err(|error| to_napi_error(&error))?;
|
||||
let named_registry_aliases: HashSet<String> =
|
||||
merged_registries_by_prefix.keys().cloned().collect();
|
||||
let named_registry_resolver = NamedRegistryResolver {
|
||||
named_registries: merged_named_registries,
|
||||
registries_by_prefix: merged_registries_by_prefix,
|
||||
registry_names: named_registry_aliases,
|
||||
http_client: Arc::clone(&http_client),
|
||||
auth_headers: Arc::clone(&config.auth_headers),
|
||||
|
||||
@@ -48,6 +48,21 @@ pub trait UpstreamRouteHook: Send + Sync {
|
||||
/// route the decision selected. `None` means fetch anonymously.
|
||||
fn authorize(&self, url: &str, package: Option<&str>) -> Option<String>;
|
||||
|
||||
/// Whether this deployment may fetch `url` at all.
|
||||
///
|
||||
/// A server resolves on behalf of callers who describe their own
|
||||
/// registries, so a URL it was told about is not automatically one it may
|
||||
/// reach. Answered at the fetch itself rather than when the request is
|
||||
/// read, so a registry a caller merely configures — a scope it never
|
||||
/// resolves a package from — costs nothing, while one it does resolve
|
||||
/// from is refused before the request leaves the process.
|
||||
///
|
||||
/// Defaults to `true` for hooks with no such policy (the CLI fetches as
|
||||
/// the user, who may reach whatever they configured).
|
||||
fn allows_fetch(&self, _url: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify the metadata cache scope for a fetch to `url` for package
|
||||
/// `package` (`None` for non-package fetches). Unlike [`Self::authorize`]
|
||||
/// this is a read-only query — it must **not** record into the resolve's
|
||||
@@ -380,6 +395,14 @@ impl AuthHeaders {
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the fetch to `url` is permitted, per an attached
|
||||
/// [`UpstreamRouteHook`]. Always true without one — see
|
||||
/// [`UpstreamRouteHook::allows_fetch`].
|
||||
#[must_use]
|
||||
pub fn allows_fetch(&self, url: &str) -> bool {
|
||||
self.route_hook.as_ref().is_none_or(|hook| hook.allows_fetch(url))
|
||||
}
|
||||
|
||||
/// Record the route for a metadata/tarball fetch that is about to be
|
||||
/// served from an in-memory or on-disk cache *without* an HTTP
|
||||
/// request, so a server [`UpstreamRouteHook`]'s footprint still
|
||||
|
||||
@@ -88,7 +88,7 @@ async fn add_routes_scoped_packages_to_configured_scoped_registry() {
|
||||
config.modules_dir = modules_dir;
|
||||
config.virtual_store_dir = virtual_store_dir;
|
||||
config.registry = format!("{}/", default_registry.url());
|
||||
config.registries.insert("@private".to_string(), scoped_registry_url);
|
||||
config.registries_by_scope.insert("@private".to_string(), scoped_registry_url);
|
||||
config.minimum_release_age = None;
|
||||
let config = config.leak();
|
||||
|
||||
@@ -177,7 +177,7 @@ async fn add_resolves_package_selectors_concurrently_and_reports_in_selector_ord
|
||||
let package_name = format!("@{scope}/{name}");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let registry_url = format!("{}/", server.url());
|
||||
config.registries.insert(format!("@{scope}"), registry_url.clone());
|
||||
config.registries_by_scope.insert(format!("@{scope}"), registry_url.clone());
|
||||
|
||||
let response_body = version_body(&package_name, ®istry_url);
|
||||
let state = Arc::clone(&request_state);
|
||||
@@ -409,7 +409,7 @@ async fn add_reports_resolution_errors_in_selector_order() {
|
||||
for (scope, name, response_delay_ms) in packages {
|
||||
let package_name = format!("@{scope}/{name}");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
config.registries.insert(format!("@{scope}"), format!("{}/", server.url()));
|
||||
config.registries_by_scope.insert(format!("@{scope}"), format!("{}/", server.url()));
|
||||
let latest_path = format!("/@{scope}%2F{name}/latest");
|
||||
let latest = server
|
||||
.mock("GET", latest_path.as_str())
|
||||
@@ -487,7 +487,7 @@ async fn add_does_not_wait_for_a_slower_later_resolution_after_an_error() {
|
||||
for (scope, name, response_delay_ms) in packages {
|
||||
let package_name = format!("@{scope}/{name}");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
config.registries.insert(format!("@{scope}"), format!("{}/", server.url()));
|
||||
config.registries_by_scope.insert(format!("@{scope}"), format!("{}/", server.url()));
|
||||
let latest_path = format!("/@{scope}%2F{name}/latest");
|
||||
let latest = server
|
||||
.mock("GET", latest_path.as_str())
|
||||
|
||||
@@ -104,8 +104,8 @@ pub fn build_resolution_verifiers(
|
||||
// tarball-prefix routing see the same set. Validated here too: this runs
|
||||
// before the resolver chain that also validates, and on the frozen path
|
||||
// that chain never runs.
|
||||
let named_registries = merge_named_registries(
|
||||
&config.named_registries.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
let registries_by_prefix = merge_named_registries(
|
||||
&config.registries_by_prefix.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
)
|
||||
.map_err(|source| BuildVerifiersError::InvalidNamedRegistries { source })?;
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn build_resolution_verifiers(
|
||||
trust_policy_exclude_patterns: config.trust_policy_exclude.clone().unwrap_or_default(),
|
||||
trust_policy_ignore_after: config.trust_policy_ignore_after,
|
||||
registries,
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
http_client,
|
||||
auth_headers: auth_override.unwrap_or_else(|| Arc::clone(&config.auth_headers)),
|
||||
cache_dir: Some(config.cache_dir.clone()),
|
||||
|
||||
@@ -17,7 +17,7 @@ const FAKE_INTEGRITY: &str = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
#[test]
|
||||
fn reserved_named_registry_is_an_error_not_a_panic() {
|
||||
let mut config = Config::default();
|
||||
config.named_registries.insert("workspace".to_string(), "https://npm.example/".to_string());
|
||||
config.registries_by_prefix.insert("workspace".to_string(), "https://npm.example/".to_string());
|
||||
|
||||
let result = build_resolution_verifiers(
|
||||
&config,
|
||||
|
||||
@@ -14,10 +14,11 @@ use pnpm_catalogs_protocol_parser::parse_catalog_protocol;
|
||||
use pnpm_catalogs_types::Catalogs;
|
||||
use pnpm_lockfile::{
|
||||
BundledDependencies, CatalogSnapshots, ComVer, ImporterDepVersion, Lockfile,
|
||||
LockfileResolution, LockfileSettings, LockfileVersion, PackageKey, PackageMetadata,
|
||||
ParseImporterDepVersionError, ParsePkgNameSuffixError, ParsePkgVerPeerError,
|
||||
PeerDependencyMeta, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, ResolvedCatalogEntry,
|
||||
ResolvedDependencyMap, ResolvedDependencySpec, SnapshotDepRef, SnapshotEntry, VersionPart,
|
||||
LockfileFormOptions, LockfileResolution, LockfileSettings, LockfileVersion, PackageKey,
|
||||
PackageMetadata, ParseImporterDepVersionError, ParsePkgNameSuffixError, ParsePkgVerPeerError,
|
||||
PeerDependencyMeta, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, RegistryOptions,
|
||||
ResolvedCatalogEntry, ResolvedDependencyMap, ResolvedDependencySpec, SnapshotDepRef,
|
||||
SnapshotEntry, VersionPart, registry_server_type,
|
||||
};
|
||||
use pnpm_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pnpm_resolving_deps_resolver::{
|
||||
@@ -111,7 +112,8 @@ pub struct GraphToLockfileOptions<'a> {
|
||||
/// user's setting). Registry-qualified package keys route their
|
||||
/// tarball-reconstructibility check through this map instead of the
|
||||
/// default registry.
|
||||
pub named_registries: &'a HashMap<String, String>,
|
||||
pub registries_by_prefix: &'a HashMap<String, String>,
|
||||
pub registry_options_by_url: &'a BTreeMap<String, RegistryOptions>,
|
||||
/// When `true`, registry tarball URLs are kept in the lockfile even when
|
||||
/// reconstructible (the `lockfileIncludeTarballUrl` setting).
|
||||
pub lockfile_include_tarball_url: bool,
|
||||
@@ -215,7 +217,8 @@ pub fn dependencies_graph_to_lockfile(
|
||||
pnpmfile_checksum,
|
||||
catalogs,
|
||||
registry,
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
registry_options_by_url,
|
||||
lockfile_include_tarball_url,
|
||||
previous_importers,
|
||||
previous_packages,
|
||||
@@ -230,7 +233,8 @@ pub fn dependencies_graph_to_lockfile(
|
||||
&optional_overrides,
|
||||
&PackageMetadataSources {
|
||||
registry,
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
registry_options_by_url,
|
||||
lockfile_include_tarball_url,
|
||||
previous_packages,
|
||||
},
|
||||
@@ -670,7 +674,8 @@ type PackagesAndSnapshots =
|
||||
/// from, beyond the graph itself.
|
||||
struct PackageMetadataSources<'a> {
|
||||
registry: &'a str,
|
||||
named_registries: &'a HashMap<String, String>,
|
||||
registries_by_prefix: &'a HashMap<String, String>,
|
||||
registry_options_by_url: &'a BTreeMap<String, RegistryOptions>,
|
||||
lockfile_include_tarball_url: bool,
|
||||
previous_packages: Option<&'a HashMap<PackageKey, PackageMetadata>>,
|
||||
}
|
||||
@@ -714,7 +719,7 @@ fn build_packages_and_snapshots(
|
||||
// entry that no install can fetch. Keeping the URL is always
|
||||
// recoverable, so an unknown alias forces it to be written.
|
||||
let (registry, include_tarball_url) = match key.suffix.registry_qualified() {
|
||||
Some((registry_name, _)) => match sources.named_registries.get(registry_name) {
|
||||
Some((registry_name, _)) => match sources.registries_by_prefix.get(registry_name) {
|
||||
Some(named_registry) => {
|
||||
(named_registry.as_str(), sources.lockfile_include_tarball_url)
|
||||
}
|
||||
@@ -722,7 +727,15 @@ fn build_packages_and_snapshots(
|
||||
},
|
||||
None => (sources.registry, sources.lockfile_include_tarball_url),
|
||||
};
|
||||
let mut metadata = build_package_metadata(node, key, registry, include_tarball_url);
|
||||
let mut metadata = build_package_metadata(
|
||||
node,
|
||||
key,
|
||||
LockfileFormOptions {
|
||||
registry,
|
||||
server_type: registry_server_type(sources.registry_options_by_url, registry),
|
||||
include_tarball_url,
|
||||
},
|
||||
);
|
||||
// `deprecated` is the only registry-mutable field of a
|
||||
// published version; an unchanged resolution must not lose
|
||||
// a recorded deprecation to a registry serving it
|
||||
@@ -751,8 +764,7 @@ fn build_packages_and_snapshots(
|
||||
fn build_package_metadata(
|
||||
node: &DependenciesGraphNode,
|
||||
metadata_key: &PackageKey,
|
||||
registry: &str,
|
||||
lockfile_include_tarball_url: bool,
|
||||
lockfile_form: LockfileFormOptions<'_>,
|
||||
) -> PackageMetadata {
|
||||
let manifest = node.resolve_result.manifest.as_deref();
|
||||
|
||||
@@ -807,8 +819,7 @@ fn build_package_metadata(
|
||||
let resolution = node.resolve_result.resolution.to_lockfile_form(
|
||||
&metadata_key.name.to_string(),
|
||||
&resolution_version,
|
||||
registry,
|
||||
lockfile_include_tarball_url,
|
||||
lockfile_form,
|
||||
);
|
||||
|
||||
// Record `version` only for non-registry packages (depPath carries
|
||||
|
||||
@@ -20,6 +20,9 @@ use pnpm_resolving_deps_resolver::{
|
||||
use pnpm_resolving_resolver_base::{PkgResolutionId, ResolveResult};
|
||||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||||
|
||||
static EMPTY_REGISTRY_OPTIONS: std::collections::BTreeMap<String, pnpm_lockfile::RegistryOptions> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
static EMPTY_NAMED_REGISTRIES: std::sync::LazyLock<std::collections::HashMap<String, String>> =
|
||||
std::sync::LazyLock::new(std::collections::HashMap::new);
|
||||
use serde_json::json;
|
||||
@@ -66,7 +69,8 @@ fn single_importer_opts<'a>(
|
||||
ImporterLockfileInput { manifest, direct_dependencies_by_alias: direct },
|
||||
);
|
||||
GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph,
|
||||
auto_install_peers,
|
||||
@@ -462,7 +466,8 @@ fn dedupe_peers_round_trips_through_lockfile_settings() {
|
||||
ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct.clone() },
|
||||
);
|
||||
let on = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -495,7 +500,8 @@ fn dedupe_peers_round_trips_through_lockfile_settings() {
|
||||
ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct },
|
||||
);
|
||||
let off = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -582,7 +588,8 @@ fn patched_dependencies_flow_into_lockfile_and_empty_is_omitted() {
|
||||
},
|
||||
);
|
||||
dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -742,7 +749,8 @@ fn aliased_catalog_dependency_records_catalog_snapshot() {
|
||||
ImporterLockfileInput { manifest: &manifest, direct_dependencies_by_alias: direct },
|
||||
);
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -1802,7 +1810,8 @@ fn snapshot_link_uses_lockfile_root_while_importer_link_uses_project_root() {
|
||||
)]);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -1895,7 +1904,8 @@ fn multi_importer_workspace_writes_per_project_lockfile_entries() {
|
||||
);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -2006,7 +2016,8 @@ fn multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches
|
||||
);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -2143,7 +2154,8 @@ fn workspace_sibling_link_renders_per_importer_with_link_ref() {
|
||||
);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
importers,
|
||||
graph: &graph,
|
||||
auto_install_peers: false,
|
||||
@@ -2452,7 +2464,8 @@ fn injected_workspace_dep_keeps_prior_link_on_untargeted_install() {
|
||||
let previous = previous_importers_with_link("n", "workspace:*", "../n");
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::All,
|
||||
..single_importer_opts(&manifest, &graph, direct, false, false, None, None)
|
||||
@@ -2477,7 +2490,8 @@ fn injected_workspace_dep_renders_file_without_prior_link() {
|
||||
let (_tmp, manifest, graph, direct) = injected_link_fixture();
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: None,
|
||||
previous_packages: None,
|
||||
update_reuse_scope: UpdateReuseScope::All,
|
||||
@@ -2506,7 +2520,8 @@ fn injected_workspace_dep_flips_to_file_when_update_targets_it() {
|
||||
let previous = previous_importers_with_link("n", "workspace:*", "../n");
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::Except(HashSet::from_iter(["n".to_string()])),
|
||||
..single_importer_opts(&manifest, &graph, direct, false, false, None, None)
|
||||
@@ -2535,7 +2550,8 @@ fn injected_workspace_dep_flips_to_file_when_specifier_changed() {
|
||||
let previous = previous_importers_with_link("n", "workspace:^1.0.0", "../n");
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::All,
|
||||
..single_importer_opts(&manifest, &graph, direct, false, false, None, None)
|
||||
@@ -2570,7 +2586,8 @@ fn injected_workspace_dep_flips_to_file_when_recursive_update_targets_it_per_imp
|
||||
)]);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::All,
|
||||
update_reuse_scopes_by_importer: scopes_by_importer,
|
||||
@@ -2607,7 +2624,8 @@ fn injected_workspace_dep_keeps_link_when_recursive_update_targets_other_pkg() {
|
||||
)]);
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::All,
|
||||
update_reuse_scopes_by_importer: scopes_by_importer,
|
||||
@@ -2635,7 +2653,8 @@ fn injected_workspace_dep_flips_to_file_on_scope_wide_update() {
|
||||
let previous = previous_importers_with_link("n", "workspace:*", "../n");
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
named_registries: &EMPTY_NAMED_REGISTRIES,
|
||||
registries_by_prefix: &EMPTY_NAMED_REGISTRIES,
|
||||
registry_options_by_url: &EMPTY_REGISTRY_OPTIONS,
|
||||
previous_importers: Some(&previous),
|
||||
update_reuse_scope: UpdateReuseScope::None,
|
||||
..single_importer_opts(&manifest, &graph, direct, false, false, None, None)
|
||||
@@ -2746,9 +2765,9 @@ fn named_registry_package_keeps_the_format_and_drops_a_canonical_tarball() {
|
||||
let mut direct = BTreeMap::new();
|
||||
direct.insert("foo".to_string(), DepPath::from("foo@work:1.0.0".to_string()));
|
||||
|
||||
let named_registries = named_registries_with("work", "https://npm.enterprise.example.com/");
|
||||
let registries_by_prefix = named_registries_with("work", "https://npm.enterprise.example.com/");
|
||||
let mut opts = single_importer_opts(&manifest, &graph, direct, true, false, None, None);
|
||||
opts.named_registries = &named_registries;
|
||||
opts.registries_by_prefix = ®istries_by_prefix;
|
||||
|
||||
let lockfile = dependencies_graph_to_lockfile(opts);
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ async fn try_update(lockfile: &Lockfile, catalogs: &Catalogs, manifest: Value) -
|
||||
resolve_options: &resolve_options,
|
||||
manifest_hook: None,
|
||||
registries: ®istries,
|
||||
registry_options_by_url: &std::collections::BTreeMap::new(),
|
||||
lockfile_include_tarball_url: false,
|
||||
},
|
||||
catalogs,
|
||||
|
||||
@@ -3,15 +3,16 @@ use indexmap::IndexMap;
|
||||
use node_semver::{Range, Version};
|
||||
use pnpm_config_parse_overrides::{PackageSelector, VersionOverride};
|
||||
use pnpm_lockfile::{
|
||||
BundledDependencies, ImporterDepVersion, Lockfile, LockfileResolution, PackageKey,
|
||||
PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, Prefix, ResolvedDependencyMap,
|
||||
SnapshotDepRef, SnapshotEntry, StringOrList, pick_registry_for_package,
|
||||
BundledDependencies, ImporterDepVersion, Lockfile, LockfileFormOptions, LockfileResolution,
|
||||
PackageKey, PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, Prefix, RegistryOptions,
|
||||
ResolvedDependencyMap, SnapshotDepRef, SnapshotEntry, StringOrList, pick_registry_for_package,
|
||||
registry_server_type,
|
||||
};
|
||||
use pnpm_resolving_deps_resolver::ManifestHook;
|
||||
use pnpm_resolving_resolver_base::{ResolveOptions, ResolveResult, Resolver, WantedDependency};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ pub(crate) struct RewriteContext<'a> {
|
||||
pub resolve_options: &'a ResolveOptions,
|
||||
pub manifest_hook: Option<&'a ManifestHook>,
|
||||
pub registries: &'a HashMap<String, String>,
|
||||
pub registry_options_by_url: &'a BTreeMap<String, RegistryOptions>,
|
||||
pub lockfile_include_tarball_url: bool,
|
||||
}
|
||||
|
||||
@@ -408,13 +410,18 @@ fn rewrite_lockfile(
|
||||
}
|
||||
snapshots.insert(new_key.clone(), snapshot);
|
||||
let metadata_key = new_key.without_peer();
|
||||
let registry =
|
||||
pick_registry_for_package(context.registries, &old_key.name.to_string(), None);
|
||||
let metadata = package_metadata(
|
||||
&replacement.manifest,
|
||||
replacement.resolution.to_lockfile_form(
|
||||
&old_key.name.to_string(),
|
||||
&new_key.suffix.version().to_string(),
|
||||
&pick_registry_for_package(context.registries, &old_key.name.to_string(), None),
|
||||
context.lockfile_include_tarball_url,
|
||||
LockfileFormOptions {
|
||||
registry: ®istry,
|
||||
server_type: registry_server_type(context.registry_options_by_url, ®istry),
|
||||
include_tarball_url: context.lockfile_include_tarball_url,
|
||||
},
|
||||
),
|
||||
);
|
||||
if let Some(existing) = packages.get(&metadata_key)
|
||||
|
||||
@@ -136,6 +136,7 @@ async fn try_update(
|
||||
resolve_options: &resolve_options,
|
||||
manifest_hook: None,
|
||||
registries: ®istries,
|
||||
registry_options_by_url: &std::collections::BTreeMap::new(),
|
||||
lockfile_include_tarball_url: false,
|
||||
},
|
||||
parsed_overrides,
|
||||
|
||||
@@ -454,7 +454,6 @@ pub(super) fn build_modules_manifest(
|
||||
// this is a fresh timestamp (a prune ran or first install) or the
|
||||
// preserved prior value.
|
||||
pruned_at,
|
||||
registries: Some(config.resolved_registries()),
|
||||
// `iter_installability` excludes fetch-failure entries so they
|
||||
// don't get persisted across installs — optional fetch failures
|
||||
// are silently swallowed.
|
||||
|
||||
@@ -913,7 +913,7 @@ async fn lockfile_only_routes_scoped_packages_to_configured_scoped_registry() {
|
||||
config.modules_dir = modules_dir;
|
||||
config.virtual_store_dir = virtual_store_dir;
|
||||
config.registry = format!("{}/", default_registry.url());
|
||||
config.registries.insert("@private".to_string(), scoped_registry_url);
|
||||
config.registries_by_scope.insert("@private".to_string(), scoped_registry_url);
|
||||
let config = config.leak();
|
||||
|
||||
Install {
|
||||
@@ -1480,7 +1480,7 @@ async fn install_emits_pnpm_event_sequence() {
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir.clone();
|
||||
config
|
||||
.registries
|
||||
.registries_by_scope
|
||||
.insert("@private".to_string(), "https://private.example.com/npm/".to_string());
|
||||
let config = config.leak();
|
||||
|
||||
@@ -1642,7 +1642,7 @@ async fn install_writes_modules_yaml() {
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir.clone();
|
||||
config
|
||||
.registries
|
||||
.registries_by_scope
|
||||
.insert("@private".to_string(), "https://private.example.com/npm/".to_string());
|
||||
let config = config.leak();
|
||||
|
||||
@@ -1707,7 +1707,6 @@ async fn install_writes_modules_yaml() {
|
||||
store_dir: emitted_store_dir,
|
||||
virtual_store_dir: emitted_virtual_store_dir,
|
||||
virtual_store_dir_max_length,
|
||||
registries,
|
||||
package_manager,
|
||||
..
|
||||
} = modules_dir
|
||||
@@ -1726,14 +1725,6 @@ async fn install_writes_modules_yaml() {
|
||||
// to the absolute install-time path.
|
||||
assert_eq!(emitted_virtual_store_dir, virtual_store_dir.to_string_lossy());
|
||||
assert_eq!(virtual_store_dir_max_length, pnpm_config::default_virtual_store_dir_max_length());
|
||||
assert_eq!(
|
||||
registries.as_ref().and_then(|r| r.get("default")).map(String::as_str),
|
||||
Some(config.registry.as_str()),
|
||||
);
|
||||
assert_eq!(
|
||||
registries.as_ref().and_then(|r| r.get("@private")).map(String::as_str),
|
||||
Some("https://private.example.com/npm/"),
|
||||
);
|
||||
assert_eq!(package_manager, format!("pnpm@{}", pnpm_config::PNPM_VERSION));
|
||||
|
||||
drop(dir);
|
||||
|
||||
@@ -790,7 +790,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
|
||||
init_store_dir_best_effort(store_dir).await;
|
||||
}
|
||||
|
||||
let resolver_setup::Registries { by_scope: registries, named: merged_named_registries } =
|
||||
let resolver_setup::Registries { by_scope: registries, named: merged_registries_by_prefix } =
|
||||
resolver_setup::resolve_registries(config)?;
|
||||
|
||||
// `resolutionMode` / `minimumReleaseAge` derivations. `time_based`
|
||||
@@ -847,7 +847,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
|
||||
requester,
|
||||
supported_architectures,
|
||||
registries: ®istries,
|
||||
named_registries: &merged_named_registries,
|
||||
registries_by_prefix: &merged_registries_by_prefix,
|
||||
full_metadata,
|
||||
wanted_lockfile,
|
||||
store_index: store_index_ref,
|
||||
@@ -1065,7 +1065,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
|
||||
update_reuse_scopes_by_importer,
|
||||
update_depth: update_seed_policy.max_depth(),
|
||||
registries,
|
||||
named_registries: merged_named_registries.clone(),
|
||||
registries_by_prefix: merged_registries_by_prefix.clone(),
|
||||
})
|
||||
.await?;
|
||||
crate::minimum_release_age::handle_minimum_release_age_violations::<Reporter>(
|
||||
@@ -2145,15 +2145,18 @@ fn build_fresh_lockfile(
|
||||
}
|
||||
// Same merge the resolver chain performs; the config was already
|
||||
// validated at resolver construction, so skip re-validation here.
|
||||
let named_registries: HashMap<String, String> =
|
||||
pnpm_resolving_npm_resolver::BUILTIN_NAMED_REGISTRIES
|
||||
let registries_by_prefix: HashMap<String, String> =
|
||||
pnpm_resolving_npm_resolver::BUILTIN_REGISTRIES_BY_PREFIX
|
||||
.iter()
|
||||
.map(|(name, url)| ((*name).to_string(), (*url).to_string()))
|
||||
.chain(config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone())))
|
||||
.chain(
|
||||
config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())),
|
||||
)
|
||||
.collect();
|
||||
let lockfile = dependencies_graph_to_lockfile(GraphToLockfileOptions {
|
||||
importers,
|
||||
graph,
|
||||
registry_options_by_url: &config.registry_options_by_url,
|
||||
auto_install_peers: config.auto_install_peers,
|
||||
dedupe_peers: config.dedupe_peers,
|
||||
exclude_links_from_lockfile: config.exclude_links_from_lockfile,
|
||||
@@ -2168,7 +2171,7 @@ fn build_fresh_lockfile(
|
||||
pnpmfile_checksum: pnpmfile_checksum.map(str::to_string),
|
||||
catalogs,
|
||||
registry: &config.registry,
|
||||
named_registries: &named_registries,
|
||||
registries_by_prefix: ®istries_by_prefix,
|
||||
lockfile_include_tarball_url: config.lockfile_include_tarball_url,
|
||||
previous_importers,
|
||||
previous_packages: wanted_lockfile.and_then(|lockfile| lockfile.packages.as_ref()),
|
||||
|
||||
@@ -315,6 +315,7 @@ pub(super) async fn lockfile_reuse_seed(inputs: ReuseSeedInputs<'_>) -> Option<A
|
||||
resolve_options,
|
||||
manifest_hook: rewrite_manifest_hook.as_ref(),
|
||||
registries,
|
||||
registry_options_by_url: &config.registry_options_by_url,
|
||||
lockfile_include_tarball_url: config.lockfile_include_tarball_url,
|
||||
},
|
||||
catalogs,
|
||||
@@ -338,6 +339,7 @@ pub(super) async fn lockfile_reuse_seed(inputs: ReuseSeedInputs<'_>) -> Option<A
|
||||
resolve_options,
|
||||
manifest_hook: rewrite_manifest_hook.as_ref(),
|
||||
registries,
|
||||
registry_options_by_url: &config.registry_options_by_url,
|
||||
lockfile_include_tarball_url: config.lockfile_include_tarball_url,
|
||||
},
|
||||
parsed_overrides: parsed_overrides?,
|
||||
@@ -418,7 +420,7 @@ pub(super) struct ResolvePassInputs<'a> {
|
||||
BTreeMap<String, pnpm_resolving_deps_resolver::UpdateReuseScope>,
|
||||
pub update_depth: pnpm_resolving_deps_resolver::UpdateDepth,
|
||||
pub registries: HashMap<String, String>,
|
||||
pub named_registries: HashMap<String, String>,
|
||||
pub registries_by_prefix: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Walk every importer's dependencies through the resolver chain.
|
||||
@@ -456,7 +458,7 @@ pub(super) async fn run_resolve_pass<Reporter: pnpm_reporter::Reporter>(
|
||||
update_reuse_scopes_by_importer,
|
||||
update_depth,
|
||||
registries,
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
} = inputs;
|
||||
|
||||
let workspace_importers: Vec<pnpm_resolving_deps_resolver::WorkspaceImporter<'_>> =
|
||||
@@ -475,6 +477,11 @@ pub(super) async fn run_resolve_pass<Reporter: pnpm_reporter::Reporter>(
|
||||
.map_or_else(|| std::ffi::OsString::from("node_modules"), std::ffi::OsStr::to_os_string);
|
||||
|
||||
let workspace_opts = pnpm_resolving_deps_resolver::WorkspaceResolveOptions {
|
||||
registry_context: pnpm_lockfile::RegistryContext {
|
||||
registries,
|
||||
registries_by_prefix,
|
||||
registry_options_by_url: config.registry_options_by_url.clone(),
|
||||
},
|
||||
dedupe_peers: config.dedupe_peers,
|
||||
dedupe_injected_deps: config.dedupe_injected_deps,
|
||||
dedupe_peer_dependents: config.dedupe_peer_dependents,
|
||||
@@ -494,8 +501,6 @@ pub(super) async fn run_resolve_pass<Reporter: pnpm_reporter::Reporter>(
|
||||
update_reuse_scopes_by_importer,
|
||||
update_depth,
|
||||
auto_install_peers: config.auto_install_peers,
|
||||
registries,
|
||||
named_registries,
|
||||
allowed_deprecated_versions: config.allowed_deprecated_versions.clone(),
|
||||
deprecation_log: Some(super::deprecation_log_fn::<Reporter>()),
|
||||
};
|
||||
|
||||
@@ -75,11 +75,11 @@ pub(super) struct Registries {
|
||||
pub(super) fn resolve_registries(
|
||||
config: &Config,
|
||||
) -> Result<Registries, InstallWithFreshLockfileError> {
|
||||
let user_named_registries: HashMap<String, String> =
|
||||
config.named_registries.iter().map(|(name, url)| (name.clone(), url.clone())).collect();
|
||||
let user_registries_by_prefix: HashMap<String, String> =
|
||||
config.registries_by_prefix.iter().map(|(name, url)| (name.clone(), url.clone())).collect();
|
||||
Ok(Registries {
|
||||
by_scope: config.resolved_registries().into_iter().collect(),
|
||||
named: merge_named_registries(&user_named_registries)
|
||||
named: merge_named_registries(&user_registries_by_prefix)
|
||||
.map_err(InstallWithFreshLockfileError::InvalidNamedRegistry)?,
|
||||
})
|
||||
}
|
||||
@@ -141,7 +141,7 @@ pub(super) struct ResolverChainInputs<'a> {
|
||||
pub requester: &'a str,
|
||||
pub supported_architectures: Option<&'a pnpm_package_is_installable::SupportedArchitectures>,
|
||||
pub registries: &'a HashMap<String, String>,
|
||||
pub named_registries: &'a HashMap<String, String>,
|
||||
pub registries_by_prefix: &'a HashMap<String, String>,
|
||||
/// See `NpmResolver::full_metadata` — forced on when `time-based`
|
||||
/// resolution or the `no-downgrade` trust policy needs the
|
||||
/// per-version `time` field.
|
||||
@@ -203,7 +203,7 @@ pub(super) async fn build_resolver_chain<Reporter: pnpm_reporter::Reporter + 'st
|
||||
requester,
|
||||
supported_architectures,
|
||||
registries,
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
full_metadata,
|
||||
wanted_lockfile,
|
||||
store_index,
|
||||
@@ -229,7 +229,7 @@ pub(super) async fn build_resolver_chain<Reporter: pnpm_reporter::Reporter + 'st
|
||||
|
||||
let npm_resolver: Arc<dyn Resolver> = Arc::new(NpmResolver {
|
||||
registries: registries.clone(),
|
||||
named_registries: named_registries.clone(),
|
||||
registries_by_prefix: registries_by_prefix.clone(),
|
||||
http_client: Arc::clone(http_client_arc),
|
||||
auth_headers: Arc::clone(auth_headers),
|
||||
meta_cache: Arc::clone(meta_cache),
|
||||
@@ -304,8 +304,8 @@ pub(super) async fn build_resolver_chain<Reporter: pnpm_reporter::Reporter + 'st
|
||||
let bun_resolver = BunResolver::new(Arc::clone(http_client_arc), Arc::clone(&npm_resolver));
|
||||
let yarn_resolver = YarnResolver::new(Arc::clone(http_client_arc));
|
||||
let named_registry_resolver = NamedRegistryResolver {
|
||||
named_registries: named_registries.clone(),
|
||||
registry_names: named_registries.keys().cloned().collect(),
|
||||
registries_by_prefix: registries_by_prefix.clone(),
|
||||
registry_names: registries_by_prefix.keys().cloned().collect(),
|
||||
http_client: Arc::clone(http_client_arc),
|
||||
auth_headers: Arc::clone(auth_headers),
|
||||
meta_cache: Arc::clone(meta_cache),
|
||||
|
||||
@@ -619,7 +619,7 @@ async fn resolve_registry_fixture(
|
||||
registries.insert("default".to_string(), registry.to_string());
|
||||
let resolver = NpmResolver {
|
||||
registries,
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client,
|
||||
auth_headers: Arc::default(),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
|
||||
@@ -1503,12 +1503,12 @@ fn ensure_latest_resolver_chain<'chain>(
|
||||
let policy =
|
||||
PickPolicy::from_config_with_extra_excludes(ctx.config, extra_excludes.as_deref())
|
||||
.map_err(UpdateError::MinimumReleaseAgeExclude)?;
|
||||
let named_registries =
|
||||
merge_named_registries(&ctx.config.named_registries.clone().into_iter().collect())
|
||||
let registries_by_prefix =
|
||||
merge_named_registries(&ctx.config.registries_by_prefix.clone().into_iter().collect())
|
||||
.map_err(UpdateError::InvalidNamedRegistry)?;
|
||||
let npm_resolver: Arc<dyn Resolver> = Arc::new(NpmResolver {
|
||||
registries: ctx.config.resolved_registries().into_iter().collect(),
|
||||
named_registries,
|
||||
registries_by_prefix,
|
||||
http_client: Arc::clone(ctx.http_client_arc),
|
||||
auth_headers: Arc::clone(&ctx.config.auth_headers),
|
||||
meta_cache: Arc::<InMemoryPackageMetaCache>::default(),
|
||||
|
||||
@@ -22,10 +22,13 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use derive_more::{Display, Error, From};
|
||||
use futures_util::StreamExt as _;
|
||||
use pnpm_catalogs_types::Catalogs;
|
||||
use pnpm_config::TrustPolicy;
|
||||
use pnpm_config::{RegistryDeclaration, TrustPolicy};
|
||||
use pnpm_lockfile::Lockfile;
|
||||
use pnpm_lockfile_verification::{RenderedViolation, VerifyError};
|
||||
use reqwest::Client;
|
||||
|
||||
/// The `registries` a request declares, keyed by registry URL.
|
||||
pub type RegistryDeclarations = BTreeMap<String, RegistryDeclaration>;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Dependency map (`name` -> `version range`).
|
||||
@@ -45,10 +48,14 @@ pub struct ResolveOptions {
|
||||
pub dev_dependencies: DepMap,
|
||||
pub optional_dependencies: DepMap,
|
||||
/// The client's default registry. The server resolves against this
|
||||
/// (and `named_registries`) rather than its own configuration.
|
||||
/// (and the registries declared alongside it) rather than its own
|
||||
/// configuration.
|
||||
pub registry: String,
|
||||
/// The client's named-registry aliases.
|
||||
pub named_registries: DepMap,
|
||||
/// The registries the client declares, keyed by URL, in the shape
|
||||
/// of the `registries` setting. The default registry is not among
|
||||
/// them: it travels as `registry`.
|
||||
pub registries: RegistryDeclarations,
|
||||
/// `Authorization` for the pnpr server's own URL (`None` if it needs
|
||||
/// none): identifies the caller to pnpr. The client never forwards its
|
||||
/// own registry credentials — pnpr selects upstream credentials from
|
||||
@@ -110,7 +117,10 @@ pub struct ResolveProject {
|
||||
pub struct ResolveProjectsOptions {
|
||||
pub projects: Vec<ResolveProject>,
|
||||
pub registry: String,
|
||||
pub named_registries: DepMap,
|
||||
/// The registries the client declares, keyed by URL, in the shape
|
||||
/// of the `registries` setting. The default registry is not among
|
||||
/// them: it travels as `registry`.
|
||||
pub registries: RegistryDeclarations,
|
||||
pub authorization: Option<String>,
|
||||
pub overrides: Option<serde_json::Value>,
|
||||
pub catalogs: Option<Catalogs>,
|
||||
@@ -139,7 +149,7 @@ impl From<ResolveOptions> for ResolveProjectsOptions {
|
||||
optional_dependencies: opts.optional_dependencies,
|
||||
}],
|
||||
registry: opts.registry,
|
||||
named_registries: opts.named_registries,
|
||||
registries: opts.registries,
|
||||
authorization: opts.authorization,
|
||||
overrides: opts.overrides,
|
||||
catalogs: opts.catalogs,
|
||||
@@ -163,7 +173,10 @@ impl From<ResolveOptions> for ResolveProjectsOptions {
|
||||
#[derive(Clone)]
|
||||
pub struct VerifyLockfileOptions {
|
||||
pub registry: String,
|
||||
pub named_registries: DepMap,
|
||||
/// The registries the client declares, keyed by URL, in the shape
|
||||
/// of the `registries` setting. The default registry is not among
|
||||
/// them: it travels as `registry`.
|
||||
pub registries: RegistryDeclarations,
|
||||
pub authorization: Option<String>,
|
||||
pub overrides: Option<serde_json::Value>,
|
||||
pub lockfile: Lockfile,
|
||||
@@ -190,7 +203,7 @@ impl VerifyLockfileOptions {
|
||||
fn from_owned_resolve_projects_options(opts: ResolveProjectsOptions) -> Option<Self> {
|
||||
Some(Self {
|
||||
registry: opts.registry,
|
||||
named_registries: opts.named_registries,
|
||||
registries: opts.registries,
|
||||
authorization: opts.authorization,
|
||||
overrides: opts.overrides,
|
||||
lockfile: opts.lockfile?,
|
||||
@@ -342,7 +355,7 @@ impl PnprClient {
|
||||
) -> Result<(), PnprClientError> {
|
||||
let request = serde_json::json!({
|
||||
"registry": opts.registry,
|
||||
"namedRegistries": opts.named_registries,
|
||||
"registries": opts.registries,
|
||||
"overrides": opts.overrides,
|
||||
"lockfile": opts.lockfile,
|
||||
"trustLockfile": opts.trust_lockfile,
|
||||
@@ -435,7 +448,7 @@ impl PnprClient {
|
||||
let request = serde_json::json!({
|
||||
"projects": opts.projects,
|
||||
"registry": opts.registry,
|
||||
"namedRegistries": opts.named_registries,
|
||||
"registries": opts.registries,
|
||||
"overrides": opts.overrides,
|
||||
"catalogs": opts.catalogs,
|
||||
"lockfile": opts.lockfile,
|
||||
|
||||
@@ -51,7 +51,7 @@ fn resolve_projects_options() -> ResolveProjectsOptions {
|
||||
optional_dependencies: BTreeMap::new(),
|
||||
}],
|
||||
registry: "https://registry.test/".to_string(),
|
||||
named_registries: BTreeMap::new(),
|
||||
registries: BTreeMap::new(),
|
||||
authorization: None,
|
||||
overrides: None,
|
||||
catalogs: Some(BTreeMap::from([(
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use pnpm_config::RegistryDeclaration;
|
||||
use pnpm_pnpr_client::{
|
||||
PnprClient, PnprClientError, ResolveOptions, ResolveProject, ResolveProjectsOptions,
|
||||
VerifyLockfileOptions,
|
||||
@@ -206,7 +207,7 @@ fn options(
|
||||
dev_dependencies: BTreeMap::new(),
|
||||
optional_dependencies: BTreeMap::new(),
|
||||
registry: registry.to_string(),
|
||||
named_registries: BTreeMap::new(),
|
||||
registries: BTreeMap::new(),
|
||||
authorization: Some(authorization.to_string()),
|
||||
overrides: None,
|
||||
catalogs: None,
|
||||
@@ -722,3 +723,83 @@ async fn handshake_rejects_a_non_pnpr_server() {
|
||||
assert!(err.to_string().contains("not a pnpr server"), "got: {err}");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// The client describes its registries to the server the way its own
|
||||
/// `registries` setting does, so a scope the client routes elsewhere resolves
|
||||
/// from that registry and not from the request's default one.
|
||||
///
|
||||
/// This is also the contract test for the two ends of the protocol: the
|
||||
/// server reads the declarations under the key the client writes them.
|
||||
#[tokio::test]
|
||||
async fn resolves_a_scope_from_the_registry_declared_for_it() {
|
||||
let registry = TestRegistry::start();
|
||||
// A default registry that is allowlisted but serves nothing: reaching it
|
||||
// for the scoped package is the failure this test is looking for.
|
||||
let dead_default = "http://127.0.0.1:9/";
|
||||
let (pnpr_url, pnpr_auth, _storage) =
|
||||
start_pnpr_inner(None, Vec::new(), vec![registry.url(), dead_default.to_string()]).await;
|
||||
|
||||
let mut opts = options(dead_default, &pnpr_auth, deps([("@foo/no-deps", "1.0.0")]));
|
||||
opts.registries = BTreeMap::from([(
|
||||
registry.url(),
|
||||
RegistryDeclaration {
|
||||
scopes: Some(vec!["@foo".to_string()]),
|
||||
..RegistryDeclaration::default()
|
||||
},
|
||||
)]);
|
||||
|
||||
let outcome = PnprClient::new(pnpr_url).resolve(opts).await.expect("install should succeed");
|
||||
|
||||
let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages");
|
||||
assert!(
|
||||
packages.keys().any(|key| key.to_string().starts_with("@foo/no-deps@1.0.0")),
|
||||
"the declared registry should have served the scope, got: {:?}",
|
||||
packages.keys().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A client describes its whole configuration, including scopes a given
|
||||
/// resolve never reaches. Declaring a registry this pnpr does not serve is
|
||||
/// therefore not an error by itself — only fetching from one is.
|
||||
#[tokio::test]
|
||||
async fn a_declared_registry_the_resolve_never_reaches_is_not_rejected() {
|
||||
let registry = TestRegistry::start();
|
||||
let (pnpr_url, pnpr_auth, _storage) = start_pnpr(®istry.url()).await;
|
||||
|
||||
let mut opts = options(®istry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")]));
|
||||
opts.registries = BTreeMap::from([(
|
||||
"http://169.254.169.254/".to_string(),
|
||||
RegistryDeclaration {
|
||||
scopes: Some(vec!["@never-resolved".to_string()]),
|
||||
..RegistryDeclaration::default()
|
||||
},
|
||||
)]);
|
||||
|
||||
let outcome = PnprClient::new(pnpr_url).resolve(opts).await.expect("install should succeed");
|
||||
let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages");
|
||||
assert!(packages.keys().any(|key| key.to_string().starts_with("@foo/no-deps@1.0.0")));
|
||||
}
|
||||
|
||||
/// The SSRF boundary still holds where it matters: a scope the resolve *does*
|
||||
/// reach is refused before the request leaves the server.
|
||||
#[tokio::test]
|
||||
async fn a_declared_registry_the_resolve_reaches_is_refused() {
|
||||
let registry = TestRegistry::start();
|
||||
let (pnpr_url, pnpr_auth, _storage) = start_pnpr(®istry.url()).await;
|
||||
|
||||
let mut opts = options(®istry.url(), &pnpr_auth, deps([("@foo/no-deps", "1.0.0")]));
|
||||
opts.registries = BTreeMap::from([(
|
||||
"http://169.254.169.254/".to_string(),
|
||||
RegistryDeclaration {
|
||||
scopes: Some(vec!["@foo".to_string()]),
|
||||
..RegistryDeclaration::default()
|
||||
},
|
||||
)]);
|
||||
|
||||
let Err(error) = PnprClient::new(pnpr_url).resolve(opts).await else {
|
||||
panic!("an off-allowlist registry the resolve reaches must be refused")
|
||||
};
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("is not allowed by this pnpr server"), "{error}");
|
||||
assert!(error.contains("169.254.169.254"), "the refused origin is named: {error}");
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
use node_semver::{Range, Version};
|
||||
use pnpm_lockfile::{
|
||||
Lockfile, LockfileResolution, PkgName, PkgNameVer, PkgNameVerPeer, ProjectSnapshot,
|
||||
ResolvedDependencySpec, SnapshotEntry, TarballResolution, npm_tarball_url,
|
||||
pick_registry_for_package,
|
||||
RegistryContext, ResolvedDependencySpec, SnapshotEntry, TarballResolution, TarballUrlOptions,
|
||||
npm_tarball_url, pick_registry_for_package, registry_server_type,
|
||||
};
|
||||
use pnpm_resolving_parse_wanted_dependency::git_specifiers_are_equivalent;
|
||||
use pnpm_resolving_resolver_base::{CurrentPkg, PkgResolutionId, ResolveResult};
|
||||
@@ -18,8 +18,7 @@ use serde_json::{Map, Value};
|
||||
pub(crate) fn current_pkg_from_lockfile(
|
||||
lockfile: &Lockfile,
|
||||
key: &PkgNameVerPeer,
|
||||
registries: &std::collections::HashMap<String, String>,
|
||||
named_registries: &std::collections::HashMap<String, String>,
|
||||
registry_context: &RegistryContext,
|
||||
) -> Option<CurrentPkg> {
|
||||
let metadata_key = key.without_peer();
|
||||
let metadata = lockfile.packages.as_ref()?.get(&metadata_key)?;
|
||||
@@ -37,11 +36,12 @@ pub(crate) fn current_pkg_from_lockfile(
|
||||
// unknown alias (or an unthreaded registry map) withholds
|
||||
// `currentPkg` so the dep re-resolves normally.
|
||||
let (registry, tarball_version) = match registry_qualified {
|
||||
Some((registry_name, version)) => {
|
||||
(named_registries.get(registry_name)?.clone(), version.to_string())
|
||||
}
|
||||
Some((registry_name, version)) => (
|
||||
registry_context.registries_by_prefix.get(registry_name)?.clone(),
|
||||
version.to_string(),
|
||||
),
|
||||
None => (
|
||||
pick_registry_for_package(registries, &name, None),
|
||||
pick_registry_for_package(®istry_context.registries, &name, None),
|
||||
metadata_key.suffix.version().to_string(),
|
||||
),
|
||||
};
|
||||
@@ -49,7 +49,17 @@ pub(crate) fn current_pkg_from_lockfile(
|
||||
return None;
|
||||
}
|
||||
LockfileResolution::Tarball(TarballResolution {
|
||||
tarball: npm_tarball_url(&name, &tarball_version, ®istry),
|
||||
tarball: npm_tarball_url(
|
||||
&name,
|
||||
&tarball_version,
|
||||
TarballUrlOptions {
|
||||
registry: ®istry,
|
||||
server_type: registry_server_type(
|
||||
®istry_context.registry_options_by_url,
|
||||
®istry,
|
||||
),
|
||||
},
|
||||
),
|
||||
integrity: Some(registry_resolution.integrity.clone()),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The registry facts with only the scope map populated — what these tests
|
||||
/// vary; the aliases and per-registry settings stay empty.
|
||||
fn registry_context(registries: HashMap<String, String>) -> pnpm_lockfile::RegistryContext {
|
||||
pnpm_lockfile::RegistryContext { registries, ..Default::default() }
|
||||
}
|
||||
|
||||
use pnpm_lockfile::{
|
||||
ComVer, GitResolution, ImporterDepVersion, Lockfile, LockfileResolution, LockfileVersion,
|
||||
PackageMetadata, PkgName, PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, RegistryResolution,
|
||||
@@ -242,7 +248,7 @@ fn current_pkg_materializes_a_registry_resolution_into_its_tarball_url() {
|
||||
Some(HashMap::from([("react@18.2.0".parse().expect("parse key"), registry_metadata())]));
|
||||
|
||||
let current_pkg =
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new())
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry()))
|
||||
.expect("packages entry exists");
|
||||
|
||||
assert_eq!(current_pkg.id.to_string(), "react@18.2.0");
|
||||
@@ -264,7 +270,7 @@ fn current_pkg_routes_a_scoped_package_to_its_scope_registry() {
|
||||
registries.insert("@scope".to_string(), "https://scoped.example.test/".to_string());
|
||||
|
||||
let current_pkg =
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istries, &HashMap::new())
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(registries))
|
||||
.expect("packages entry exists");
|
||||
|
||||
let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else {
|
||||
@@ -287,7 +293,7 @@ fn current_pkg_passes_a_recorded_tarball_resolution_through() {
|
||||
lockfile.packages = Some(HashMap::from([(key.clone(), metadata)]));
|
||||
|
||||
let current_pkg =
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new())
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry()))
|
||||
.expect("packages entry exists");
|
||||
|
||||
let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else {
|
||||
@@ -301,7 +307,7 @@ fn current_pkg_is_none_without_a_packages_entry() {
|
||||
let key: PkgNameVerPeer = "react@18.2.0".parse().expect("parse key");
|
||||
let lockfile = empty_lockfile();
|
||||
assert!(
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, &default_registry(), &HashMap::new())
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(default_registry()))
|
||||
.is_none(),
|
||||
);
|
||||
}
|
||||
@@ -312,7 +318,7 @@ fn current_pkg_is_withheld_for_a_registry_entry_without_a_registry_map() {
|
||||
let mut lockfile = empty_lockfile();
|
||||
lockfile.packages = Some(HashMap::from([(key.clone(), registry_metadata())]));
|
||||
assert!(
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, &HashMap::new(), &HashMap::new())
|
||||
super::current_pkg_from_lockfile(&lockfile, &key, ®istry_context(HashMap::new()))
|
||||
.is_none(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,12 +250,7 @@ where
|
||||
// exists for a freshly resolving edge.
|
||||
let current_pkg = prior_key.as_ref().and_then(|key| {
|
||||
let lockfile = ctx.workspace.wanted_lockfile.as_ref()?;
|
||||
current_pkg_from_lockfile(
|
||||
lockfile,
|
||||
key,
|
||||
&ctx.workspace.registries,
|
||||
&ctx.workspace.named_registries,
|
||||
)
|
||||
current_pkg_from_lockfile(lockfile, key, &ctx.workspace.registry_context)
|
||||
});
|
||||
let opts_with_current_pkg;
|
||||
let opts = match current_pkg {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use pnpm_hooks::PnpmfileHooks;
|
||||
use pnpm_lockfile::{PkgName, PkgNameVerPeer};
|
||||
use pnpm_lockfile::{PkgName, PkgNameVerPeer, RegistryContext};
|
||||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
@@ -330,11 +330,10 @@ pub struct WorkspaceTreeCtx {
|
||||
/// tarball URL for the `currentPkg` payload. Empty when the entry
|
||||
/// point doesn't thread registries (then `currentPkg` is withheld
|
||||
/// for `Registry`-shaped entries rather than sent without a URL).
|
||||
pub(super) registries: std::collections::HashMap<String, String>,
|
||||
/// Alias → URL map of named registries (built-ins merged with the
|
||||
/// user's setting), for materializing a prior registry-qualified
|
||||
/// `Registry` lockfile resolution back into its tarball URL.
|
||||
pub(super) named_registries: std::collections::HashMap<String, String>,
|
||||
pub(super) registry_context: RegistryContext,
|
||||
/// `pkg id → importer id` of the importer whose occurrence owns
|
||||
/// that package's shared children context. Ownership is chosen by
|
||||
/// update-active status followed by `(depth, importer order, parent path)`:
|
||||
@@ -510,8 +509,7 @@ impl Default for WorkspaceTreeCtx {
|
||||
allowed_deprecated_versions: BTreeMap::new(),
|
||||
deprecation_log: None,
|
||||
auto_install_peers: false,
|
||||
registries: std::collections::HashMap::new(),
|
||||
named_registries: std::collections::HashMap::new(),
|
||||
registry_context: RegistryContext::default(),
|
||||
first_importer_by_pkg: Mutex::new(SnapshotCell::default()),
|
||||
first_walk_missing_by_pkg: Mutex::new(SnapshotCell::default()),
|
||||
changed_direct_deps: Mutex::new(HashMap::default()),
|
||||
@@ -1162,23 +1160,10 @@ impl WorkspaceTreeCtx {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the resolved registry map. See the `registries` field.
|
||||
/// Attach the registry facts. See the `registry_context` field.
|
||||
#[must_use]
|
||||
pub fn with_registries(
|
||||
mut self,
|
||||
registries: std::collections::HashMap<String, String>,
|
||||
) -> Self {
|
||||
self.registries = registries;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the named-registry alias map. See the `named_registries` field.
|
||||
#[must_use]
|
||||
pub fn with_named_registries(
|
||||
mut self,
|
||||
named_registries: std::collections::HashMap<String, String>,
|
||||
) -> Self {
|
||||
self.named_registries = named_registries;
|
||||
pub fn with_registry_context(mut self, registry_context: RegistryContext) -> Self {
|
||||
self.registry_context = registry_context;
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::{
|
||||
resolved_tree::ResolvedTree,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use pnpm_lockfile::RegistryContext;
|
||||
use pnpm_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pnpm_resolving_resolver_base::{Resolver, WantedDependency, parse_packument_timestamp};
|
||||
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
|
||||
@@ -139,14 +140,12 @@ pub struct WorkspaceResolveOptions {
|
||||
/// [`crate::ResolveImporterOptions::auto_install_peers`] — the
|
||||
/// setting is workspace-wide.
|
||||
pub auto_install_peers: bool,
|
||||
/// Resolved registry map (`"default"` + per-scope), for
|
||||
/// materializing a prior `Registry` lockfile resolution back into
|
||||
/// its tarball URL when building the `currentPkg` payload custom
|
||||
/// resolvers receive.
|
||||
pub registries: std::collections::HashMap<String, String>,
|
||||
/// Alias → URL map of named registries (built-ins merged with the
|
||||
/// user's setting). See [`WorkspaceResolveOptions::registries`].
|
||||
pub named_registries: std::collections::HashMap<String, String>,
|
||||
/// How a package's registry is decided and what it serves: the scope
|
||||
/// map, the named-registry aliases (built-ins merged with the user's
|
||||
/// setting), and the per-registry settings. Used to materialize a
|
||||
/// prior `Registry` lockfile resolution back into its tarball URL when
|
||||
/// building the `currentPkg` payload custom resolvers receive.
|
||||
pub registry_context: RegistryContext,
|
||||
}
|
||||
|
||||
/// Result of [`fn@resolve_workspace`]. The combined
|
||||
@@ -202,8 +201,7 @@ where
|
||||
update_reuse_scopes_by_importer,
|
||||
update_depth,
|
||||
auto_install_peers,
|
||||
registries,
|
||||
named_registries,
|
||||
registry_context,
|
||||
} = opts;
|
||||
// Taken before the lockfile moves into the workspace ctx below, and
|
||||
// only for the pre-pass that reads it — a lockfile is untrusted
|
||||
@@ -226,8 +224,7 @@ where
|
||||
.with_allowed_deprecated_versions(allowed_deprecated_versions)
|
||||
.with_deprecation_log(deprecation_log)
|
||||
.with_auto_install_peers(auto_install_peers)
|
||||
.with_registries(registries)
|
||||
.with_named_registries(named_registries),
|
||||
.with_registry_context(registry_context),
|
||||
);
|
||||
|
||||
// Build every importer's options up front so the `time-based`
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
};
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use pnpm_lockfile::{DirectoryResolution, LockfileResolution};
|
||||
use pnpm_lockfile::{DirectoryResolution, LockfileResolution, RegistryContext};
|
||||
use pnpm_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pnpm_resolving_resolver_base::{
|
||||
LatestQuery, NoMatchingVersionError, PkgResolutionId, PreferredVersions, RegistryResponseError,
|
||||
@@ -196,7 +196,7 @@ fn importer_opts(
|
||||
|
||||
fn workspace_opts(pick_lowest_direct: bool, time_based: bool) -> WorkspaceResolveOptions {
|
||||
WorkspaceResolveOptions {
|
||||
named_registries: std::collections::HashMap::new(),
|
||||
registry_context: RegistryContext::default(),
|
||||
dedupe_peers: false,
|
||||
dedupe_injected_deps: false,
|
||||
dedupe_peer_dependents: false,
|
||||
@@ -218,7 +218,6 @@ fn workspace_opts(pick_lowest_direct: bool, time_based: bool) -> WorkspaceResolv
|
||||
update_reuse_scopes_by_importer: BTreeMap::new(),
|
||||
update_depth: crate::UpdateDepth::UNLIMITED,
|
||||
auto_install_peers: false,
|
||||
registries: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ pub struct CreateNpmResolutionVerifierOptions {
|
||||
pub registries: HashMap<String, String>,
|
||||
/// User-defined named-registry aliases (e.g. `gh:` →
|
||||
/// `https://npm.pkg.github.com/`). Merged with
|
||||
/// [`crate::BUILTIN_NAMED_REGISTRIES`].
|
||||
pub named_registries: HashMap<String, String>,
|
||||
/// [`crate::BUILTIN_REGISTRIES_BY_PREFIX`].
|
||||
pub registries_by_prefix: HashMap<String, String>,
|
||||
pub http_client: Arc<ThrottledClient>,
|
||||
pub auth_headers: Arc<AuthHeaders>,
|
||||
/// Root of pnpm's on-disk metadata mirror. When set, the verifier
|
||||
@@ -184,7 +184,7 @@ pub struct NpmResolutionVerifier {
|
||||
/// Alias → URL map (built-ins merged with the user's setting) for
|
||||
/// routing registry-qualified lockfile keys, which carry no tarball
|
||||
/// URL for the prefix list to match.
|
||||
named_registries_by_name: HashMap<String, String>,
|
||||
registries_by_prefix: HashMap<String, String>,
|
||||
http_client: Arc<ThrottledClient>,
|
||||
auth_headers: Arc<AuthHeaders>,
|
||||
cache_dir: Option<PathBuf>,
|
||||
@@ -241,12 +241,12 @@ pub fn create_npm_resolution_verifier(
|
||||
None
|
||||
};
|
||||
|
||||
let named_registry_prefixes = named_registry_tarball_prefixes(&opts.named_registries);
|
||||
let named_registries_by_name = opts.named_registries.clone();
|
||||
let named_registry_prefixes = named_registry_tarball_prefixes(&opts.registries_by_prefix);
|
||||
let registries_by_prefix = opts.registries_by_prefix.clone();
|
||||
|
||||
let sorted_min_age_excludes = sorted_unique(&opts.minimum_release_age_exclude_patterns);
|
||||
let sorted_trust_excludes = sorted_unique(&opts.trust_policy_exclude_patterns);
|
||||
let named_registries_routing = named_registries_routing_digest(&named_registries_by_name);
|
||||
let named_registries_routing = named_registries_routing_digest(®istries_by_prefix);
|
||||
|
||||
let policy_snapshot = build_policy_snapshot(
|
||||
opts.minimum_release_age.unwrap_or(0),
|
||||
@@ -269,7 +269,7 @@ pub fn create_npm_resolution_verifier(
|
||||
sorted_trust_excludes,
|
||||
registries: opts.registries,
|
||||
named_registry_prefixes,
|
||||
named_registries_by_name,
|
||||
registries_by_prefix,
|
||||
http_client: opts.http_client,
|
||||
auth_headers: opts.auth_headers,
|
||||
cache_dir: opts.cache_dir,
|
||||
@@ -414,7 +414,7 @@ impl NpmResolutionVerifier {
|
||||
// closed on an unknown alias: none of the metadata-backed checks
|
||||
// below could vouch for the entry without its registry URL.
|
||||
let named_registry = match ctx.registry_name {
|
||||
Some(registry_name) => match self.named_registries_by_name.get(registry_name) {
|
||||
Some(registry_name) => match self.registries_by_prefix.get(registry_name) {
|
||||
Some(url) => Some(url.clone()),
|
||||
None => {
|
||||
return ResolutionVerification::Err {
|
||||
@@ -1107,8 +1107,8 @@ fn sorted_unique(values: &[String]) -> Vec<String> {
|
||||
deduped
|
||||
}
|
||||
|
||||
fn named_registries_routing_digest(named_registries: &HashMap<String, String>) -> String {
|
||||
let sorted: BTreeMap<&str, &str> = named_registries
|
||||
fn named_registries_routing_digest(registries_by_prefix: &HashMap<String, String>) -> String {
|
||||
let sorted: BTreeMap<&str, &str> = registries_by_prefix
|
||||
.iter()
|
||||
.map(|(alias, registry)| (alias.as_str(), registry.as_str()))
|
||||
.collect();
|
||||
|
||||
@@ -62,7 +62,7 @@ fn default_opts(registry_url: &str) -> CreateNpmResolutionVerifierOptions {
|
||||
trust_policy_exclude_patterns: Vec::new(),
|
||||
trust_policy_ignore_after: None,
|
||||
registries: registries_with_default(registry_url),
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client: Arc::new(ThrottledClient::default()),
|
||||
auth_headers: Arc::new(AuthHeaders::default()),
|
||||
cache_dir: None,
|
||||
@@ -971,7 +971,7 @@ async fn verify_routes_via_named_registry_prefix() {
|
||||
// breaks, the request would target the bogus URL and the test
|
||||
// would fail with a connection error instead of finding the mock.
|
||||
let mut opts = default_opts("http://nonexistent.example.invalid/");
|
||||
opts.named_registries = named;
|
||||
opts.registries_by_prefix = named;
|
||||
opts.minimum_release_age = Some(60 * 24);
|
||||
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
|
||||
let verifier = create_npm_resolution_verifier(opts);
|
||||
@@ -1052,11 +1052,14 @@ fn can_trust_past_check_accepts_looser_min_age() {
|
||||
#[test]
|
||||
fn can_trust_past_check_rejects_changed_named_registry_mapping() {
|
||||
let mut opts = default_opts("https://registry.example/");
|
||||
opts.named_registries.insert("work".to_string(), "https://registry.work.example/".to_string());
|
||||
opts.registries_by_prefix
|
||||
.insert("work".to_string(), "https://registry.work.example/".to_string());
|
||||
let verifier = create_npm_resolution_verifier(opts);
|
||||
let cached = verifier.policy().clone();
|
||||
let mut changed_opts = default_opts("https://registry.example/");
|
||||
changed_opts.named_registries.insert("work".to_string(), "https://other.example/".to_string());
|
||||
changed_opts
|
||||
.registries_by_prefix
|
||||
.insert("work".to_string(), "https://other.example/".to_string());
|
||||
let changed = create_npm_resolution_verifier(changed_opts);
|
||||
|
||||
// Pins that the rejection below comes from the URL change and not from
|
||||
|
||||
@@ -30,6 +30,19 @@ pub enum FetchMetadataError {
|
||||
pkg_mirror: std::path::PathBuf,
|
||||
},
|
||||
|
||||
/// The deployment's route policy refuses this origin. Only a server
|
||||
/// with an [`UpstreamRouteHook`](pnpm_network::UpstreamRouteHook)
|
||||
/// raises it: the CLI fetches as the user and reaches whatever the user
|
||||
/// configured.
|
||||
#[display(
|
||||
"{url} is not allowed by this pnpr server; the operator must declare its registry as a public route or an upstream"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_REGISTRY_OFF_ALLOWLIST))]
|
||||
OffAllowlist {
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
},
|
||||
|
||||
#[display("Failed to fetch metadata from {url}: {error}")]
|
||||
#[diagnostic(code(ERR_PNPM_RESOLVING_NPM_RESOLVER_NETWORK_ERROR))]
|
||||
Network {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! them into a violation reason instead of swallowing.
|
||||
|
||||
use chrono::DateTime;
|
||||
use pnpm_network::{AuthHeaders, ThrottledClient};
|
||||
use pnpm_network::{AuthHeaders, ThrottledClient, redact_url_credentials};
|
||||
|
||||
use crate::FetchMetadataError;
|
||||
|
||||
@@ -39,6 +39,9 @@ pub async fn fetch_attestation_published_at(
|
||||
) -> Result<Option<String>, FetchMetadataError> {
|
||||
let registry = opts.registry.trim_end_matches('/');
|
||||
let url = format!("{registry}/-/npm/v1/attestations/{pkg_name}@{version}");
|
||||
if !opts.auth_headers.allows_fetch(&url) {
|
||||
return Err(FetchMetadataError::OffAllowlist { url: redact_url_credentials(&url) });
|
||||
}
|
||||
// Verification-only lookup: queue in the background class so it
|
||||
// never outranks resolution-gating fetches.
|
||||
let mut request = opts
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(crate) struct MetadataRequestOptions<'a> {
|
||||
pub(crate) async fn send_metadata_request<'a>(
|
||||
opts: &MetadataRequestOptions<'a>,
|
||||
) -> Result<(ThrottledClientGuard<'a>, Response), FetchMetadataError> {
|
||||
// The route policy decides whether this origin may be reached at all,
|
||||
// here rather than when the request that named it was read: a registry a
|
||||
// caller configures but never resolves from costs nothing.
|
||||
if !opts.auth_headers.allows_fetch(opts.url) {
|
||||
return Err(FetchMetadataError::OffAllowlist { url: redact_url_credentials(opts.url) });
|
||||
}
|
||||
let etag = if opts.bypass_cache { None } else { opts.etag.filter(|value| !value.is_empty()) };
|
||||
let modified = if opts.bypass_cache {
|
||||
None
|
||||
|
||||
@@ -53,7 +53,7 @@ pub use fetch_full_metadata_cached::{FetchFullMetadataCachedOptions, fetch_full_
|
||||
pub use infer_range_spec_style::infer_range_spec_style;
|
||||
pub use mirror::{ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR};
|
||||
pub use named_registry::{
|
||||
BUILTIN_NAMED_REGISTRIES, MergeNamedRegistriesError, merge_named_registries,
|
||||
BUILTIN_REGISTRIES_BY_PREFIX, MergeNamedRegistriesError, merge_named_registries,
|
||||
named_registry_tarball_prefixes, pick_registry_for_version,
|
||||
};
|
||||
pub use named_registry_resolver::NamedRegistryResolver;
|
||||
|
||||
@@ -29,7 +29,7 @@ use reqwest::Url;
|
||||
/// against, so an org that proxies
|
||||
/// npmjs should point `npmjs` at their proxy to keep verification
|
||||
/// going there rather than to the public host.
|
||||
pub const BUILTIN_NAMED_REGISTRIES: &[(&str, &str)] =
|
||||
pub const BUILTIN_REGISTRIES_BY_PREFIX: &[(&str, &str)] =
|
||||
&[("gh", "https://npm.pkg.github.com/"), ("npmjs", "https://registry.npmjs.org/")];
|
||||
|
||||
/// Failure from [`merge_named_registries`], surfaced with the
|
||||
@@ -100,7 +100,7 @@ pub fn merge_named_registries(
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut merged: HashMap<String, String> = BUILTIN_NAMED_REGISTRIES
|
||||
let mut merged: HashMap<String, String> = BUILTIN_REGISTRIES_BY_PREFIX
|
||||
.iter()
|
||||
.map(|(name, url)| ((*name).to_string(), (*url).to_string()))
|
||||
.collect();
|
||||
@@ -118,8 +118,10 @@ fn is_valid_http_url(url: &str) -> bool {
|
||||
/// Equal lengths tie-break lexicographically, since length alone leaves
|
||||
/// the order to `HashMap` iteration.
|
||||
#[must_use]
|
||||
pub fn named_registry_tarball_prefixes(named_registries: &HashMap<String, String>) -> Vec<String> {
|
||||
let mut prefixes: Vec<String> = named_registries
|
||||
pub fn named_registry_tarball_prefixes(
|
||||
registries_by_prefix: &HashMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let mut prefixes: Vec<String> = registries_by_prefix
|
||||
.values()
|
||||
.filter_map(|url| Url::parse(url).ok())
|
||||
.map(|parsed| {
|
||||
|
||||
@@ -61,8 +61,8 @@ pub struct NamedRegistryResolver<Cache: PackageMetaCache> {
|
||||
/// (`gh:` → GitHub Packages) plus any user-supplied overrides
|
||||
/// from `pnpm-workspace.yaml#namedRegistries`. Already validated
|
||||
/// — every URL parses and is http(s).
|
||||
pub named_registries: HashMap<String, String>,
|
||||
/// Precomputed key set of [`Self::named_registries`]. The parser
|
||||
pub registries_by_prefix: HashMap<String, String>,
|
||||
/// Precomputed key set of [`Self::registries_by_prefix`]. The parser
|
||||
/// checks aliases against this set per call, so caching it
|
||||
/// avoids rebuilding the set for every resolve.
|
||||
pub registry_names: HashSet<String>,
|
||||
@@ -136,7 +136,7 @@ impl<Cache: PackageMetaCache + 'static> NamedRegistryResolver<Cache> {
|
||||
|
||||
// Defensive: should never trigger because the parser checks
|
||||
// the alias set first, but kept as a belt-and-braces guard.
|
||||
let Some(registry) = self.named_registries.get(®istry_name) else {
|
||||
let Some(registry) = self.registries_by_prefix.get(®istry_name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
|
||||
@@ -65,13 +65,13 @@ const ACME_PRIVATE_BODY: &str = r#"{
|
||||
reason = "nested test helper called many times; owned arg keeps the call sites and assert ergonomics simple"
|
||||
)]
|
||||
fn build_resolver(
|
||||
user_named_registries: HashMap<String, String>,
|
||||
user_registries_by_prefix: HashMap<String, String>,
|
||||
) -> (NamedRegistryResolver<InMemoryPackageMetaCache>, TempDir) {
|
||||
let merged = merge_named_registries(&user_named_registries).expect("URLs are valid");
|
||||
let merged = merge_named_registries(&user_registries_by_prefix).expect("URLs are valid");
|
||||
let registry_names: HashSet<String> = merged.keys().cloned().collect();
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let resolver = NamedRegistryResolver {
|
||||
named_registries: merged,
|
||||
registries_by_prefix: merged,
|
||||
registry_names,
|
||||
http_client: Arc::new(ThrottledClient::default()),
|
||||
auth_headers: Arc::new(AuthHeaders::default()),
|
||||
|
||||
@@ -86,11 +86,11 @@ pub struct NpmResolver<Cache: PackageMetaCache> {
|
||||
pub registries: HashMap<String, String>,
|
||||
/// User-supplied named-registry aliases (e.g. `gh:` →
|
||||
/// `https://npm.pkg.github.com/`). Merged with
|
||||
/// [`crate::BUILTIN_NAMED_REGISTRIES`] at construction. Today
|
||||
/// [`crate::BUILTIN_REGISTRIES_BY_PREFIX`] at construction. Today
|
||||
/// only consulted by the named-registry resolver (out of scope
|
||||
/// for this port); kept here so the install layer can build one
|
||||
/// resolver instance with the full registry view.
|
||||
pub named_registries: HashMap<String, String>,
|
||||
pub registries_by_prefix: HashMap<String, String>,
|
||||
pub http_client: Arc<ThrottledClient>,
|
||||
pub auth_headers: Arc<AuthHeaders>,
|
||||
pub meta_cache: Arc<Cache>,
|
||||
|
||||
@@ -69,7 +69,7 @@ fn build_resolver_with_registries(
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let resolver = NpmResolver {
|
||||
registries,
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client: Arc::new(ThrottledClient::default()),
|
||||
auth_headers: Arc::new(AuthHeaders::default()),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
@@ -746,7 +746,7 @@ async fn shared_manifest_cache_does_not_leak_across_registries() {
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let resolver = NpmResolver {
|
||||
registries,
|
||||
named_registries: HashMap::new(),
|
||||
registries_by_prefix: HashMap::new(),
|
||||
http_client: Arc::new(ThrottledClient::default()),
|
||||
auth_headers: Arc::new(AuthHeaders::default()),
|
||||
meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
|
||||
|
||||
@@ -198,6 +198,9 @@ pub(crate) fn tarball_error_to_request_retry(err: &TarballError) -> RequestRetry
|
||||
TarballError::FetchTarball(_) => {
|
||||
out.code = Some("ERR_PNPM_FETCH".to_string());
|
||||
}
|
||||
TarballError::OffAllowlist { .. } => {
|
||||
out.code = Some("ERR_PNPM_REGISTRY_OFF_ALLOWLIST".to_string());
|
||||
}
|
||||
TarballError::Checksum(_) => {
|
||||
out.code = Some("ERR_PNPM_TARBALL_INTEGRITY".to_string());
|
||||
}
|
||||
@@ -257,6 +260,8 @@ pub(crate) fn is_transient_error(err: &TarballError) -> bool {
|
||||
match err {
|
||||
TarballError::HttpStatus(http) => !matches!(http.status, 401 | 403 | 404),
|
||||
TarballError::ReadLocalTarball { .. } => false,
|
||||
// A route policy does not change between attempts.
|
||||
TarballError::OffAllowlist { .. } => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -384,6 +389,13 @@ pub(crate) async fn fetch_and_extract_once<Reporter: self::Reporter>(
|
||||
// URL. When the pool is saturated, the package with the most
|
||||
// estimated pipeline work claims the next freed slot, so the
|
||||
// longest download+extract jobs never start last.
|
||||
// The route policy decides whether this origin may be reached at all,
|
||||
// at the fetch rather than when the request that named it was read.
|
||||
if !auth_headers.allows_fetch(package_url) {
|
||||
return Err(TarballError::OffAllowlist {
|
||||
url: pnpm_network::redact_url_credentials(package_url),
|
||||
});
|
||||
}
|
||||
let client = http_client.acquire_for_url_with_priority(package_url, download_priority).await;
|
||||
let mut request = client.get(package_url);
|
||||
// Resolve the per-URL auth header and attach it. Tarball hosts that
|
||||
|
||||
@@ -69,6 +69,20 @@ pub enum TarballError {
|
||||
#[diagnostic(code(ERR_PNPM_TARBALL_FETCH_TARBALL))]
|
||||
FetchTarball(NetworkError),
|
||||
|
||||
/// The deployment's route policy refuses this origin. Only a server
|
||||
/// with an [`UpstreamRouteHook`](pnpm_network::UpstreamRouteHook)
|
||||
/// raises it: the CLI fetches as the user and reaches whatever the user
|
||||
/// configured.
|
||||
#[from(ignore)]
|
||||
#[display(
|
||||
"{url} is not allowed by this pnpr server; the operator must declare its registry as a public route or an upstream"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_REGISTRY_OFF_ALLOWLIST))]
|
||||
OffAllowlist {
|
||||
#[error(not(source))]
|
||||
url: String,
|
||||
},
|
||||
|
||||
#[diagnostic(code(ERR_PNPM_TARBALL_HTTP_STATUS))]
|
||||
HttpStatus(HttpStatusError),
|
||||
|
||||
|
||||
@@ -221,6 +221,13 @@ pub(crate) async fn fetch_and_extract_zip_once<Reporter: self::Reporter>(
|
||||
let network_error =
|
||||
|error| TarballError::FetchTarball(NetworkError { url: package_url.to_string(), error });
|
||||
|
||||
// The route policy decides whether this origin may be reached at all,
|
||||
// at the fetch rather than when the request that named it was read.
|
||||
if !auth_headers.allows_fetch(package_url) {
|
||||
return Err(TarballError::OffAllowlist {
|
||||
url: pnpm_network::redact_url_credentials(package_url),
|
||||
});
|
||||
}
|
||||
let client = http_client.acquire_for_url(package_url).await;
|
||||
|
||||
let mut request = client.get(package_url);
|
||||
|
||||
@@ -29,7 +29,7 @@ use std::{
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use pnpm_lockfile::{LockfileResolution, PkgName, PkgNameVer, TarballResolution};
|
||||
use pnpm_lockfile::{LockfileResolution, PkgName, PkgNameVer, RegistryContext, TarballResolution};
|
||||
use pnpm_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pnpm_resolving_deps_resolver::{
|
||||
ResolveImporterOptions, UpdateDepth, UpdateReuseScope, WorkspaceImporter,
|
||||
@@ -274,7 +274,7 @@ fn importer_options(importer: &WorkspaceImporter<'_>) -> ResolveImporterOptions
|
||||
|
||||
fn workspace_options() -> WorkspaceResolveOptions {
|
||||
WorkspaceResolveOptions {
|
||||
named_registries: HashMap::new(),
|
||||
registry_context: RegistryContext::default(),
|
||||
dedupe_peers: true,
|
||||
dedupe_injected_deps: true,
|
||||
dedupe_peer_dependents: true,
|
||||
@@ -296,7 +296,6 @@ fn workspace_options() -> WorkspaceResolveOptions {
|
||||
allowed_deprecated_versions: BTreeMap::new(),
|
||||
deprecation_log: None,
|
||||
auto_install_peers: true,
|
||||
registries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { DEFAULT_REGISTRIES, normalizeRegistries } from '@pnpm/config.normalize-registries'
|
||||
import { DEFAULT_REGISTRIES_BY_SCOPE, normalizeRegistriesByScope } from '@pnpm/config.normalize-registries'
|
||||
import type { Config, ConfigContext } from '@pnpm/config.reader'
|
||||
import type { LogBase } from '@pnpm/logger'
|
||||
import type { StoreController } from '@pnpm/store.controller-types'
|
||||
import type { Registries, RegistryConfig, SupportedArchitectures } from '@pnpm/types'
|
||||
import type { RegistriesByScope, RegistryConfig, SupportedArchitectures } from '@pnpm/types'
|
||||
import { loadJsonFile } from 'load-json-file'
|
||||
|
||||
export type StrictBuildOptions = {
|
||||
@@ -28,7 +28,7 @@ export type StrictBuildOptions = {
|
||||
storeController: StoreController
|
||||
force: boolean
|
||||
useLockfile: boolean
|
||||
registries: Registries
|
||||
registriesByScope: RegistriesByScope
|
||||
dir: string
|
||||
pnpmHomeDir: string
|
||||
|
||||
@@ -78,7 +78,7 @@ const defaults = async (opts: BuildOptions): Promise<StrictBuildOptions> => {
|
||||
pending: false,
|
||||
production: true,
|
||||
configByUri: {},
|
||||
registries: DEFAULT_REGISTRIES,
|
||||
registriesByScope: DEFAULT_REGISTRIES_BY_SCOPE,
|
||||
scriptsPrependNodePath: false,
|
||||
shamefullyHoist: false,
|
||||
shellEmulator: false,
|
||||
@@ -109,7 +109,7 @@ export async function extendBuildOptions (
|
||||
...opts,
|
||||
storeDir: defaultOpts.storeDir,
|
||||
}
|
||||
extendedOpts.registries = normalizeRegistries(extendedOpts.registries)
|
||||
extendedOpts.registriesByScope = normalizeRegistriesByScope(extendedOpts.registriesByScope)
|
||||
// Mirror extendInstallOptions: under a global virtual store, the virtual
|
||||
// store directory is `<storeDir>/links`, not the per-project
|
||||
// `node_modules/.pnpm`. Without this, getContext() in the build step
|
||||
|
||||
@@ -169,7 +169,6 @@ export async function buildSelectedPkgs (
|
||||
packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`,
|
||||
pendingBuilds: ctx.pendingBuilds,
|
||||
publicHoistPattern: ctx.publicHoistPattern,
|
||||
registries: ctx.registries,
|
||||
skipped: Array.from(ctx.skipped),
|
||||
storeDir: ctx.modulesFile?.storeDir ?? ctx.storeDir,
|
||||
virtualStoreDir: ctx.modulesFile?.virtualStoreDir ?? ctx.virtualStoreDir,
|
||||
@@ -251,7 +250,6 @@ export async function buildProjects (
|
||||
packageManager: `${opts.packageManager.name}@${opts.packageManager.version}`,
|
||||
pendingBuilds: ctx.pendingBuilds,
|
||||
publicHoistPattern: ctx.publicHoistPattern,
|
||||
registries: ctx.registries,
|
||||
skipped: Array.from(ctx.skipped),
|
||||
storeDir: ctx.storeDir,
|
||||
virtualStoreDir: ctx.virtualStoreDir,
|
||||
|
||||
@@ -80,7 +80,7 @@ export type RebuildCommandOpts = Pick<Config,
|
||||
| 'engineStrict'
|
||||
| 'lockfileDir'
|
||||
| 'nodeLinker'
|
||||
| 'registries'
|
||||
| 'registriesByScope'
|
||||
| 'scriptShell'
|
||||
| 'sideEffectsCache'
|
||||
| 'sideEffectsCacheReadonly'
|
||||
|
||||
@@ -27,7 +27,7 @@ type RecursiveRebuildOpts = CreateStoreControllerOptions & Pick<Config,
|
||||
| 'lockfileOnly'
|
||||
| 'nodeLinker'
|
||||
| 'packageConfigs'
|
||||
| 'registries'
|
||||
| 'registriesByScope'
|
||||
| 'sharedWorkspaceLockfile'
|
||||
> & Pick<ConfigContext,
|
||||
| 'hooks'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Config, ConfigContext } from '@pnpm/config.reader'
|
||||
import { writeSettings } from '@pnpm/config.writer'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { install } from '@pnpm/installing.commands'
|
||||
import { type StrictModules, writeModulesManifest } from '@pnpm/installing.modules-yaml'
|
||||
import { type Modules, writeModulesManifest } from '@pnpm/installing.modules-yaml'
|
||||
import { globalInfo } from '@pnpm/logger'
|
||||
import { lexCompare } from '@pnpm/text.ordinal-comparator'
|
||||
import chalk from 'chalk'
|
||||
@@ -206,7 +206,7 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp
|
||||
} else {
|
||||
delete modulesManifest.ignoredBuilds
|
||||
}
|
||||
await writeModulesManifest(modulesDir, modulesManifest as StrictModules)
|
||||
await writeModulesManifest(modulesDir, modulesManifest as Modules)
|
||||
}
|
||||
if (buildPackages.length) {
|
||||
if (opts.enableGlobalVirtualStore) {
|
||||
|
||||
@@ -49,13 +49,11 @@ test('rebuilds dependencies', async () => {
|
||||
])
|
||||
const gitDepPath = modules!.pendingBuilds[1]
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true },
|
||||
}, [])
|
||||
@@ -103,7 +101,7 @@ test('rebuilds dependencies', async () => {
|
||||
})
|
||||
|
||||
test('rebuilds dependencies in the global virtual store', async () => {
|
||||
const project = prepare()
|
||||
prepare()
|
||||
const cacheDir = path.resolve('cache')
|
||||
const storeDir = path.resolve('store')
|
||||
|
||||
@@ -137,14 +135,12 @@ test('rebuilds dependencies in the global virtual store', async () => {
|
||||
// The build was deferred, so the postinstall artifact is not there yet.
|
||||
expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeFalsy()
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
enableGlobalVirtualStore: true,
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
|
||||
}, [])
|
||||
@@ -259,13 +255,11 @@ test('skipIfHasSideEffectsCache', async () => {
|
||||
'@pnpm.e2e/pre-and-postinstall-scripts-example@1.0.0',
|
||||
])
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: true,
|
||||
registries: modulesManifest!.registries!,
|
||||
skipIfHasSideEffectsCache: true,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
|
||||
@@ -281,7 +275,7 @@ test('skipIfHasSideEffectsCache', async () => {
|
||||
})
|
||||
|
||||
test('rebuild does not fail when a linked package is present', async () => {
|
||||
const project = prepare()
|
||||
prepare()
|
||||
const cacheDir = path.resolve('cache')
|
||||
const storeDir = path.resolve('store')
|
||||
f.copy('local-pkg', path.resolve('..', 'local-pkg'))
|
||||
@@ -298,13 +292,11 @@ test('rebuild does not fail when a linked package is present', async () => {
|
||||
'--config.enableGlobalVirtualStore=false',
|
||||
])
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { 'local-pkg': true, 'is-positive': true },
|
||||
}, [])
|
||||
@@ -336,7 +328,6 @@ test('rebuilds specific dependencies', async () => {
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { [gitDepPath]: true },
|
||||
}, ['install-scripts-example-for-pnpm'])
|
||||
@@ -397,7 +388,6 @@ test('rebuild with pending option', async () => {
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: true,
|
||||
registries: modules!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, [gitDepPath]: true },
|
||||
}, [])
|
||||
@@ -451,7 +441,6 @@ test('rebuild dependencies in correct order', async () => {
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: false,
|
||||
registries: modules!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/with-postinstall-a': true, '@pnpm.e2e/with-postinstall-b': true },
|
||||
}, [])
|
||||
@@ -487,13 +476,11 @@ test('rebuild links bins', async () => {
|
||||
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/has-generated-bins-as-dep/node_modules/.bin/cmd1'))).toBeFalsy()
|
||||
expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/has-generated-bins-as-dep/node_modules/.bin/cmd2'))).toBeFalsy()
|
||||
|
||||
const modules = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: true,
|
||||
registries: modules!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/has-generated-bins-as-dep': true, '@pnpm.e2e/generated-bins': true },
|
||||
}, [])
|
||||
@@ -505,7 +492,7 @@ test('rebuild links bins', async () => {
|
||||
})
|
||||
|
||||
test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => {
|
||||
const project = prepare({
|
||||
prepare({
|
||||
dependencies: {
|
||||
'@pnpm.e2e/pre-and-postinstall-scripts-example': '1.0.0',
|
||||
},
|
||||
@@ -528,13 +515,11 @@ test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => {
|
||||
|
||||
const reporter = jest.fn()
|
||||
|
||||
const modules = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: true,
|
||||
registries: modules!.registries!,
|
||||
reporter,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true, '@pnpm.e2e/not-compatible-with-any-os': true },
|
||||
@@ -542,7 +527,7 @@ test(`rebuild should not fail on incomplete ${WANTED_LOCKFILE}`, async () => {
|
||||
})
|
||||
|
||||
test('rebuilds in the global virtual store when the approval was granted after the install', async () => {
|
||||
const project = prepare()
|
||||
prepare()
|
||||
const cacheDir = path.resolve('cache')
|
||||
const storeDir = path.resolve('store')
|
||||
|
||||
@@ -571,14 +556,12 @@ test('rebuilds in the global virtual store when the approval was granted after t
|
||||
const pkgInGvs = path.join(pkgVersionDir, hash, 'node_modules/@pnpm.e2e/pre-and-postinstall-scripts-example')
|
||||
expect(fs.existsSync(path.join(pkgInGvs, 'generated-by-postinstall.js'))).toBeFalsy()
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
enableGlobalVirtualStore: true,
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
|
||||
}, [])
|
||||
@@ -589,7 +572,7 @@ test('rebuilds in the global virtual store when the approval was granted after t
|
||||
// GHSA-c59q-g84q-2gj5: a traversal depPath key must not point the build
|
||||
// at a directory outside the virtual store.
|
||||
test('rebuild refuses a lockfile depPath name that escapes the virtual store', async () => {
|
||||
const project = prepare()
|
||||
prepare()
|
||||
const cacheDir = path.resolve('cache')
|
||||
const storeDir = path.resolve('store')
|
||||
|
||||
@@ -624,13 +607,11 @@ test('rebuild refuses a lockfile depPath name that escapes the virtual store', a
|
||||
fs.writeFileSync(path.join(escapedPkgDir, 'write-marker.cjs'),
|
||||
`require('fs').writeFileSync(${JSON.stringify(marker)}, 'pwned')`)
|
||||
|
||||
const modulesManifest = project.readModulesManifest()
|
||||
await expect(rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
cacheDir,
|
||||
dir: process.cwd(),
|
||||
pending: false,
|
||||
registries: modulesManifest!.registries!,
|
||||
storeDir,
|
||||
// Approve the build, so that what stops the script is the contained
|
||||
// join and not the build policy. The key has to carry the version:
|
||||
|
||||
@@ -52,13 +52,11 @@ test('pnpm recursive rebuild', async () => {
|
||||
projects['project-2'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')
|
||||
projects['project-2'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')
|
||||
|
||||
const modulesManifest = projects['project-1'].readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
allProjects,
|
||||
dir: process.cwd(),
|
||||
recursive: true,
|
||||
registries: modulesManifest!.registries!,
|
||||
selectedProjectsGraph,
|
||||
workspaceDir: process.cwd(),
|
||||
allowBuilds: { '@pnpm.e2e/pre-and-postinstall-scripts-example': true },
|
||||
@@ -129,14 +127,12 @@ test('pnpm recursive rebuild with hoisted node linker', async () => {
|
||||
projects['project-4'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-preinstall.js')
|
||||
projects['project-4'].hasNot('@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js')
|
||||
|
||||
const modulesManifest = rootProject.readModulesManifest()
|
||||
await rebuild.handler({
|
||||
...DEFAULT_OPTS,
|
||||
allProjects,
|
||||
dir: process.cwd(),
|
||||
nodeLinker: 'hoisted',
|
||||
recursive: true,
|
||||
registries: modulesManifest!.registries!,
|
||||
selectedProjectsGraph,
|
||||
lockfileDir: process.cwd(),
|
||||
workspaceDir: process.cwd(),
|
||||
@@ -287,7 +283,6 @@ test('only build allowBuilds (not others)', async () => {
|
||||
'@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js'
|
||||
)
|
||||
|
||||
const modulesManifest = projects['project-1'].readModulesManifest()
|
||||
await rebuild.handler(
|
||||
{
|
||||
...DEFAULT_OPTS,
|
||||
@@ -295,7 +290,6 @@ test('only build allowBuilds (not others)', async () => {
|
||||
allProjects,
|
||||
dir: process.cwd(),
|
||||
recursive: true,
|
||||
registries: modulesManifest!.registries!,
|
||||
selectedProjectsGraph,
|
||||
workspaceDir: process.cwd(),
|
||||
},
|
||||
@@ -382,7 +376,6 @@ test('only build allowBuilds', async () => {
|
||||
'@pnpm.e2e/pre-and-postinstall-scripts-example/generated-by-postinstall.js'
|
||||
)
|
||||
|
||||
const modulesManifest = projects['project-1'].readModulesManifest()
|
||||
await rebuild.handler(
|
||||
{
|
||||
...DEFAULT_OPTS,
|
||||
@@ -390,7 +383,6 @@ test('only build allowBuilds', async () => {
|
||||
allProjects,
|
||||
dir: process.cwd(),
|
||||
recursive: true,
|
||||
registries: modulesManifest!.registries!,
|
||||
selectedProjectsGraph,
|
||||
workspaceDir: process.cwd(),
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ function lookupConfig (opts: ConfigCommandOptions, key: string, isScopedKey: boo
|
||||
// `pnpm publish` and the resolvers actually use.
|
||||
if (key.endsWith(':registry')) {
|
||||
const scope = key.slice(0, key.length - ':registry'.length)
|
||||
const merged = opts._config.registries?.[scope]
|
||||
const merged = opts._config.registriesByScope?.[scope]
|
||||
if (merged !== undefined) {
|
||||
return { value: merged }
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ test('config get with scoped registry returns the merged value from pnpm-workspa
|
||||
authConfig: {
|
||||
'@scope:registry': 'https://from-npmrc.example.com/',
|
||||
},
|
||||
registries: {
|
||||
registriesByScope: {
|
||||
default: 'https://registry.npmjs.org/',
|
||||
'@scope': 'https://from-workspace-yaml.example.com/',
|
||||
},
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
"!*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "pn compile",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "pn compile && pn .test",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"prepublishOnly": "tsgo --build",
|
||||
"compile": "tsgo --build && pn lint --fix"
|
||||
"compile": "tsgo --build && pn lint --fix",
|
||||
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/constants": "workspace:*",
|
||||
@@ -36,6 +37,7 @@
|
||||
"ramda": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/config.normalize-registries": "workspace:*",
|
||||
"@types/ramda": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,40 +1,103 @@
|
||||
import { BUILTIN_NAMED_REGISTRIES } from '@pnpm/constants'
|
||||
import type { NamedRegistries, Registries } from '@pnpm/types'
|
||||
import { BUILTIN_REGISTRIES_BY_PREFIX } from '@pnpm/constants'
|
||||
import type { RegistriesByPrefix, RegistriesByScope, RegistryContext, RegistryDeclaration, RegistryServerType } from '@pnpm/types'
|
||||
import normalizeRegistryUrl from 'normalize-registry-url'
|
||||
import { map as mapValues } from 'ramda'
|
||||
|
||||
export const DEFAULT_REGISTRIES: Registries = {
|
||||
export const DEFAULT_REGISTRIES_BY_SCOPE: RegistriesByScope = {
|
||||
default: 'https://registry.npmjs.org/',
|
||||
'@jsr': 'https://npm.jsr.io/',
|
||||
}
|
||||
|
||||
export function normalizeRegistries (registries?: Record<string, string>): Registries {
|
||||
if (registries == null) return DEFAULT_REGISTRIES
|
||||
const normalizeRegistries = mapValues(normalizeRegistryUrl, registries)
|
||||
export function normalizeRegistriesByScope (registries?: Record<string, string>): RegistriesByScope {
|
||||
if (registries == null) return DEFAULT_REGISTRIES_BY_SCOPE
|
||||
const normalizeRegistriesByScope = mapValues(normalizeRegistryUrl, registries)
|
||||
return {
|
||||
...DEFAULT_REGISTRIES,
|
||||
...normalizeRegistries,
|
||||
...DEFAULT_REGISTRIES_BY_SCOPE,
|
||||
...normalizeRegistriesByScope,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User entries win on collision, so a GHES user can point `gh` at their
|
||||
* enterprise host. URLs are deliberately not normalized the way
|
||||
* `normalizeRegistries` normalizes `registries`: the name and URL are recorded
|
||||
* `normalizeRegistriesByScope` normalizes `registries`: the name and URL are recorded
|
||||
* in lockfile dep paths, so rewriting one changes what a lockfile resolves to.
|
||||
*
|
||||
* Null-prototype because names come out of those dep paths: a crafted
|
||||
* `foo@constructor:1.0.0` must not resolve to `Object.prototype.constructor`
|
||||
* and slip past the `if (!registry)` guards that fail closed on unknown names.
|
||||
*/
|
||||
export function normalizeNamedRegistries (namedRegistries?: Record<string, string>): NamedRegistries {
|
||||
if (namedRegistries == null) return DEFAULT_NAMED_REGISTRIES
|
||||
export function normalizeRegistriesByPrefix (registriesByPrefix?: Record<string, string>): RegistriesByPrefix {
|
||||
if (registriesByPrefix == null) return DEFAULT_REGISTRIES_BY_PREFIX
|
||||
return Object.assign(
|
||||
Object.create(null) as NamedRegistries,
|
||||
BUILTIN_NAMED_REGISTRIES,
|
||||
namedRegistries
|
||||
Object.create(null) as RegistriesByPrefix,
|
||||
BUILTIN_REGISTRIES_BY_PREFIX,
|
||||
registriesByPrefix
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared, like `DEFAULT_REGISTRIES`, so per-package callers can cache on its identity. */
|
||||
const DEFAULT_NAMED_REGISTRIES = normalizeNamedRegistries({})
|
||||
/** Shared, like `DEFAULT_REGISTRIES_BY_SCOPE`, so per-package callers can cache on its identity. */
|
||||
const DEFAULT_REGISTRIES_BY_PREFIX = normalizeRegistriesByPrefix({})
|
||||
|
||||
/**
|
||||
* Narrow a config-shaped object down to the registry facts alone, so the
|
||||
* install and lockfile layers are not handed the whole config, and so a
|
||||
* forwarding call site cannot drop one. The single place a new
|
||||
* {@link RegistryContext} field has to be listed.
|
||||
*/
|
||||
export function pickRegistryContext (source: RegistryContext): RegistryContext {
|
||||
return {
|
||||
registriesByScope: source.registriesByScope,
|
||||
registriesByPrefix: source.registriesByPrefix,
|
||||
registryOptionsByUrl: source.registryOptionsByUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the declarations from the lookups they were split into, for a
|
||||
* client that has to describe its registries to a pnpr server.
|
||||
*
|
||||
* The inverse of what the config reader does to the `registries` setting,
|
||||
* minus the default registry: that one travels as the request's own
|
||||
* `registry` field.
|
||||
*
|
||||
* Entries are keyed by the URL each lookup holds rather than by a normalized
|
||||
* one, so a registry a prefix addresses without a trailing slash stays the URL
|
||||
* the client resolves against.
|
||||
*
|
||||
* A built-in route the user has not pointed elsewhere is not declared: it is
|
||||
* not part of the client's configuration, and declaring `@jsr` would put
|
||||
* npm.jsr.io in front of a pnpr server's allowlist on every request, including
|
||||
* the requests that never resolve a JSR package.
|
||||
*/
|
||||
export function toRegistryDeclarations (context: Partial<RegistryContext>): Record<string, RegistryDeclaration> {
|
||||
const declarations: Record<string, RegistryDeclaration> = {}
|
||||
const declarationFor = (registry: string): RegistryDeclaration => (declarations[registry] ??= {})
|
||||
for (const [scope, registry] of Object.entries(context.registriesByScope ?? {})) {
|
||||
if (scope === 'default' || DEFAULT_REGISTRIES_BY_SCOPE[scope] === registry) continue
|
||||
const declaration = declarationFor(registry)
|
||||
declaration.scopes = [...declaration.scopes ?? [], scope]
|
||||
}
|
||||
for (const [prefix, registry] of Object.entries(context.registriesByPrefix ?? {})) {
|
||||
declarationFor(registry).prefix = prefix
|
||||
}
|
||||
for (const [registry, options] of Object.entries(context.registryOptionsByUrl ?? {})) {
|
||||
if (options.serverType == null) continue
|
||||
declarationFor(registry).serverType = options.serverType
|
||||
}
|
||||
return declarations
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout the user declared for `registry`, or `undefined` for none.
|
||||
*
|
||||
* Built-in layouts are deliberately not applied here — they belong with
|
||||
* `isCanonicalRegistryTarballUrl`, which acts on them, so that a code path
|
||||
* that never threads `registryOptionsByUrl` still gets them.
|
||||
*/
|
||||
export function getRegistryServerType (
|
||||
registryContext: Pick<RegistryContext, 'registryOptionsByUrl'>,
|
||||
registry: string
|
||||
): RegistryServerType | undefined {
|
||||
return registryContext.registryOptionsByUrl?.[normalizeRegistryUrl(registry)]?.serverType
|
||||
}
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user