perf(resolver): adopt the indexed pnpm-meta-v1 metadata mirror with lazy version hydration (#13512)
The metadata mirror stored one JSON document per package, so every resolution parsed every version manifest of every packument it touched: 178MB of JSON for the 1246-package benchmark fixture, of which ~1% (the picked versions) was ever read. loadMeta was 23% of resolution CPU after pnpm/pnpm#13504. The Rust CLI already shipped an indexed mirror layout (headers record, index record with per-version byte spans, concatenated raw version fragments); the TypeScript CLI treated those mirrors as cache misses, so the two stacks thrashed a shared cache. Both CLIs now read and write the same layout under the format id pnpm-meta-v1, and the metadata cache directories move from v11 to v12 so a pnpm version that predates the layout never opens the files at all. The offline legacy-mirror hint now looks under v11, and pnpm cache prune removes the v11 roots. NDJSON remains the layout for filterMetadata documents and is still read as a fallback. Loading an indexed mirror parses only the two leading records and builds versions as enumerable parse-once getters over byte spans, so a manifest keeps one identity across filtered views and hook mutations. Corruption is treated like an unreadable mirror in both stacks: a fragment that does not parse throws a typed error, offline resolution fails with NO_OFFLINE_META, the disk fast paths fall through to the network (only for errors attributable to the disk copy), and a 304 over a damaged file escalates to a cache-bypassing refetch that rewrites the mirror — otherwise the intact etag would keep 304-validating a poisoned file forever. The declared headers length is bounded, the encoded mirror form is memoized per fetch result so workspace projects sharing one in-flight fetch do not each serialize a copy, and cache view reads through the shared loader in both CLIs. Resolution of the 1246-package fixture is 16% faster on a warm mirror (3.82s -> 3.21s, interleaved paired runs, offline); the resulting lockfile is byte-identical across old-format, new-format, and mixed-format configurations. --------- Co-authored-by: Zoltan Kochan <z@kochan.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
67 files changed
+2707
-791
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"pacquet": patch
|
||||
---
|
||||
|
||||
`pnpm cache prune` now also removes the registry metadata cache that older pnpm versions wrote under `<cache-dir>/v11/` [#13512](https://github.com/pnpm/pnpm/pull/13512).
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@pnpm/resolving.npm-resolver": patch
|
||||
"@pnpm/resolving.registry.pkg-metadata-filter": patch
|
||||
"@pnpm/constants": patch
|
||||
"@pnpm/cache.api": patch
|
||||
"pacquet": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Dependency resolution reads cached registry metadata faster. The metadata cache moved to `<cache-dir>/v12/`, so the first install after upgrading downloads registry metadata again. A damaged cache entry is downloaded again, or reported as an error when `--offline` is set [#13512](https://github.com/pnpm/pnpm/pull/13512).
|
||||
@@ -4,8 +4,9 @@ use miette::IntoDiagnostic;
|
||||
use pnpm_config::{Config, ResolutionMode};
|
||||
use pnpm_fs::lexical_normalize;
|
||||
use pnpm_resolving_npm_resolver::mirror::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, decode_registry_name,
|
||||
get_registry_name, is_unreadable_registry_key, load_meta,
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, LEGACY_META_DIRS,
|
||||
LEGACY_PRIVATE_META_ROOT, decode_registry_name, get_registry_name, is_unreadable_registry_key,
|
||||
load_meta,
|
||||
};
|
||||
use pnpm_store_dir::StoreIndex;
|
||||
use serde_json::json;
|
||||
@@ -30,9 +31,9 @@ pub enum CacheCommand {
|
||||
/// Deletes registry metadata cache directories that this version of pnpm
|
||||
/// can no longer read.
|
||||
///
|
||||
/// pnpm 11.26 and earlier, and pnpm 12.3 and earlier, depend on these
|
||||
/// directories. Projects that use those versions refetch registry metadata
|
||||
/// after a prune, and their offline installs fail until they do.
|
||||
/// Older pnpm versions depend on these directories. Projects that use
|
||||
/// those versions refetch registry metadata after a prune, and their
|
||||
/// offline installs fail until they do.
|
||||
Prune {
|
||||
/// Lists what would be deleted without removing anything.
|
||||
#[arg(long)]
|
||||
@@ -184,8 +185,11 @@ impl CacheCommand {
|
||||
let mut meta_files_by_path = IndexMap::new();
|
||||
for (file_path, full_path) in meta_file_paths {
|
||||
let Some(meta_object) = load_meta(&full_path) else { continue };
|
||||
let (cached_versions, non_cached_versions) =
|
||||
split_cached_versions(&meta_object, store_index.as_deref());
|
||||
let Some((cached_versions, non_cached_versions)) =
|
||||
split_cached_versions(&meta_object, store_index.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// The output groups versions per registry.
|
||||
meta_files_by_path.insert(
|
||||
@@ -228,11 +232,13 @@ impl CacheCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the mirror directories left behind by a pnpm that keyed them on
|
||||
/// the registry's host alone.
|
||||
/// Remove the mirror directories left behind by an older pnpm: every
|
||||
/// directory of the [`LEGACY_META_DIRS`] roots and of
|
||||
/// [`LEGACY_PRIVATE_META_ROOT`], and those of the current roots that are
|
||||
/// keyed on the registry's host alone.
|
||||
///
|
||||
/// Only [`is_unreadable_registry_key`] decides what goes, so a mirror
|
||||
/// this version could still read is never a candidate.
|
||||
/// In a current root only [`is_unreadable_registry_key`] decides what
|
||||
/// goes, so a mirror this version could still read is never a candidate.
|
||||
///
|
||||
/// Prints each removed directory as `<meta-dir>/<registry-key>`, the name
|
||||
/// on disk rather than the decoded URL. `dry_run` prints the same list and
|
||||
@@ -243,14 +249,20 @@ impl CacheCommand {
|
||||
/// user the rest. Every root is confined to the cache directory first; see
|
||||
/// [`confined_meta_root`].
|
||||
///
|
||||
/// The descriptor-scoped roots under `v11/metadata-private` are left alone,
|
||||
/// The descriptor-scoped roots under `v12/metadata-private` are left alone,
|
||||
/// as every other `pnpm cache` subcommand leaves them alone.
|
||||
fn prune(config: &Config, dry_run: bool) -> miette::Result<()> {
|
||||
let mut outcome = PruneOutcome::default();
|
||||
match dunce::canonicalize(&config.cache_dir) {
|
||||
Ok(cache_dir) => {
|
||||
for meta_dir in [ABBREVIATED_META_DIR, FULL_META_DIR, FULL_FILTERED_META_DIR] {
|
||||
outcome.prune_root(&cache_dir, meta_dir, dry_run);
|
||||
outcome.prune_root(&cache_dir, meta_dir, is_unreadable_registry_key, dry_run);
|
||||
}
|
||||
for meta_dir in LEGACY_META_DIRS
|
||||
.into_iter()
|
||||
.chain([LEGACY_PRIVATE_META_ROOT])
|
||||
{
|
||||
outcome.prune_root(&cache_dir, meta_dir, |_| true, dry_run);
|
||||
}
|
||||
}
|
||||
// No cache directory at all is nothing to reclaim, as an absent
|
||||
@@ -277,7 +289,15 @@ impl PruneOutcome {
|
||||
///
|
||||
/// A root that cannot be read is recorded rather than passed over: silence
|
||||
/// would report it as holding nothing stale when it was never read at all.
|
||||
fn prune_root(&mut self, cache_dir: &Path, meta_dir: &str, dry_run: bool) {
|
||||
/// `unreadable` decides which of its directories this version can no
|
||||
/// longer read.
|
||||
fn prune_root(
|
||||
&mut self,
|
||||
cache_dir: &Path,
|
||||
meta_dir: &str,
|
||||
unreadable: fn(&str) -> bool,
|
||||
dry_run: bool,
|
||||
) {
|
||||
let root = match confined_meta_root(cache_dir, meta_dir) {
|
||||
Ok(None) => return,
|
||||
Ok(Some(root)) => root,
|
||||
@@ -293,7 +313,7 @@ impl PruneOutcome {
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
self.prune_entry(entry, meta_dir, dry_run);
|
||||
self.prune_entry(entry, meta_dir, unreadable, dry_run);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +327,13 @@ impl PruneOutcome {
|
||||
/// and that `lstat` is the exception, as an already-removed directory is in
|
||||
/// [`remove_pruned_dir`]: a prune running alongside this one must not make
|
||||
/// it fail for reaching the same end first.
|
||||
fn prune_entry(&mut self, entry: io::Result<fs::DirEntry>, meta_dir: &str, dry_run: bool) {
|
||||
fn prune_entry(
|
||||
&mut self,
|
||||
entry: io::Result<fs::DirEntry>,
|
||||
meta_dir: &str,
|
||||
unreadable: fn(&str) -> bool,
|
||||
dry_run: bool,
|
||||
) {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
@@ -331,7 +357,7 @@ impl PruneOutcome {
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if !is_unreadable_registry_key(®istry_key) {
|
||||
if !unreadable(®istry_key) {
|
||||
return;
|
||||
}
|
||||
match remove_pruned_dir(&path, dry_run) {
|
||||
@@ -496,13 +522,22 @@ fn walk_metadata_files(
|
||||
fn split_cached_versions(
|
||||
meta_object: &pnpm_registry::Package,
|
||||
store_index: Option<&StoreIndex>,
|
||||
) -> (Vec<String>, Vec<String>) {
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
let mut cached = Vec::new();
|
||||
let mut non_cached = Vec::new();
|
||||
let mut read = 0;
|
||||
for (version, json_frag) in meta_object.versions.fragments() {
|
||||
let Some(integrity) = version_integrity(json_frag.as_ref()) else { continue };
|
||||
read += 1;
|
||||
let manifest = serde_json::from_str::<serde_json::Value>(json_frag.as_ref()).ok()?;
|
||||
let Some(integrity) = manifest
|
||||
.get("dist")
|
||||
.and_then(|dist| dist.get("integrity"))
|
||||
.and_then(|integrity_value| integrity_value.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = pnpm_store_dir::store_index_key(
|
||||
&integrity,
|
||||
integrity,
|
||||
&format!("{}@{}", meta_object.name, version),
|
||||
);
|
||||
let is_cached = store_index.is_some_and(|index| index.contains_key(&key).unwrap_or(false));
|
||||
@@ -512,14 +547,7 @@ fn split_cached_versions(
|
||||
non_cached.push(version.clone());
|
||||
}
|
||||
}
|
||||
(cached, non_cached)
|
||||
}
|
||||
|
||||
fn version_integrity(json_frag: &str) -> Option<String> {
|
||||
let manifest = serde_json::from_str::<serde_json::Value>(json_frag).ok()?;
|
||||
manifest
|
||||
.get("dist")?
|
||||
.get("integrity")?
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
// A fragment that could not be read is skipped by `fragments`, and a
|
||||
// partial version list would misreport the cache.
|
||||
(read == meta_object.versions.len()).then_some((cached, non_cached))
|
||||
}
|
||||
@@ -28,7 +28,10 @@ fn install(workspace: &Path, args: &[&str]) {
|
||||
/// Without registry metadata a resolution fails, so an offline install
|
||||
/// succeeds only when it trusts the lockfile as it is.
|
||||
fn resolves_offline_without_metadata(workspace: &Path, cache_dir: &Path, args: &[&str]) -> bool {
|
||||
let metadata = cache_dir.join("v11");
|
||||
// Every metadata mirror lives under the version directory of this one.
|
||||
let metadata_version_dir =
|
||||
Path::new(pnpm_resolving_npm_resolver::mirror::ABBREVIATED_META_DIR).parent().unwrap();
|
||||
let metadata = cache_dir.join(metadata_version_dir);
|
||||
if metadata.exists() {
|
||||
fs::remove_dir_all(metadata).unwrap();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::{
|
||||
fn should_list_registries() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
fs::create_dir_all(cache_dir.join("registry.npmjs.org")).unwrap();
|
||||
fs::create_dir_all(cache_dir.join("registry.yarnpkg.com")).unwrap();
|
||||
|
||||
@@ -37,7 +37,7 @@ fn should_list_registries() {
|
||||
fn should_list_registries_as_decoded_urls() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
for registry in ["https://registry.npmjs.org/", "https://npm.example:8443/team/a/"] {
|
||||
let registry_name =
|
||||
pnpm_resolving_npm_resolver::mirror::get_registry_name(registry).unwrap();
|
||||
@@ -62,7 +62,7 @@ fn should_list_registries_as_decoded_urls() {
|
||||
fn should_list_packages() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
let url_str = cwd.npmrc_info.mock_instance.url();
|
||||
let registry_name = pnpm_resolving_npm_resolver::mirror::get_registry_name(url_str).unwrap();
|
||||
fs::create_dir_all(cache_dir.join(®istry_name)).unwrap();
|
||||
@@ -87,7 +87,7 @@ fn should_list_packages() {
|
||||
fn should_list_only_files_not_directories() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
let url_str = cwd.npmrc_info.mock_instance.url();
|
||||
let registry_name = pnpm_resolving_npm_resolver::mirror::get_registry_name(url_str).unwrap();
|
||||
fs::create_dir_all(cache_dir.join(®istry_name)).unwrap();
|
||||
@@ -129,7 +129,7 @@ fn should_list_only_files_not_directories() {
|
||||
fn should_delete_packages() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
let url_str = cwd.npmrc_info.mock_instance.url();
|
||||
let registry_name = pnpm_resolving_npm_resolver::mirror::get_registry_name(url_str).unwrap();
|
||||
fs::create_dir_all(cache_dir.join(®istry_name)).unwrap();
|
||||
@@ -252,6 +252,55 @@ fn should_prune_registries_written_before_the_scheme_joined_the_key() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror roots of the previous cache layout are unread by this version,
|
||||
/// whatever their registry keys look like, and so are its descriptor-scoped
|
||||
/// roots.
|
||||
#[test]
|
||||
fn should_prune_every_registry_of_the_legacy_roots() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
|
||||
let live = pnpm_resolving_npm_resolver::mirror::get_registry_name(LIVE_REGISTRY).unwrap();
|
||||
let legacy_meta_dirs = pnpm_resolving_npm_resolver::mirror::LEGACY_META_DIRS;
|
||||
for meta_dir in legacy_meta_dirs {
|
||||
for registry_name in [live.as_str(), "registry.npmjs.org"] {
|
||||
let dir = cwd.npmrc_info.cache_dir.join(meta_dir).join(registry_name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("is-positive.jsonl"), "{}").unwrap();
|
||||
}
|
||||
}
|
||||
let legacy_private_root = pnpm_resolving_npm_resolver::mirror::LEGACY_PRIVATE_META_ROOT;
|
||||
let private_mirror = cwd.npmrc_info.cache_dir
|
||||
.join(legacy_private_root)
|
||||
.join("abc123/metadata")
|
||||
.join(&live);
|
||||
fs::create_dir_all(&private_mirror).unwrap();
|
||||
|
||||
let output = cwd.pacquet
|
||||
.with_arg("cache")
|
||||
.with_arg("prune")
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let stdout = String::from_utf8(output).unwrap();
|
||||
let pruned: Vec<&str> = stdout.lines().collect();
|
||||
let mut expected: Vec<String> = legacy_meta_dirs
|
||||
.iter()
|
||||
.flat_map(|meta_dir| {
|
||||
[format!("{meta_dir}/{live}"), format!("{meta_dir}/registry.npmjs.org")]
|
||||
})
|
||||
.chain([format!("{legacy_private_root}/abc123")])
|
||||
.collect();
|
||||
expected.sort();
|
||||
assert_eq!(pruned, expected);
|
||||
for meta_dir in legacy_meta_dirs {
|
||||
let root = cwd.npmrc_info.cache_dir.join(meta_dir);
|
||||
assert_eq!(fs::read_dir(&root).unwrap().count(), 0, "expected {root:?} to be emptied");
|
||||
}
|
||||
}
|
||||
|
||||
/// The deletion rests on the shape of a directory name, and the cache is shared
|
||||
/// with any other pnpm on the machine, so a user has to be able to see the list
|
||||
/// before committing to it.
|
||||
@@ -337,7 +386,7 @@ fn should_prune_the_readable_roots_when_another_root_cannot_be_read() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// The trailing quote of the debug-printed path pins which root is named:
|
||||
// `v11/metadata` is otherwise a prefix of the two roots that did not fail.
|
||||
// `v12/metadata` is otherwise a prefix of the two roots that did not fail.
|
||||
let sealed_root = format!(r#"{}":"#, pnpm_resolving_npm_resolver::mirror::ABBREVIATED_META_DIR);
|
||||
assert!(stderr.contains(&sealed_root), "failure must name the unreadable root, got: {stderr}");
|
||||
assert!(
|
||||
@@ -427,7 +476,7 @@ fn should_refuse_to_prune_through_a_symlinked_metadata_root() {
|
||||
/// A path is its own prefix, so a root linked back to the cache directory
|
||||
/// satisfies a containment check written as one. Prune would then list that
|
||||
/// directory's own children and take every one of them for a stale registry
|
||||
/// key, `v11` and the rest of the cache included.
|
||||
/// key, `v12` and the rest of the cache included.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn should_refuse_to_prune_a_metadata_root_linked_to_the_cache_directory() {
|
||||
@@ -515,7 +564,7 @@ fn should_prune_nothing_when_every_registry_is_readable() {
|
||||
#[test]
|
||||
fn should_view_package_cache() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
let url_str = cwd.npmrc_info.mock_instance.url();
|
||||
let registry_name = pnpm_resolving_npm_resolver::mirror::get_registry_name(url_str).unwrap();
|
||||
fs::create_dir_all(cache_dir.join(®istry_name)).unwrap();
|
||||
@@ -554,6 +603,59 @@ fn should_view_package_cache() {
|
||||
assert!(info.get("cachedAt").is_some());
|
||||
}
|
||||
|
||||
/// A damaged file is what the resolver refuses to read, so `cache view`
|
||||
/// must not report the versions around the damage as cached.
|
||||
#[test]
|
||||
fn should_omit_a_package_whose_cache_file_is_damaged() {
|
||||
let cwd = CommandTempCwd::init().add_mocked_registry();
|
||||
let cache_dir = cwd.npmrc_info.cache_dir.join("v12").join("metadata");
|
||||
let url_str = cwd.npmrc_info.mock_instance.url();
|
||||
let registry_name = pnpm_resolving_npm_resolver::mirror::get_registry_name(url_str).unwrap();
|
||||
fs::create_dir_all(cache_dir.join(®istry_name)).unwrap();
|
||||
|
||||
let meta: pnpm_registry::Package = serde_json::from_str(
|
||||
r#"{
|
||||
"name": "is-positive",
|
||||
"dist-tags": {"latest": "1.0.0"},
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"name": "is-positive",
|
||||
"version": "1.0.0",
|
||||
"dist": {"integrity": "sha512-BBBBBBBBBBBB", "tarball": "https://r/is-positive-1.0.0.tgz"}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mirror = cache_dir.join(®istry_name).join("is-positive.jsonl");
|
||||
pnpm_resolving_npm_resolver::mirror::save_meta_indexed(&mirror, &meta, None, false).unwrap();
|
||||
|
||||
// An unescaped quote inside the integrity string: the same number of
|
||||
// bytes, so the index spans still address this fragment, but its own
|
||||
// bytes no longer parse.
|
||||
let mut bytes = fs::read(&mirror).unwrap();
|
||||
let marker = b"sha512-BBBB";
|
||||
let at = bytes
|
||||
.windows(marker.len())
|
||||
.position(|window| window == marker)
|
||||
.unwrap();
|
||||
bytes[at + 3] = b'"';
|
||||
fs::write(&mirror, &bytes).unwrap();
|
||||
|
||||
let output = cwd.pacquet
|
||||
.with_args(["cache", "view", "is-positive"])
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let stdout = String::from_utf8(output).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
let key = registry_name.replace('+', ":");
|
||||
assert!(json.get(&key).is_none(), "damaged cache file should be omitted, got {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_populates_metadata_cache() {
|
||||
let CommandTempCwd {
|
||||
@@ -596,7 +698,7 @@ fn import_populates_metadata_cache() {
|
||||
let registry_name =
|
||||
pnpm_resolving_npm_resolver::mirror::get_registry_name(mock_instance.url()).unwrap();
|
||||
let cache_metadata_dir = cache_dir
|
||||
.join("v11")
|
||||
.join("v12")
|
||||
.join("metadata")
|
||||
.join(®istry_name);
|
||||
|
||||
@@ -655,7 +757,7 @@ fn prune_help_says_older_versions_depend_on_pruned_dirs() {
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("pnpm 11.26 and earlier, and pnpm 12.3 and earlier, depend on these"),
|
||||
stdout.contains("Older pnpm versions depend on these directories"),
|
||||
"help should say older versions depend on the pruned directories: {stdout}",
|
||||
);
|
||||
drop(root);
|
||||
|
||||
@@ -34,7 +34,7 @@ fn an_override_to_a_local_directory_is_not_written_to_the_metadata_cache() {
|
||||
let registry_name =
|
||||
pnpm_resolving_npm_resolver::mirror::get_registry_name(mock_instance.url()).unwrap();
|
||||
let mirror_path = cache_dir
|
||||
.join("v11")
|
||||
.join("v12")
|
||||
.join("metadata")
|
||||
.join(registry_name)
|
||||
.join("@pnpm.e2e/pkg-with-1-dep.jsonl");
|
||||
|
||||
@@ -46,9 +46,8 @@ use std::{
|
||||
};
|
||||
|
||||
/// Directory under the pnpm cache dir holding the cached SHASUMS
|
||||
/// bodies. The `v11/` prefix groups it with the registry metadata
|
||||
/// mirror dirs (`v11/metadata`, ...), which share the cache dir's
|
||||
/// versioning story.
|
||||
/// bodies. Its `v11/` prefix versions this cache's own format, which
|
||||
/// is independent of the registry metadata mirror's.
|
||||
pub const RUNTIME_SHASUMS_CACHE_DIR: &str = "v11/runtime-shasums";
|
||||
|
||||
/// How the body of a cache entry was authenticated before it was
|
||||
|
||||
@@ -11,16 +11,29 @@
|
||||
//! asks for the typed form, and the hydrated manifest is cached per
|
||||
//! slot so repeated lookups parse once.
|
||||
//!
|
||||
//! A fragment that fails to decode behaves as if the version were
|
||||
//! absent from the packument (with a `tracing::warn`), mirroring the
|
||||
//! tolerance of JavaScript package managers, which never validate
|
||||
//! version entries they don't pick.
|
||||
//! A registry-served fragment that fails to decode behaves as if the
|
||||
//! version were absent from the packument (with a `tracing::warn`),
|
||||
//! mirroring the tolerance of JavaScript package managers, which never
|
||||
//! validate version entries they don't pick. A fragment read out of an
|
||||
//! indexed on-disk mirror is different when its bytes are not JSON at
|
||||
//! all: the index vouched for the span, and the mirror stores registry
|
||||
//! fragments verbatim, so such bytes mean the local file is damaged
|
||||
//! rather than that the version is missing. Those are recorded in
|
||||
//! [`PackageVersions::has_corrupt_mirror_fragment`], which the resolver
|
||||
//! reads to treat the whole mirror as unreadable — silently resolving a
|
||||
//! different version off damaged local data would be worse than the
|
||||
//! refetch, and the etag lives in the intact headers record, so nothing
|
||||
//! else would ever repair the file.
|
||||
|
||||
pub use mirror::{MirrorFile, read_exact_at};
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
sync::{Arc, OnceLock},
|
||||
sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
@@ -40,6 +53,9 @@ struct DeprecatedProbe {
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct PackageVersions {
|
||||
slots: Vec<(String, VersionSlot)>,
|
||||
/// Shared with every clone and filtered view, so corruption stays
|
||||
/// visible through whichever handle the resolver ends up holding.
|
||||
corrupt_mirror_fragment: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -55,47 +71,6 @@ struct VersionSlot {
|
||||
trust: OnceLock<Option<Arc<VersionTrustMetadata>>>,
|
||||
}
|
||||
|
||||
/// A mirror file held open for on-demand fragment reads, counted
|
||||
/// against a caller-supplied cap so a fleet of held handles can never
|
||||
/// exhaust the process's descriptor budget — a load that would exceed
|
||||
/// the cap falls back to buffering its fragments instead (see
|
||||
/// [`MirrorFile::try_hold`]).
|
||||
#[derive(Debug)]
|
||||
pub struct MirrorFile {
|
||||
file: File,
|
||||
}
|
||||
|
||||
static HELD_MIRROR_FILES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
impl MirrorFile {
|
||||
/// Wrap `file` for span reads when fewer than `cap` mirror files
|
||||
/// are currently held; hand the file back otherwise so the caller
|
||||
/// can buffer its contents and close it.
|
||||
pub fn try_hold(file: File, cap: usize) -> Result<Arc<MirrorFile>, File> {
|
||||
let mut held = HELD_MIRROR_FILES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
loop {
|
||||
if held >= cap {
|
||||
return Err(file);
|
||||
}
|
||||
match HELD_MIRROR_FILES.compare_exchange_weak(
|
||||
held,
|
||||
held + 1,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return Ok(Arc::new(MirrorFile { file })),
|
||||
Err(current) => held = current,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MirrorFile {
|
||||
fn drop(&mut self) {
|
||||
HELD_MIRROR_FILES.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a version's JSON fragment lives until it is hydrated.
|
||||
#[derive(Debug, Clone)]
|
||||
enum FragmentSource {
|
||||
@@ -109,18 +84,24 @@ enum FragmentSource {
|
||||
/// [`PackageVersions::from_file_spans`] for the inode-pinning
|
||||
/// contract the held handle provides.
|
||||
FileSpan { file: Arc<MirrorFile>, offset: u64, len: u32 },
|
||||
/// An indexed mirror's fragment, buffered in memory because its
|
||||
/// file could not be held open (see
|
||||
/// [`PackageVersions::from_buffered_mirror_fragments`]).
|
||||
BufferedMirror(Arc<RawValue>),
|
||||
/// No fragment — the slot was constructed from an already-typed
|
||||
/// manifest (tests, the publish-date filter's slot moves).
|
||||
None,
|
||||
}
|
||||
|
||||
impl FragmentSource {
|
||||
/// The fragment's JSON text: borrowed for [`FragmentSource::Raw`],
|
||||
/// The fragment's JSON text: borrowed for a buffered fragment,
|
||||
/// read from the mirror file for [`FragmentSource::FileSpan`],
|
||||
/// absent for [`FragmentSource::None`] or unreadable spans.
|
||||
fn json(&self) -> Option<Cow<'_, str>> {
|
||||
match self {
|
||||
FragmentSource::Raw(raw) => Some(Cow::Borrowed(raw.get())),
|
||||
FragmentSource::Raw(raw) | FragmentSource::BufferedMirror(raw) => {
|
||||
Some(Cow::Borrowed(raw.get()))
|
||||
}
|
||||
FragmentSource::FileSpan { file, offset, len } => {
|
||||
let mut bytes = vec![0u8; *len as usize];
|
||||
if let Err(error) = read_exact_at(&file.file, &mut bytes, *offset) {
|
||||
@@ -148,42 +129,13 @@ impl FragmentSource {
|
||||
FragmentSource::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `buf` from `file` at the absolute `offset`. The handle's read
|
||||
/// cursor is never consulted, and callers must not rely on where it
|
||||
/// ends up: the unix implementation leaves it untouched, while the
|
||||
/// Windows one moves it as a `seek_read` side effect.
|
||||
#[cfg(unix)]
|
||||
pub fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(file, buf, offset)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::wasi::fs::FileExt::read_exact_at(file, buf, offset)
|
||||
}
|
||||
|
||||
/// See the unix sibling for the shared contract.
|
||||
#[cfg(windows)]
|
||||
pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
match std::os::windows::fs::FileExt::seek_read(file, buf, offset) {
|
||||
Ok(0) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"mirror fragment span reaches past the end of the file",
|
||||
));
|
||||
}
|
||||
Ok(read) => {
|
||||
buf = &mut buf[read..];
|
||||
offset += read as u64;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
/// Whether the fragment comes from an indexed on-disk mirror,
|
||||
/// where a decode failure means a damaged file rather than a
|
||||
/// version the registry served badly.
|
||||
fn is_mirror_span(&self) -> bool {
|
||||
matches!(self, FragmentSource::FileSpan { .. } | FragmentSource::BufferedMirror(_))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Clone for VersionSlot {
|
||||
@@ -210,10 +162,17 @@ impl VersionSlot {
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate(&self, version: &str) -> Option<Arc<PackageVersion>> {
|
||||
fn hydrate(
|
||||
&self,
|
||||
version: &str,
|
||||
corrupt_mirror_fragment: &AtomicBool,
|
||||
) -> Option<Arc<PackageVersion>> {
|
||||
self.parsed
|
||||
.get_or_init(|| {
|
||||
let json = self.source.json()?;
|
||||
let Some(json) = self.source.json() else {
|
||||
self.report_undecodable(version, corrupt_mirror_fragment);
|
||||
return None;
|
||||
};
|
||||
match serde_json::from_str::<PackageVersion>(&json) {
|
||||
Ok(parsed) => Some(Arc::new(parsed)),
|
||||
Err(error) => {
|
||||
@@ -223,12 +182,35 @@ impl VersionSlot {
|
||||
version,
|
||||
"skipping registry version with an undecodable manifest",
|
||||
);
|
||||
self.report_decode_error(version, &json, corrupt_mirror_fragment);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Only bytes that are not JSON at all mean a damaged mirror. A
|
||||
/// well-formed fragment of the wrong shape is how the registry served
|
||||
/// that version, and the mirror stores it verbatim, so it stays an
|
||||
/// absent version as it is for a fragment read off the network.
|
||||
fn report_decode_error(&self, version: &str, json: &str, corrupt_mirror_fragment: &AtomicBool) {
|
||||
if serde_json::from_str::<serde::de::IgnoredAny>(json).is_err() {
|
||||
self.report_undecodable(version, corrupt_mirror_fragment);
|
||||
}
|
||||
}
|
||||
|
||||
fn report_undecodable(&self, version: &str, corrupt_mirror_fragment: &AtomicBool) {
|
||||
if !self.source.is_mirror_span() {
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "pnpm_registry",
|
||||
version,
|
||||
"metadata mirror fragment is damaged; the mirror will be treated as unreadable",
|
||||
);
|
||||
corrupt_mirror_fragment.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageVersions {
|
||||
@@ -237,7 +219,16 @@ impl PackageVersions {
|
||||
/// fragment fails to decode.
|
||||
#[must_use]
|
||||
pub fn get(&self, version: &str) -> Option<Arc<PackageVersion>> {
|
||||
self.slot(version)?.hydrate(version)
|
||||
self.slot(version)?.hydrate(version, &self.corrupt_mirror_fragment)
|
||||
}
|
||||
|
||||
/// Whether hydrating any version so far read a damaged fragment of
|
||||
/// an indexed on-disk mirror. Lazy, like the hydration it reports
|
||||
/// on: a corrupt fragment nobody touched goes unnoticed, exactly as
|
||||
/// its version going unpicked means nothing was resolved from it.
|
||||
#[must_use]
|
||||
pub fn has_corrupt_mirror_fragment(&self) -> bool {
|
||||
self.corrupt_mirror_fragment.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Reads the fields used by trust-downgrade checks without hydrating
|
||||
@@ -251,7 +242,10 @@ impl PackageVersions {
|
||||
if let Some(Some(parsed)) = slot.parsed.get() {
|
||||
return Some(Arc::new(VersionTrustMetadata::from(parsed.as_ref())));
|
||||
}
|
||||
let json = slot.source.json()?;
|
||||
let Some(json) = slot.source.json() else {
|
||||
slot.report_undecodable(version, &self.corrupt_mirror_fragment);
|
||||
return None;
|
||||
};
|
||||
match serde_json::from_str::<VersionTrustMetadata>(&json) {
|
||||
Ok(trust) => Some(Arc::new(trust)),
|
||||
Err(error) => {
|
||||
@@ -261,6 +255,7 @@ impl PackageVersions {
|
||||
version,
|
||||
"skipping registry version with undecodable trust metadata",
|
||||
);
|
||||
slot.report_decode_error(version, &json, &self.corrupt_mirror_fragment);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -310,11 +305,19 @@ impl PackageVersions {
|
||||
if let Some(deprecated) = slot.deprecated.get() {
|
||||
return *deprecated;
|
||||
}
|
||||
let Some(json) = slot.source.json() else { return false };
|
||||
let Some(json) = slot.source.json() else {
|
||||
slot.report_undecodable(version, &self.corrupt_mirror_fragment);
|
||||
return false;
|
||||
};
|
||||
*slot.deprecated.get_or_init(|| {
|
||||
json.contains(r#""deprecated""#)
|
||||
&& serde_json::from_str::<DeprecatedProbe>(&json)
|
||||
.is_ok_and(|probe| probe.deprecated.is_some())
|
||||
if !json.contains(r#""deprecated""#) {
|
||||
return false;
|
||||
}
|
||||
let Ok(probe) = serde_json::from_str::<DeprecatedProbe>(&json) else {
|
||||
slot.report_decode_error(version, &json, &self.corrupt_mirror_fragment);
|
||||
return false;
|
||||
};
|
||||
probe.deprecated.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,7 +343,9 @@ impl PackageVersions {
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, Arc<PackageVersion>)> {
|
||||
self.slots
|
||||
.iter()
|
||||
.filter_map(|(version, slot)| Some((version, slot.hydrate(version)?)))
|
||||
.filter_map(|(version, slot)| {
|
||||
Some((version, slot.hydrate(version, &self.corrupt_mirror_fragment)?))
|
||||
})
|
||||
}
|
||||
|
||||
/// Filtered copy keeping only the versions `keep` accepts. Slots
|
||||
@@ -355,6 +360,7 @@ impl PackageVersions {
|
||||
.filter(|(version, _)| keep(version))
|
||||
.map(|(version, slot)| (version.clone(), slot.clone()))
|
||||
.collect(),
|
||||
corrupt_mirror_fragment: Arc::clone(&self.corrupt_mirror_fragment),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,104 +375,7 @@ impl PackageVersions {
|
||||
if !slots.is_sorted_by(|left, right| left.0 <= right.0) {
|
||||
slots.sort_unstable_by(|left, right| left.0.cmp(&right.0));
|
||||
}
|
||||
PackageVersions { slots }
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructors and accessors for the indexed on-disk mirror format
|
||||
/// (see `pnpm-resolving-npm-resolver`'s `mirror` module, which owns
|
||||
/// the file layout).
|
||||
impl PackageVersions {
|
||||
/// Build a map whose fragments are byte spans read on demand from
|
||||
/// the held-open `file` (the indexed mirror). Nothing parses until
|
||||
/// a version hydrates, and no fragment bytes stay resident.
|
||||
///
|
||||
/// The handle pins the inode: mirror rewrites go through a temp
|
||||
/// file followed by `rename`, so the bytes behind this open handle
|
||||
/// can never shift under the recorded spans.
|
||||
#[must_use]
|
||||
pub fn from_file_spans(
|
||||
file: &Arc<MirrorFile>,
|
||||
spans: impl IntoIterator<Item = (String, u64, u32)>,
|
||||
) -> Self {
|
||||
PackageVersions::from_slots(
|
||||
spans
|
||||
.into_iter()
|
||||
.map(|(version, offset, len)| {
|
||||
(
|
||||
version,
|
||||
VersionSlot {
|
||||
source: FragmentSource::FileSpan {
|
||||
file: Arc::clone(file),
|
||||
offset,
|
||||
len,
|
||||
},
|
||||
parsed: OnceLock::new(),
|
||||
deprecated: OnceLock::new(),
|
||||
trust: OnceLock::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a map from already-extracted raw JSON fragments. The
|
||||
/// fallback for a mirror the loader could not keep open (the
|
||||
/// held-handle cap in [`MirrorFile::try_hold`] was reached): the
|
||||
/// fragments stay buffered in memory like a freshly-fetched
|
||||
/// packument's, trading residency for a descriptor.
|
||||
#[must_use]
|
||||
pub fn from_raw_fragments(
|
||||
fragments: impl IntoIterator<Item = (String, Box<RawValue>)>,
|
||||
) -> Self {
|
||||
PackageVersions::from_slots(
|
||||
fragments
|
||||
.into_iter()
|
||||
.map(|(version, raw)| {
|
||||
(
|
||||
version,
|
||||
VersionSlot {
|
||||
source: FragmentSource::Raw(Arc::from(raw)),
|
||||
parsed: OnceLock::new(),
|
||||
deprecated: OnceLock::new(),
|
||||
trust: OnceLock::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Iterate every version's JSON fragment text, for the mirror
|
||||
/// writer. Raw fragments borrow; slots holding only a typed
|
||||
/// manifest re-serialize it; file-span slots read their span.
|
||||
/// A slot whose fragment can be neither borrowed nor produced is
|
||||
/// skipped with a warning — the mirror then simply omits that
|
||||
/// version, which reads back as "absent" (the same contract as an
|
||||
/// undecodable fragment).
|
||||
pub fn fragments(&self) -> impl Iterator<Item = (&String, Cow<'_, str>)> {
|
||||
self.slots
|
||||
.iter()
|
||||
.filter_map(|(version, slot)| {
|
||||
if let Some(json) = slot.source.json() {
|
||||
return Some((version, json));
|
||||
}
|
||||
if let Some(Some(parsed)) = slot.parsed.get() {
|
||||
match serde_json::to_string(parsed.as_ref()) {
|
||||
Ok(json) => return Some((version, Cow::Owned(json))),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
target: "pnpm_registry",
|
||||
%error,
|
||||
version,
|
||||
"failed to re-serialize a typed manifest for the metadata mirror",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
PackageVersions { slots, corrupt_mirror_fragment: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,5 +455,7 @@ impl Serialize for PackageVersions {
|
||||
}
|
||||
}
|
||||
|
||||
mod mirror;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The indexed on-disk mirror's side of [`PackageVersions`]: the held-open
|
||||
//! file its fragment spans are read from, and the constructors and
|
||||
//! accessors the mirror reader and writer use.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fs::File,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
use super::{FragmentSource, PackageVersions, VersionSlot};
|
||||
|
||||
/// A mirror file held open for on-demand fragment reads, counted
|
||||
/// against a caller-supplied cap so a fleet of held handles can never
|
||||
/// exhaust the process's descriptor budget — a load that would exceed
|
||||
/// the cap falls back to buffering its fragments instead (see
|
||||
/// [`MirrorFile::try_hold`]).
|
||||
#[derive(Debug)]
|
||||
pub struct MirrorFile {
|
||||
pub(super) file: File,
|
||||
}
|
||||
|
||||
static HELD_MIRROR_FILES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
impl MirrorFile {
|
||||
/// Wrap `file` for span reads when fewer than `cap` mirror files
|
||||
/// are currently held; hand the file back otherwise so the caller
|
||||
/// can buffer its contents and close it.
|
||||
pub fn try_hold(file: File, cap: usize) -> Result<Arc<MirrorFile>, File> {
|
||||
let mut held = HELD_MIRROR_FILES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
loop {
|
||||
if held >= cap {
|
||||
return Err(file);
|
||||
}
|
||||
match HELD_MIRROR_FILES.compare_exchange_weak(
|
||||
held,
|
||||
held + 1,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return Ok(Arc::new(MirrorFile { file })),
|
||||
Err(current) => held = current,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MirrorFile {
|
||||
fn drop(&mut self) {
|
||||
HELD_MIRROR_FILES.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `buf` from `file` at the absolute `offset`. The handle's read
|
||||
/// cursor is never consulted, and callers must not rely on where it
|
||||
/// ends up: the unix implementation leaves it untouched, while the
|
||||
/// Windows one moves it as a `seek_read` side effect.
|
||||
#[cfg(unix)]
|
||||
pub fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(file, buf, offset)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "wasi")]
|
||||
pub fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::wasi::fs::FileExt::read_exact_at(file, buf, offset)
|
||||
}
|
||||
|
||||
/// See the unix sibling for the shared contract.
|
||||
#[cfg(windows)]
|
||||
pub fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
match std::os::windows::fs::FileExt::seek_read(file, buf, offset) {
|
||||
Ok(0) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"mirror fragment span reaches past the end of the file",
|
||||
));
|
||||
}
|
||||
Ok(read) => {
|
||||
buf = &mut buf[read..];
|
||||
offset += read as u64;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Constructors and accessors for the indexed on-disk mirror format
|
||||
/// (see `pnpm-resolving-npm-resolver`'s `mirror` module, which owns
|
||||
/// the file layout).
|
||||
impl PackageVersions {
|
||||
/// Build a map whose fragments are byte spans read on demand from
|
||||
/// the held-open `file` (the indexed mirror). Nothing parses until
|
||||
/// a version hydrates, and no fragment bytes stay resident.
|
||||
///
|
||||
/// The handle pins the inode: mirror rewrites go through a temp
|
||||
/// file followed by `rename`, so the bytes behind this open handle
|
||||
/// can never shift under the recorded spans.
|
||||
#[must_use]
|
||||
pub fn from_file_spans(
|
||||
file: &Arc<MirrorFile>,
|
||||
spans: impl IntoIterator<Item = (String, u64, u32)>,
|
||||
) -> Self {
|
||||
PackageVersions::from_slots(
|
||||
spans
|
||||
.into_iter()
|
||||
.map(|(version, offset, len)| {
|
||||
(
|
||||
version,
|
||||
VersionSlot {
|
||||
source: FragmentSource::FileSpan {
|
||||
file: Arc::clone(file),
|
||||
offset,
|
||||
len,
|
||||
},
|
||||
parsed: OnceLock::new(),
|
||||
deprecated: OnceLock::new(),
|
||||
trust: OnceLock::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a map from fragments already read out of an indexed
|
||||
/// mirror. The fallback for a mirror the loader could not keep open
|
||||
/// (the held-handle cap in [`MirrorFile::try_hold`] was reached):
|
||||
/// the fragments stay buffered in memory like a freshly-fetched
|
||||
/// packument's, trading residency for a descriptor, and a decode
|
||||
/// failure still counts as a damaged mirror.
|
||||
#[must_use]
|
||||
pub fn from_buffered_mirror_fragments(
|
||||
fragments: impl IntoIterator<Item = (String, Box<RawValue>)>,
|
||||
) -> Self {
|
||||
PackageVersions::from_slots(
|
||||
fragments
|
||||
.into_iter()
|
||||
.map(|(version, raw)| {
|
||||
(
|
||||
version,
|
||||
VersionSlot {
|
||||
source: FragmentSource::BufferedMirror(Arc::from(raw)),
|
||||
parsed: OnceLock::new(),
|
||||
deprecated: OnceLock::new(),
|
||||
trust: OnceLock::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Iterate every version's JSON fragment text, for the mirror
|
||||
/// writer. Raw fragments borrow; slots holding only a typed
|
||||
/// manifest re-serialize it; file-span slots read their span.
|
||||
/// A slot whose fragment can be neither borrowed nor produced is
|
||||
/// skipped with a warning — the mirror then simply omits that
|
||||
/// version, which reads back as "absent" (the same contract as an
|
||||
/// undecodable fragment).
|
||||
pub fn fragments(&self) -> impl Iterator<Item = (&String, Cow<'_, str>)> {
|
||||
self.slots
|
||||
.iter()
|
||||
.filter_map(|(version, slot)| {
|
||||
if let Some(json) = slot.source.json() {
|
||||
return Some((version, json));
|
||||
}
|
||||
if let Some(Some(parsed)) = slot.parsed.get() {
|
||||
match serde_json::to_string(parsed.as_ref()) {
|
||||
Ok(json) => return Some((version, Cow::Owned(json))),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
target: "pnpm_registry",
|
||||
%error,
|
||||
version,
|
||||
"failed to re-serialize a typed manifest for the metadata mirror",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, io::Write};
|
||||
|
||||
use crate::{Package, PackageVersion};
|
||||
|
||||
@@ -69,6 +69,68 @@ fn undecodable_fragment_behaves_as_absent() {
|
||||
assert!(package.versions.get("9.9.9").is_none());
|
||||
assert!(package.versions.get("1.0.0").is_some());
|
||||
assert_eq!(package.versions.iter().count(), 1);
|
||||
assert!(!package.versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
/// A mirror-backed packument whose valid fragment sits before a
|
||||
/// damaged one, plus the two spans that address them.
|
||||
fn mirror_versions() -> crate::PackageVersions {
|
||||
const VALID: &str = r#"{"name": "foo", "version": "1.0.0", "dist": {"integrity": "sha512-a", "tarball": "https://r/foo-1.0.0.tgz"}}"#;
|
||||
const DAMAGED: &str = "{not json at all}";
|
||||
let valid_len = u32::try_from(VALID.len()).unwrap();
|
||||
mirror_spans(
|
||||
&format!("{VALID}{DAMAGED}"),
|
||||
[
|
||||
("1.0.0".to_string(), 0, valid_len),
|
||||
("2.0.0".to_string(), u64::from(valid_len), u32::try_from(DAMAGED.len()).unwrap()),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Spans over `fragments`, served from a held-open file the way an
|
||||
/// indexed mirror load serves them.
|
||||
fn mirror_spans(
|
||||
fragments: &str,
|
||||
spans: impl IntoIterator<Item = (String, u64, u32)>,
|
||||
) -> crate::PackageVersions {
|
||||
let mut file = tempfile::tempfile().expect("create the mirror file");
|
||||
file.write_all(fragments.as_bytes()).expect("write the fragments");
|
||||
let held = super::MirrorFile::try_hold(file, usize::MAX).expect("hold the mirror file");
|
||||
crate::PackageVersions::from_file_spans(&held, spans)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_mirror_fragment_is_reported_once_hydrated() {
|
||||
let versions = mirror_versions();
|
||||
|
||||
assert!(versions.get("1.0.0").is_some());
|
||||
assert!(!versions.has_corrupt_mirror_fragment());
|
||||
|
||||
assert!(versions.get("2.0.0").is_none());
|
||||
assert!(versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
/// The publish-date filter hands out a filtered view of the same
|
||||
/// fragments, so damage found through either handle has to be visible
|
||||
/// from the one the resolver checks.
|
||||
#[test]
|
||||
fn a_filtered_view_shares_the_damage_report() {
|
||||
let versions = mirror_versions();
|
||||
let filtered = versions.filtered(|version| version == "2.0.0");
|
||||
|
||||
assert!(filtered.get("2.0.0").is_none());
|
||||
assert!(filtered.has_corrupt_mirror_fragment());
|
||||
assert!(versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probing_a_damaged_mirror_fragment_for_deprecation_reports_it() {
|
||||
const DAMAGED: &str = r#"{"deprecated": "use 2.x",,}"#;
|
||||
let versions =
|
||||
mirror_spans(DAMAGED, [("1.0.0".to_string(), 0, u32::try_from(DAMAGED.len()).unwrap())]);
|
||||
|
||||
assert!(!versions.is_deprecated("1.0.0"));
|
||||
assert!(versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -430,3 +492,15 @@ fn compact_trust_reads_typed_manifests_without_fragments() {
|
||||
.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror stores registry fragments verbatim, so a version the registry
|
||||
/// served in the wrong shape reads back the same way and stays absent.
|
||||
#[test]
|
||||
fn a_well_formed_mirror_fragment_of_the_wrong_shape_is_not_damage() {
|
||||
const ODD: &str = r#"{"name":"acme","version":"not semver"}"#;
|
||||
let versions = mirror_spans(ODD, [("1.0.0".to_string(), 0, u32::try_from(ODD.len()).unwrap())]);
|
||||
|
||||
assert!(versions.get("1.0.0").is_none());
|
||||
assert!(!versions.is_deprecated("1.0.0"));
|
||||
assert!(!versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
@@ -187,7 +187,7 @@ pub struct VerificationMetadataClient {
|
||||
pub auth_headers: Arc<AuthHeaders>,
|
||||
/// Root of pnpm's on-disk metadata mirror. When set, the verifier
|
||||
/// reads conditional headers from
|
||||
/// `<cache_dir>/v11/metadata-full/<registry>/<pkg>.jsonl` and
|
||||
/// `<cache_dir>/v12/metadata-full/<registry>/<pkg>.jsonl` and
|
||||
/// writes 200 responses back; when `None`, every fetch is
|
||||
/// unconditional.
|
||||
pub cache_dir: Option<PathBuf>,
|
||||
|
||||
@@ -178,7 +178,7 @@ impl NpmResolutionVerifier {
|
||||
/// fetched, so a recent `modified` does not have to escalate to
|
||||
/// the per-version fallbacks below.
|
||||
/// 3. **On-disk full-meta mirror.** If a previous verification
|
||||
/// populated `<cache_dir>/v11/metadata-full/.../<name>.jsonl`,
|
||||
/// populated `<cache_dir>/v12/metadata-full/.../<name>.jsonl`,
|
||||
/// take the per-version timestamp from there with no network.
|
||||
/// 4. **Npm attestation endpoint.** Small payload, just this
|
||||
/// version's Sigstore-anchored timestamp. Wins on cold cache
|
||||
|
||||
+11
-5
@@ -40,11 +40,14 @@ impl NpmResolutionVerifier {
|
||||
)
|
||||
};
|
||||
let value = cell.get_or_init(|| async {
|
||||
// A shared packument that turns out to hold a damaged mirror
|
||||
// fragment falls through to the fetch below, which repairs it.
|
||||
if let Some(shared) = self.read_shared_meta(registry, name) {
|
||||
return Ok(project_abbreviated_meta(
|
||||
&shared,
|
||||
self.metadata.registry_supports_time_field,
|
||||
));
|
||||
let projection =
|
||||
project_abbreviated_meta(&shared, self.metadata.registry_supports_time_field);
|
||||
if !shared.versions.has_corrupt_mirror_fragment() {
|
||||
return Ok(projection);
|
||||
}
|
||||
}
|
||||
let opts = FetchFullMetadataCachedOptions {
|
||||
registry,
|
||||
@@ -212,7 +215,10 @@ impl NpmResolutionVerifier {
|
||||
.or_else(|| cache.get(&format!("{key}:full:filtered")))
|
||||
});
|
||||
if let Some(cached) = shared {
|
||||
return Ok(Arc::new(project_trust_meta(cached.meta.as_ref())));
|
||||
let projection = project_trust_meta(cached.meta.as_ref());
|
||||
if !cached.meta.versions.has_corrupt_mirror_fragment() {
|
||||
return Ok(Arc::new(projection));
|
||||
}
|
||||
}
|
||||
// Project the packument to just the fields `fail_if_trust_downgraded`
|
||||
// reads before stashing in the cache. The full document — dependency
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ async fn private_scope_verifier_ignores_public_mirror_and_writes_private_mirror(
|
||||
|
||||
let private_path = get_pkg_mirror_path(
|
||||
cache.path(),
|
||||
"v11/metadata-private/private-scope/metadata",
|
||||
"v12/metadata-private/private-scope/metadata",
|
||||
®istry,
|
||||
"acme",
|
||||
)
|
||||
|
||||
@@ -115,9 +115,9 @@ pub enum FetchMetadataError {
|
||||
#[must_use]
|
||||
pub fn legacy_mirror_hint(legacy_mirror: &std::path::Path) -> String {
|
||||
format!(
|
||||
"The cache layout for registry metadata changed in pnpm 11.27 and 12.4. {} holds a mirror \
|
||||
from an older pnpm version, which this offline install cannot read. Run one online install \
|
||||
to repopulate the cache under the new layout, then retry offline.",
|
||||
"The cache layout for registry metadata has changed. {} holds a mirror from an older pnpm \
|
||||
version, which this offline install cannot read. Run one online install to repopulate \
|
||||
the cache under the new layout, then retry offline.",
|
||||
legacy_mirror.display(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Cache-aware metadata fetcher.
|
||||
//!
|
||||
//! When a cache directory is configured, the fetcher consults a
|
||||
//! shared mirror under `<cache_dir>/v11/metadata-full/` (full) or
|
||||
//! `<cache_dir>/v11/metadata/` (abbreviated), keyed by
|
||||
//! shared mirror under `<cache_dir>/v12/metadata-full/` (full) or
|
||||
//! `<cache_dir>/v12/metadata/` (abbreviated), keyed by
|
||||
//! `full_metadata`. It issues a conditional GET against the upstream
|
||||
//! registry, and either reads the cached body (304) or writes the
|
||||
//! new body back (2xx). Without a cache directory it falls through
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
},
|
||||
mirror::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, MetaHeaders, clear_meta,
|
||||
get_legacy_pkg_mirror_path, get_pkg_mirror_path, load_meta, load_meta_async,
|
||||
find_legacy_pkg_mirror, get_pkg_mirror_path, load_meta, load_meta_async,
|
||||
load_meta_headers_async, save_meta_indexed_with_headers, save_meta_ndjson_with_headers,
|
||||
scoped_meta_dir,
|
||||
},
|
||||
@@ -47,7 +47,7 @@ use crate::{
|
||||
pub struct FetchFullMetadataCachedOptions<'a> {
|
||||
pub registry: &'a str,
|
||||
/// When `Some`, the fetcher consults the on-disk mirror under
|
||||
/// the matching `<cache_dir>/v11/metadata...` subdirectory.
|
||||
/// the matching `<cache_dir>/v12/metadata...` subdirectory.
|
||||
/// When `None`, the fetcher short-circuits to an unconditional
|
||||
/// GET.
|
||||
pub cache_dir: Option<&'a Path>,
|
||||
@@ -71,9 +71,56 @@ pub struct FetchFullMetadataCachedOptions<'a> {
|
||||
|
||||
/// Fetch the full registry metadata document for `pkg_name`, reusing
|
||||
/// the shared on-disk mirror when `cache_dir` is supplied.
|
||||
///
|
||||
/// Every version is hydrated before the document is returned, because
|
||||
/// its callers read across all versions and have no fallback of their
|
||||
/// own. A damaged mirror fragment therefore reads as a missing mirror:
|
||||
/// offline it fails with `ERR_PNPM_NO_OFFLINE_META`, online the document
|
||||
/// is refetched without the conditional cache, which rewrites the mirror.
|
||||
pub async fn fetch_full_metadata_cached(
|
||||
pkg_name: &str,
|
||||
opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
) -> Result<Package, FetchMetadataError> {
|
||||
let meta = fetch_metadata_cached(pkg_name, opts, false).await?;
|
||||
if !has_damaged_fragment(&meta) {
|
||||
return Ok(meta);
|
||||
}
|
||||
if opts.offline {
|
||||
let url = to_registry_url(opts.registry, pkg_name);
|
||||
return Err(FetchMetadataError::NoOfflineMeta {
|
||||
pkg_name: pkg_name.to_string(),
|
||||
pkg_mirror: mirror_path_for(pkg_name, opts, &url).unwrap_or_default(),
|
||||
hint: None,
|
||||
});
|
||||
}
|
||||
fetch_metadata_cached(pkg_name, opts, true).await
|
||||
}
|
||||
|
||||
fn has_damaged_fragment(meta: &Package) -> bool {
|
||||
meta.versions.iter().for_each(drop);
|
||||
meta.versions.has_corrupt_mirror_fragment()
|
||||
}
|
||||
|
||||
/// [`fetch_full_metadata_cached`] without the up-front hydration, for the
|
||||
/// resolver, which checks the versions it reads itself.
|
||||
pub(crate) async fn fetch_full_metadata_cached_lazily(
|
||||
pkg_name: &str,
|
||||
opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
) -> Result<Package, FetchMetadataError> {
|
||||
fetch_metadata_cached(pkg_name, opts, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_full_metadata_bypassing_cache(
|
||||
pkg_name: &str,
|
||||
opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
) -> Result<Package, FetchMetadataError> {
|
||||
fetch_metadata_cached(pkg_name, opts, true).await
|
||||
}
|
||||
|
||||
async fn fetch_metadata_cached(
|
||||
pkg_name: &str,
|
||||
opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
bypass_cache: bool,
|
||||
) -> Result<Package, FetchMetadataError> {
|
||||
let url = to_registry_url(opts.registry, pkg_name);
|
||||
let mirror_path = mirror_path_for(pkg_name, opts, &url);
|
||||
@@ -82,12 +129,7 @@ pub async fn fetch_full_metadata_cached(
|
||||
if let Some(meta) = load_meta_async(mirror_path.as_deref()).await {
|
||||
return Ok(meta);
|
||||
}
|
||||
let hint = match legacy_mirror_path_for(pkg_name, opts) {
|
||||
Some(path) if tokio::fs::try_exists(&path).await.unwrap_or(false) => {
|
||||
Some(legacy_mirror_hint(&path))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let hint = legacy_mirror_for(pkg_name, opts).await.map(|path| legacy_mirror_hint(&path));
|
||||
return Err(FetchMetadataError::NoOfflineMeta {
|
||||
pkg_name: pkg_name.to_string(),
|
||||
pkg_mirror: mirror_path.unwrap_or_default(),
|
||||
@@ -95,17 +137,15 @@ pub async fn fetch_full_metadata_cached(
|
||||
});
|
||||
}
|
||||
|
||||
let cache_headers =
|
||||
if bypass_cache { None } else { load_meta_headers_async(mirror_path.as_deref()).await };
|
||||
let attempt = FetchAttempt {
|
||||
pkg_name,
|
||||
url: &url,
|
||||
opts,
|
||||
mirror_path: mirror_path.as_deref(),
|
||||
cache_headers: load_meta_headers_async(mirror_path.as_deref()).await,
|
||||
// A body retry re-enters the attempt from the top, so the bypass has
|
||||
// to outlive the attempt that discovered the loss: re-validating
|
||||
// against a mirror already known to be gone would 304 into the same
|
||||
// dead end.
|
||||
cache_bypass: AtomicBool::new(false),
|
||||
cache_headers,
|
||||
cache_bypass: AtomicBool::new(bypass_cache),
|
||||
};
|
||||
retry_async(&url, opts.http.retry_opts, FetchMetadataError::is_transient, || attempt.run())
|
||||
.await
|
||||
@@ -272,9 +312,9 @@ fn mirror_path_for(
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the legacy mirror path for `pkg_name` in `opts.cache_dir`.
|
||||
/// Locate a legacy mirror of `pkg_name` in `opts.cache_dir`.
|
||||
/// Unlike [`mirror_path_for`], this checks only the unscoped directory.
|
||||
fn legacy_mirror_path_for(
|
||||
async fn legacy_mirror_for(
|
||||
pkg_name: &str,
|
||||
opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
) -> Option<PathBuf> {
|
||||
@@ -283,7 +323,7 @@ fn legacy_mirror_path_for(
|
||||
} else {
|
||||
ABBREVIATED_META_DIR
|
||||
};
|
||||
get_legacy_pkg_mirror_path(opts.cache_dir?, base_meta_dir, opts.registry, pkg_name)
|
||||
find_legacy_pkg_mirror(opts.cache_dir?, base_meta_dir, opts.registry, pkg_name).await
|
||||
}
|
||||
|
||||
/// The off-reactor half of one fetch: parse the body, normalize it, and
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod behavior;
|
||||
|
||||
mod damaged_mirror;
|
||||
|
||||
mod metadata_cache_body_read_failure_retries;
|
||||
|
||||
mod metadata_cache_cold_cache_writes_mirror;
|
||||
@@ -15,7 +17,7 @@ use super::{FetchFullMetadataCachedOptions, fetch_full_metadata_cached};
|
||||
use crate::{
|
||||
FetchMetadataError,
|
||||
mirror::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, get_legacy_pkg_mirror_path,
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, get_legacy_pkg_mirror_paths,
|
||||
get_pkg_mirror_path, load_meta, load_meta_headers, save_meta_indexed,
|
||||
},
|
||||
};
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
use super::{
|
||||
AuthHeaders, FULL_META_DIR, FetchFullMetadataCachedOptions, FetchMetadataError, Matcher,
|
||||
PACKAGE_BODY, TempDir, ThrottledClient, fetch_full_metadata_cached, load_meta, no_retry_opts,
|
||||
write_stale_mirror,
|
||||
};
|
||||
|
||||
/// Breaks the bytes of the `1.0.0` fragment without moving any span, so only
|
||||
/// that fragment fails to parse.
|
||||
fn damage_fragment(mirror_path: &std::path::Path) {
|
||||
let mut bytes = std::fs::read(mirror_path).expect("read mirror");
|
||||
let marker = b"sha512-AAAA";
|
||||
let at = bytes
|
||||
.windows(marker.len())
|
||||
.position(|window| window == marker)
|
||||
.expect("1.0.0 fragment in the mirror");
|
||||
bytes[at + 3] = b'"';
|
||||
std::fs::write(mirror_path, &bytes).expect("write damaged mirror");
|
||||
}
|
||||
|
||||
fn options<'a>(
|
||||
registry: &'a str,
|
||||
cache_dir: &'a std::path::Path,
|
||||
offline: bool,
|
||||
http_client: &'a ThrottledClient,
|
||||
auth_headers: &'a AuthHeaders,
|
||||
) -> FetchFullMetadataCachedOptions<'a> {
|
||||
FetchFullMetadataCachedOptions {
|
||||
registry,
|
||||
cache_dir: Some(cache_dir),
|
||||
full_metadata: true,
|
||||
filter_metadata: false,
|
||||
offline,
|
||||
priority: pnpm_network::UNPRIORITIZED,
|
||||
http: crate::MetadataHttpClient { http_client, auth_headers, retry_opts: no_retry_opts() },
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offline_over_a_damaged_fragment_is_no_offline_meta() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
let cache = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let mirror_path = write_stale_mirror(cache.path(), FULL_META_DIR, ®istry);
|
||||
damage_fragment(&mirror_path);
|
||||
|
||||
let http_client = ThrottledClient::default();
|
||||
let auth_headers = AuthHeaders::default();
|
||||
let opts = options(®istry, cache.path(), true, &http_client, &auth_headers);
|
||||
let error = fetch_full_metadata_cached("acme", &opts).await.expect_err("damaged mirror");
|
||||
assert!(matches!(error, FetchMetadataError::NoOfflineMeta { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_304_over_a_damaged_fragment_refetches_and_rewrites_the_mirror() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let cache = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let mirror_path = write_stale_mirror(cache.path(), FULL_META_DIR, ®istry);
|
||||
damage_fragment(&mirror_path);
|
||||
let revalidate = server
|
||||
.mock("GET", "/acme")
|
||||
.match_header("if-none-match", r#"W/"stale""#)
|
||||
.with_status(304)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let refetch = server
|
||||
.mock("GET", "/acme")
|
||||
.match_header("if-none-match", Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("etag", r#"W/"fresh""#)
|
||||
.with_body(PACKAGE_BODY)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let http_client = ThrottledClient::default();
|
||||
let auth_headers = AuthHeaders::default();
|
||||
let opts = options(®istry, cache.path(), false, &http_client, &auth_headers);
|
||||
let pkg = fetch_full_metadata_cached("acme", &opts).await.expect("refetched metadata");
|
||||
assert!(pkg.versions.get("1.0.0").is_some());
|
||||
assert!(!pkg.versions.has_corrupt_mirror_fragment());
|
||||
let persisted = load_meta(&mirror_path).expect("rewritten mirror");
|
||||
assert!(persisted.versions.get("1.0.0").is_some());
|
||||
revalidate.assert_async().await;
|
||||
refetch.assert_async().await;
|
||||
}
|
||||
+5
-3
@@ -2,7 +2,7 @@ use super::{
|
||||
ABBREVIATED_META_DIR, ACCEPT_ABBREVIATED, AuthHeaders, FULL_FILTERED_META_DIR, FULL_META_DIR,
|
||||
FetchFullMetadataCachedOptions, FetchMetadataError, Matcher, PACKAGE_BODY, TempDir,
|
||||
ThrottledClient, assert_cache_loss_after_304_recovers, fast_retry_opts,
|
||||
fetch_full_metadata_cached, get_legacy_pkg_mirror_path, get_pkg_mirror_path, load_meta,
|
||||
fetch_full_metadata_cached, get_legacy_pkg_mirror_paths, get_pkg_mirror_path, load_meta,
|
||||
load_meta_headers, no_retry_opts, remove_raced_mirror, write_stale_mirror,
|
||||
};
|
||||
|
||||
@@ -131,8 +131,10 @@ async fn offline_without_mirror_names_the_legacy_mirror_when_it_predates_the_ren
|
||||
|
||||
let cache = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let legacy_mirror = get_legacy_pkg_mirror_path(cache.path(), FULL_META_DIR, ®istry, "acme")
|
||||
.expect("legacy mirror path");
|
||||
let legacy_mirror = get_legacy_pkg_mirror_paths(cache.path(), FULL_META_DIR, ®istry, "acme")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("legacy mirror path under the current registry key");
|
||||
std::fs::create_dir_all(legacy_mirror.parent().expect("legacy mirror parent")).expect("mkdir");
|
||||
std::fs::write(&legacy_mirror, "{}\n{}").expect("write legacy mirror");
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! Pacquet's indexed format:
|
||||
//!
|
||||
//! ```text
|
||||
//! pacquet-meta-v1 <headers_len> <index_len>\n
|
||||
//! pnpm-meta-v1 <headers_len> <index_len>\n
|
||||
//! <headers JSON> # MetaHeaders: etag, fullEtag, modified
|
||||
//! <index JSON> # MirrorIndex: name, dist-tags, time,
|
||||
//! # homepage, versions: [version, off, len]
|
||||
@@ -50,6 +50,9 @@
|
||||
pub(crate) mod save_fail;
|
||||
|
||||
pub use headers::MetaHeaders;
|
||||
pub use legacy::{
|
||||
LEGACY_META_DIRS, LEGACY_PRIVATE_META_ROOT, find_legacy_pkg_mirror, get_legacy_pkg_mirror_paths,
|
||||
};
|
||||
pub use registry_key::{
|
||||
EncodeRegistryError, decode_registry_name, encode_pkg_name, get_legacy_registry_name,
|
||||
get_registry_name, is_unreadable_registry_key,
|
||||
@@ -62,6 +65,8 @@ mod registry_key;
|
||||
|
||||
mod headers;
|
||||
|
||||
mod legacy;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Write as _,
|
||||
@@ -84,13 +89,13 @@ use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Mirror directory for the **abbreviated** metadata cache.
|
||||
pub const ABBREVIATED_META_DIR: &str = "v11/metadata";
|
||||
pub const ABBREVIATED_META_DIR: &str = "v12/metadata";
|
||||
|
||||
/// Mirror directory for the **full** metadata cache.
|
||||
pub const FULL_META_DIR: &str = "v11/metadata-full";
|
||||
pub const FULL_META_DIR: &str = "v12/metadata-full";
|
||||
|
||||
/// Mirror directory for the filtered full metadata cache.
|
||||
pub const FULL_FILTERED_META_DIR: &str = "v11/metadata-full-filtered";
|
||||
pub const FULL_FILTERED_META_DIR: &str = "v12/metadata-full-filtered";
|
||||
|
||||
/// Error from [`save_meta`]. Surfaced to callers that care about
|
||||
/// individual write failures (tests, in particular); production
|
||||
@@ -142,25 +147,25 @@ const MAX_FRAGMENT_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
/// Mirror root for descriptor-scoped private metadata. A
|
||||
/// [`MetadataCacheScope::Private`] route stores its packuments under
|
||||
/// `<cache_dir>/v11/metadata-private/<descriptor-id>/<meta-suffix>/...`
|
||||
/// `<cache_dir>/v12/metadata-private/<descriptor-id>/<meta-suffix>/...`
|
||||
/// so one caller's private metadata never lands in the global mirror
|
||||
/// every other caller reads.
|
||||
const PRIVATE_META_ROOT: &str = "v11/metadata-private";
|
||||
const PRIVATE_META_ROOT: &str = "v12/metadata-private";
|
||||
|
||||
/// The mirror directory `base_meta_dir` resolves to under `scope`.
|
||||
///
|
||||
/// * [`MetadataCacheScope::Public`] keeps the global directory unchanged
|
||||
/// (the CLI and public routes).
|
||||
/// * [`MetadataCacheScope::Private`] relocates it under
|
||||
/// `v11/metadata-private/<descriptor-id>/` keyed by the descriptor id,
|
||||
/// `v12/metadata-private/<descriptor-id>/` keyed by the descriptor id,
|
||||
/// preserving the abbreviated/full/filtered split via the suffix after
|
||||
/// `v11/`.
|
||||
/// `v12/`.
|
||||
#[must_use]
|
||||
pub fn scoped_meta_dir(scope: &MetadataCacheScope, base_meta_dir: &str) -> String {
|
||||
match scope {
|
||||
MetadataCacheScope::Public => base_meta_dir.to_string(),
|
||||
MetadataCacheScope::Private { descriptor_id } => {
|
||||
let suffix = base_meta_dir.strip_prefix("v11/").unwrap_or(base_meta_dir);
|
||||
let suffix = base_meta_dir.strip_prefix("v12/").unwrap_or(base_meta_dir);
|
||||
format!("{PRIVATE_META_ROOT}/{descriptor_id}/{suffix}")
|
||||
}
|
||||
}
|
||||
@@ -182,28 +187,9 @@ pub fn get_pkg_mirror_path(
|
||||
.join(format!("{encoded_name}.jsonl")))
|
||||
}
|
||||
|
||||
/// Path to a legacy metadata mirror file for the given registry and package.
|
||||
/// Returns `None` when the registry URL does not parse or has no host.
|
||||
#[must_use]
|
||||
pub fn get_legacy_pkg_mirror_path(
|
||||
cache_dir: &Path,
|
||||
meta_dir: &str,
|
||||
registry: &str,
|
||||
pkg_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let registry_name = get_legacy_registry_name(registry)?;
|
||||
let encoded_name = encode_pkg_name(pkg_name);
|
||||
Some(
|
||||
cache_dir
|
||||
.join(meta_dir)
|
||||
.join(registry_name)
|
||||
.join(format!("{encoded_name}.jsonl")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Magic + format version. The trailing space separates it from the
|
||||
/// two record lengths on the same line.
|
||||
const MIRROR_MAGIC: &str = "pacquet-meta-v1";
|
||||
const MIRROR_FORMAT_ID: &str = "pnpm-meta-v1";
|
||||
|
||||
/// Top-level packument fields persisted in the mirror's index record.
|
||||
/// Everything else a registry serves at the top level is neither read
|
||||
@@ -283,7 +269,7 @@ pub fn save_meta_indexed_with_headers(
|
||||
.map_err(|error| SaveMetaError::Encode(EncodeMetaError(error)))?;
|
||||
|
||||
let mut contents = String::with_capacity(headers.len() + index.len() + 64);
|
||||
let _ = writeln!(contents, "{MIRROR_MAGIC} {} {}", headers.len(), index.len());
|
||||
let _ = writeln!(contents, "{MIRROR_FORMAT_ID} {} {}", headers.len(), index.len());
|
||||
contents.push_str(&headers);
|
||||
contents.push_str(&index);
|
||||
let mut bytes = contents.into_bytes();
|
||||
@@ -409,10 +395,10 @@ fn raise_open_file_limit_once() {
|
||||
#[cfg(not(unix))]
|
||||
fn raise_open_file_limit_once() {}
|
||||
|
||||
/// Parse the `pacquet-meta-v1 <headers_len> <index_len>` line.
|
||||
/// Parse the `pnpm-meta-v1 <headers_len> <index_len>` line.
|
||||
/// `None` for anything else, including pnpm's NDJSON format.
|
||||
fn parse_mirror_magic(line: &str) -> Option<(usize, usize)> {
|
||||
let rest = line.strip_prefix(MIRROR_MAGIC)?.strip_prefix(' ')?;
|
||||
pub(super) fn parse_format_line(line: &str) -> Option<(usize, usize)> {
|
||||
let rest = line.strip_prefix(MIRROR_FORMAT_ID)?.strip_prefix(' ')?;
|
||||
let (headers_len, index_len) = rest.split_once(' ')?;
|
||||
Some((headers_len.parse().ok()?, index_len.parse().ok()?))
|
||||
}
|
||||
@@ -429,7 +415,7 @@ fn read_mirror_headers(file: &mut File) -> Option<MetaHeaders> {
|
||||
.iter()
|
||||
.position(|&byte| byte == b'\n')?;
|
||||
let line = std::str::from_utf8(&chunk[..newline]).ok()?;
|
||||
let Some((headers_len, _)) = parse_mirror_magic(line) else {
|
||||
let Some((headers_len, _)) = parse_format_line(line) else {
|
||||
return serde_json::from_str(line).ok();
|
||||
};
|
||||
if headers_len > MAX_HEADERS_LEN {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Mirrors an older pnpm wrote, which this version no longer reads.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{encode_pkg_name, get_legacy_registry_name, get_registry_name};
|
||||
|
||||
/// Mirror directories of the NDJSON-era cache layout. The indexed layout
|
||||
/// moved every mirror to a new version directory, so nothing reads these.
|
||||
pub const LEGACY_META_DIRS: [&str; 3] =
|
||||
["v11/metadata", "v11/metadata-full", "v11/metadata-full-filtered"];
|
||||
|
||||
/// The descriptor-scoped mirror root of the NDJSON-era cache layout.
|
||||
pub const LEGACY_PRIVATE_META_ROOT: &str = "v11/metadata-private";
|
||||
|
||||
/// Paths where an older pnpm may have mirrored `pkg_name` for the current
|
||||
/// `meta_dir`: the previous version directory, under the current registry
|
||||
/// key and under the host-only key that predates it. Empty when `meta_dir`
|
||||
/// has no legacy counterpart.
|
||||
#[must_use]
|
||||
pub fn get_legacy_pkg_mirror_paths(
|
||||
cache_dir: &Path,
|
||||
meta_dir: &str,
|
||||
registry: &str,
|
||||
pkg_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
let Some(legacy_meta_dir) = LEGACY_META_DIRS
|
||||
.iter()
|
||||
.find(|legacy| legacy_meta_dir_suffix(legacy) == legacy_meta_dir_suffix(meta_dir))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let file_name = format!("{}.jsonl", encode_pkg_name(pkg_name));
|
||||
[get_registry_name(registry).ok(), get_legacy_registry_name(registry)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|registry_name| {
|
||||
cache_dir
|
||||
.join(legacy_meta_dir)
|
||||
.join(registry_name)
|
||||
.join(&file_name)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The first of [`get_legacy_pkg_mirror_paths`] that exists on disk.
|
||||
pub async fn find_legacy_pkg_mirror(
|
||||
cache_dir: &Path,
|
||||
meta_dir: &str,
|
||||
registry: &str,
|
||||
pkg_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
for path in get_legacy_pkg_mirror_paths(cache_dir, meta_dir, registry, pkg_name) {
|
||||
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn legacy_meta_dir_suffix(meta_dir: &str) -> &str {
|
||||
meta_dir.split_once('/').map_or(meta_dir, |(_, suffix)| suffix)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, DerivedPackuments, File, MAX_FRAGMENT_LEN, MAX_HEADERS_LEN, MAX_INDEX_LEN, MetaHeaders,
|
||||
MirrorFile, MirrorIndex, Package, PackageVersions, Path, Read, fs, parse_mirror_magic,
|
||||
MirrorFile, MirrorIndex, Package, PackageVersions, Path, Read, fs, parse_format_line,
|
||||
raise_open_file_limit_once,
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(super) fn mirror_layout(prefix: &[u8]) -> Option<Option<MirrorLayout>> {
|
||||
.iter()
|
||||
.position(|&byte| byte == b'\n')?;
|
||||
let line = std::str::from_utf8(&prefix[..newline]).ok()?;
|
||||
let Some((headers_len, index_len)) = parse_mirror_magic(line) else {
|
||||
let Some((headers_len, index_len)) = parse_format_line(line) else {
|
||||
return Some(None);
|
||||
};
|
||||
if headers_len > MAX_HEADERS_LEN || index_len > MAX_INDEX_LEN {
|
||||
@@ -124,6 +124,9 @@ pub(super) fn absolute_spans(
|
||||
/// fragments and close the file, so a full cache can never make `File::open`
|
||||
/// fail elsewhere and turn present mirrors into cache misses.
|
||||
///
|
||||
/// A span that cannot be read or is not JSON makes the whole mirror a cache
|
||||
/// miss, as it would through the held handle once the version hydrates.
|
||||
///
|
||||
/// Each validated span is read with its own positioned read: reading the
|
||||
/// contiguous fragment region would let a corrupt index's sparse gaps
|
||||
/// inflate the buffer far past the real fragment bytes. The budget bounds
|
||||
@@ -138,16 +141,12 @@ pub(super) fn buffer_fragments(
|
||||
for (version, absolute, len) in spans {
|
||||
budget = budget.checked_sub(u64::from(len))?;
|
||||
let mut bytes = vec![0u8; len as usize];
|
||||
if pnpm_registry::read_exact_at(file, &mut bytes, absolute).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(json) = String::from_utf8(bytes) else { continue };
|
||||
let Ok(raw) = serde_json::from_str::<Box<serde_json::value::RawValue>>(&json) else {
|
||||
continue;
|
||||
};
|
||||
pnpm_registry::read_exact_at(file, &mut bytes, absolute).ok()?;
|
||||
let json = String::from_utf8(bytes).ok()?;
|
||||
let raw = serde_json::from_str::<Box<serde_json::value::RawValue>>(&json).ok()?;
|
||||
raw_fragments.push((version, raw));
|
||||
}
|
||||
Some(PackageVersions::from_raw_fragments(raw_fragments))
|
||||
Some(PackageVersions::from_buffered_mirror_fragments(raw_fragments))
|
||||
}
|
||||
|
||||
/// A legacy NDJSON mirror: the whole body after the header line is the
|
||||
|
||||
@@ -8,7 +8,7 @@ use pnpm_network::MetadataCacheScope;
|
||||
|
||||
use super::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, decode_registry_name,
|
||||
encode_pkg_name, get_legacy_pkg_mirror_path, get_legacy_registry_name, get_pkg_mirror_path,
|
||||
encode_pkg_name, get_legacy_pkg_mirror_paths, get_legacy_registry_name, get_pkg_mirror_path,
|
||||
get_registry_name, load_meta, load_meta_headers, load_meta_with_hold_cap, save_meta_indexed,
|
||||
scoped_meta_dir,
|
||||
};
|
||||
@@ -27,12 +27,12 @@ fn scoped_meta_dir_private_namespaces_by_descriptor() {
|
||||
let scope = MetadataCacheScope::Private { descriptor_id: "abc123".to_string() };
|
||||
assert_eq!(
|
||||
scoped_meta_dir(&scope, ABBREVIATED_META_DIR),
|
||||
"v11/metadata-private/abc123/metadata",
|
||||
"v12/metadata-private/abc123/metadata",
|
||||
);
|
||||
assert_eq!(scoped_meta_dir(&scope, FULL_META_DIR), "v11/metadata-private/abc123/metadata-full");
|
||||
assert_eq!(scoped_meta_dir(&scope, FULL_META_DIR), "v12/metadata-private/abc123/metadata-full");
|
||||
assert_eq!(
|
||||
scoped_meta_dir(&scope, FULL_FILTERED_META_DIR),
|
||||
"v11/metadata-private/abc123/metadata-full-filtered",
|
||||
"v12/metadata-private/abc123/metadata-full-filtered",
|
||||
);
|
||||
// Distinct descriptors never share a directory.
|
||||
let other = MetadataCacheScope::Private { descriptor_id: "def456".to_string() };
|
||||
@@ -185,36 +185,26 @@ fn get_legacy_registry_name_none_for_a_hostless_url() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_legacy_pkg_mirror_path_sits_next_to_the_current_mirror() {
|
||||
fn get_legacy_pkg_mirror_paths_cover_both_registry_keys_of_the_legacy_layout() {
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let current = get_pkg_mirror_path(
|
||||
cache_dir.path(),
|
||||
ABBREVIATED_META_DIR,
|
||||
"https://registry.npmjs.org/",
|
||||
"acme",
|
||||
)
|
||||
.expect("current path");
|
||||
let legacy = get_legacy_pkg_mirror_path(
|
||||
cache_dir.path(),
|
||||
ABBREVIATED_META_DIR,
|
||||
"https://registry.npmjs.org/",
|
||||
"acme",
|
||||
)
|
||||
.expect("legacy path");
|
||||
assert_eq!(legacy.file_name(), current.file_name(), "same package, same file name");
|
||||
assert_ne!(legacy, current, "legacy and current mirrors never collide");
|
||||
let registry = "https://registry.npmjs.org/";
|
||||
let current = get_pkg_mirror_path(cache_dir.path(), ABBREVIATED_META_DIR, registry, "acme")
|
||||
.expect("current path");
|
||||
let legacy =
|
||||
get_legacy_pkg_mirror_paths(cache_dir.path(), ABBREVIATED_META_DIR, registry, "acme");
|
||||
let legacy_root = cache_dir.path().join("v11/metadata");
|
||||
assert_eq!(
|
||||
legacy,
|
||||
cache_dir
|
||||
.path()
|
||||
.join(ABBREVIATED_META_DIR)
|
||||
.join("registry.npmjs.org")
|
||||
.join("acme.jsonl"),
|
||||
[
|
||||
legacy_root
|
||||
.join(get_registry_name(registry).expect("registry key"))
|
||||
.join("acme.jsonl"),
|
||||
legacy_root.join("registry.npmjs.org").join("acme.jsonl"),
|
||||
],
|
||||
);
|
||||
assert!(!legacy.contains(¤t), "legacy and current mirrors never collide");
|
||||
}
|
||||
|
||||
/// `http` metadata can be rewritten in transit and must never be handed to
|
||||
/// a resolution configured for `https`.
|
||||
#[test]
|
||||
fn get_registry_name_separates_schemes() {
|
||||
assert_ne!(
|
||||
@@ -459,7 +449,7 @@ fn get_pkg_mirror_path_composes_full_path() {
|
||||
.expect("compose");
|
||||
assert_eq!(
|
||||
got,
|
||||
PathBuf::from("/cache/v11/metadata-full/https%3A+registry.npmjs.org/lodash.jsonl"),
|
||||
PathBuf::from("/cache/v12/metadata-full/https%3A+registry.npmjs.org/lodash.jsonl"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,9 +457,9 @@ fn get_pkg_mirror_path_composes_full_path() {
|
||||
/// Any drift would silently fork the cache layout from pnpm's.
|
||||
#[test]
|
||||
fn constants_match_upstream() {
|
||||
assert_eq!(FULL_META_DIR, "v11/metadata-full");
|
||||
assert_eq!(FULL_FILTERED_META_DIR, "v11/metadata-full-filtered");
|
||||
assert_eq!(ABBREVIATED_META_DIR, "v11/metadata");
|
||||
assert_eq!(FULL_META_DIR, "v12/metadata-full");
|
||||
assert_eq!(FULL_FILTERED_META_DIR, "v12/metadata-full-filtered");
|
||||
assert_eq!(ABBREVIATED_META_DIR, "v12/metadata");
|
||||
}
|
||||
|
||||
/// Build a minimal `Package` fixture for the round-trip tests.
|
||||
@@ -579,7 +569,7 @@ fn load_meta_past_the_hold_cap_buffers_fragments_instead_of_missing() {
|
||||
fn load_meta_rejects_oversized_declared_record_lengths() {
|
||||
let dir = TempDir::new().expect("tmp dir");
|
||||
let mirror = dir.path().join("acme.jsonl");
|
||||
std::fs::write(&mirror, "pacquet-meta-v1 128 999999999999\n{}{}").expect("write");
|
||||
std::fs::write(&mirror, "pnpm-meta-v1 128 999999999999\n{}{}").expect("write");
|
||||
assert!(load_meta(&mirror).is_none());
|
||||
}
|
||||
|
||||
@@ -613,7 +603,7 @@ fn load_meta_past_the_hold_cap_skips_a_sparse_gap_between_spans() {
|
||||
fragment.len(),
|
||||
);
|
||||
let contents =
|
||||
format!("pacquet-meta-v1 {} {}\n{headers}{index}{fragment}", headers.len(), index.len());
|
||||
format!("pnpm-meta-v1 {} {}\n{headers}{index}{fragment}", headers.len(), index.len());
|
||||
std::fs::write(&mirror, &contents).expect("write");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -621,12 +611,27 @@ fn load_meta_past_the_hold_cap_skips_a_sparse_gap_between_spans() {
|
||||
.expect("open");
|
||||
file.set_len(contents.len() as u64 + far_offset + 16)
|
||||
.expect("extend sparsely");
|
||||
let loaded = load_meta_with_hold_cap(&mirror, 0).expect("read full back without a handle");
|
||||
let manifest = loaded.versions.get("1.0.0").expect("hydrate the near fragment");
|
||||
assert_eq!(manifest.dist.tarball, "https://registry/acme-1.0.0.tgz");
|
||||
// The far span reads zeroes out of the sparse hole — not JSON, so
|
||||
// the version is absent; the gap itself must never be buffered.
|
||||
assert!(loaded.versions.get("2.0.0").is_none());
|
||||
// The far span reads zeroes out of the sparse hole, which are not JSON,
|
||||
// so the mirror is a miss. The gap itself must never be buffered.
|
||||
assert!(load_meta_with_hold_cap(&mirror, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_meta_past_the_hold_cap_keeps_a_wrong_shape_fragment_absent_not_damaged() {
|
||||
let dir = TempDir::new().expect("tmp dir");
|
||||
let mirror = dir.path().join("acme.jsonl");
|
||||
let headers = "{}";
|
||||
let fragment = r#"{"name":1}"#;
|
||||
let index = format!(
|
||||
r#"{{"name":"acme","distTags":{{}},"versions":[["1.0.0",0,{}]]}}"#,
|
||||
fragment.len(),
|
||||
);
|
||||
let contents =
|
||||
format!("pnpm-meta-v1 {} {}\n{headers}{index}{fragment}", headers.len(), index.len());
|
||||
std::fs::write(&mirror, &contents).expect("write");
|
||||
let loaded = load_meta_with_hold_cap(&mirror, 0).expect("read back without a handle");
|
||||
assert!(loaded.versions.get("1.0.0").is_none());
|
||||
assert!(!loaded.versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -642,7 +647,7 @@ fn load_meta_treats_an_oversized_fragment_span_as_absent() {
|
||||
32 * 1024 * 1024,
|
||||
);
|
||||
let contents =
|
||||
format!("pacquet-meta-v1 {} {}\n{headers}{index}{fragment}", headers.len(), index.len());
|
||||
format!("pnpm-meta-v1 {} {}\n{headers}{index}{fragment}", headers.len(), index.len());
|
||||
std::fs::write(&mirror, &contents).expect("write");
|
||||
// A sparse tail makes the file size cover the declared span
|
||||
// without paying for the bytes, like a corrupt mirror would.
|
||||
|
||||
@@ -62,6 +62,8 @@ mod state;
|
||||
|
||||
mod mirror_pick;
|
||||
|
||||
mod fetch_pick;
|
||||
|
||||
mod mirror_persistence;
|
||||
use mirror_persistence::{get_file_mtime, metadata_cache_key, validate_package_name};
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ use std::{path::Path, sync::Arc};
|
||||
/// re-fetching). Extracting it keeps the two call sites identical so
|
||||
/// the upgrade-and-persist side-effects can't drift.
|
||||
///
|
||||
/// Returns `Ok(None)` when the hit must not be terminal: the entry is
|
||||
/// a registry-unverified disk promotion (see
|
||||
/// [`PackageMetaCache::set_unverified`]) whose pick failed, and the
|
||||
/// resolver isn't offline. The caller then falls through to the disk +
|
||||
/// network flow, whose fetch replaces the entry with a verified one.
|
||||
/// Returns `Ok(None)` when the hit must not be terminal, and the caller
|
||||
/// falls through to the disk + network flow that replaces the entry:
|
||||
/// either the entry is a registry-unverified disk promotion (see
|
||||
/// [`PackageMetaCache::set_unverified`]) whose pick failed and the
|
||||
/// resolver isn't offline, or the pick hydrated a damaged fragment of
|
||||
/// the mirror the entry came from.
|
||||
///
|
||||
/// The argument list is wide because the helper consumes everything
|
||||
/// the per-call frame already computed (cache key, derived
|
||||
@@ -117,6 +118,9 @@ async fn finish_cache_hit_pick<Cache: PackageMetaCache>(
|
||||
} else {
|
||||
(meta, picked)
|
||||
};
|
||||
if meta.versions.has_corrupt_mirror_fragment() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !ctx.cache_policy.offline
|
||||
&& !registry_verified
|
||||
&& !unverified_pick_is_safe(ctx, spec, opts, &meta, picked.as_ref())
|
||||
|
||||
@@ -37,6 +37,20 @@ pub enum PickPackageError {
|
||||
#[help]
|
||||
hint: Option<String>,
|
||||
},
|
||||
/// `ERR_PNPM_CORRUPT_METADATA_MIRROR`: a bypassing refetch — the
|
||||
/// last available remedy for a mirror fragment that fails to
|
||||
/// decode — rewrote the mirror and the fragment is still
|
||||
/// undecodable.
|
||||
#[display(
|
||||
"Metadata mirror for {spec_name}@{spec_fetch_spec} is still unreadable after a bypassing refetch ({pkg_mirror:?})"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_CORRUPT_METADATA_MIRROR))]
|
||||
CorruptMetadataMirror {
|
||||
#[error(not(source))]
|
||||
spec_name: String,
|
||||
spec_fetch_spec: String,
|
||||
pkg_mirror: PathBuf,
|
||||
},
|
||||
/// Underlying picker error (no versions, unpublished, missing
|
||||
/// time, etc.). The picker errors are described on
|
||||
/// [`PickPackageFromMetaError`].
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! The network fetch of a pick and its fallbacks.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pnpm_network::MetadataCacheScope;
|
||||
use pnpm_registry::{Package, PackageVersion};
|
||||
|
||||
use crate::{
|
||||
FetchFullMetadataCachedOptions, FetchMetadataError,
|
||||
fetch_full_metadata_cached::{
|
||||
fetch_full_metadata_bypassing_cache, fetch_full_metadata_cached_lazily,
|
||||
},
|
||||
mirror::load_meta_async,
|
||||
pick_package_from_meta::RegistryPackageSpec,
|
||||
};
|
||||
|
||||
use super::{
|
||||
PackageMetaCache, PickPackageContext, PickPackageError, PickPackageOptions, PickPackageResult,
|
||||
PickState, UpgradeOutcome, maybe_upgrade_abbreviated_meta_for_release_age,
|
||||
persist_upgraded_to_mirror, pick_from_meta,
|
||||
};
|
||||
|
||||
impl PickState<'_> {
|
||||
/// The network fetch via the cached fetcher (step 5).
|
||||
pub(super) async fn fetch_and_pick<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
spec: &RegistryPackageSpec,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
mut disk_meta: Option<Arc<Package>>,
|
||||
) -> Result<PickPackageResult, PickPackageError> {
|
||||
let fetch_opts = self.cached_fetch_options(ctx, opts.registry);
|
||||
let mut bypass_cache = false;
|
||||
loop {
|
||||
let meta = match self.fetch_packument(&spec.name, &fetch_opts, bypass_cache).await {
|
||||
Ok(meta) => meta,
|
||||
Err(error) => {
|
||||
let disk = self.disk_fallback(&error, disk_meta.take()).await;
|
||||
return self.handle_fetch_fallback(error, disk, spec, opts);
|
||||
}
|
||||
};
|
||||
|
||||
let (meta, picked_meta, picked) = self.upgrade_and_pick(ctx, spec, opts, meta).await?;
|
||||
if !picked_meta.versions.has_corrupt_mirror_fragment() {
|
||||
self.cache_picked_meta(ctx, opts, &meta);
|
||||
return Ok(PickPackageResult { meta: picked_meta, picked_package: picked });
|
||||
}
|
||||
if bypass_cache {
|
||||
return self.unreadable_mirror_error(spec, picked_meta, picked.is_some());
|
||||
}
|
||||
bypass_cache = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn unreadable_mirror_error(
|
||||
&self,
|
||||
spec: &RegistryPackageSpec,
|
||||
picked_meta: Arc<Package>,
|
||||
has_picked: bool,
|
||||
) -> Result<PickPackageResult, PickPackageError> {
|
||||
if !has_picked && picked_meta.latest_decode_error().is_some() {
|
||||
return Ok(PickPackageResult { meta: picked_meta, picked_package: None });
|
||||
}
|
||||
Err(PickPackageError::CorruptMetadataMirror {
|
||||
spec_name: spec.name.clone(),
|
||||
spec_fetch_spec: spec.fetch_spec.clone(),
|
||||
pkg_mirror: self.pkg_mirror.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_packument(
|
||||
&self,
|
||||
spec_name: &str,
|
||||
fetch_opts: &FetchFullMetadataCachedOptions<'_>,
|
||||
bypass_cache: bool,
|
||||
) -> Result<Arc<Package>, FetchMetadataError> {
|
||||
let result = if bypass_cache {
|
||||
fetch_full_metadata_bypassing_cache(spec_name, fetch_opts).await
|
||||
} else {
|
||||
fetch_full_metadata_cached_lazily(spec_name, fetch_opts).await
|
||||
};
|
||||
result.map(Arc::new)
|
||||
}
|
||||
|
||||
async fn upgrade_and_pick<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
spec: &RegistryPackageSpec,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
meta: Arc<Package>,
|
||||
) -> Result<(Arc<Package>, Arc<Package>, Option<Arc<PackageVersion>>), PickPackageError> {
|
||||
let upgrade = maybe_upgrade_abbreviated_meta_for_release_age(
|
||||
ctx,
|
||||
spec,
|
||||
opts,
|
||||
self.full_metadata,
|
||||
&self.cache_key,
|
||||
meta,
|
||||
)
|
||||
.await?;
|
||||
let meta = self.persist_release_age_upgrade(ctx, opts, &upgrade);
|
||||
let (picked_meta, picked) =
|
||||
pick_from_meta(&self.picker_opts, spec, Arc::clone(&meta), opts.blocked_versions)?;
|
||||
Ok((meta, picked_meta, picked))
|
||||
}
|
||||
|
||||
fn cache_picked_meta<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
meta: &Arc<Package>,
|
||||
) {
|
||||
if !opts.request.dry_run {
|
||||
ctx.metadata.meta_cache.set(self.cache_key.clone(), Arc::clone(meta));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fetch_fallback(
|
||||
&self,
|
||||
error: FetchMetadataError,
|
||||
disk: Option<Arc<Package>>,
|
||||
spec: &RegistryPackageSpec,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
) -> Result<PickPackageResult, PickPackageError> {
|
||||
let Some(disk) = disk else {
|
||||
return Err(error.into());
|
||||
};
|
||||
tracing::debug!(
|
||||
target: "pnpm_resolving_npm_resolver::pick_package",
|
||||
?error,
|
||||
pkg_name = %spec.name,
|
||||
"metadata fetch failed; falling back to on-disk mirror",
|
||||
);
|
||||
let (meta, picked) = pick_from_meta(&self.picker_opts, spec, disk, opts.blocked_versions)?;
|
||||
if !meta.versions.has_corrupt_mirror_fragment() {
|
||||
return Ok(PickPackageResult { meta, picked_package: picked });
|
||||
}
|
||||
Err(error.into())
|
||||
}
|
||||
|
||||
fn persist_release_age_upgrade<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
upgrade: &UpgradeOutcome,
|
||||
) -> Arc<Package> {
|
||||
let mut meta = Arc::clone(&upgrade.meta);
|
||||
if upgrade.upgraded {
|
||||
if !opts.request.dry_run
|
||||
&& let Some(reloaded) = self.pkg_mirror
|
||||
.as_deref()
|
||||
.and_then(|path| {
|
||||
persist_upgraded_to_mirror(path, upgrade, self.use_filtered_full_metadata)
|
||||
})
|
||||
{
|
||||
meta = Arc::new(reloaded);
|
||||
}
|
||||
ctx.metadata.fetch_locker.mark_release_age_upgrade_checked(&self.cache_key, &meta);
|
||||
}
|
||||
|
||||
meta
|
||||
}
|
||||
|
||||
fn cached_fetch_options<'ctx, Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'ctx, Cache>,
|
||||
registry: &'ctx str,
|
||||
) -> FetchFullMetadataCachedOptions<'ctx> {
|
||||
FetchFullMetadataCachedOptions {
|
||||
registry,
|
||||
cache_dir: ctx.metadata.cache_dir,
|
||||
full_metadata: self.full_metadata,
|
||||
filter_metadata: self.use_filtered_full_metadata,
|
||||
offline: ctx.cache_policy.offline,
|
||||
priority: pnpm_network::UNPRIORITIZED,
|
||||
http: ctx.metadata.http,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror a failed fetch may fall back to.
|
||||
///
|
||||
/// The fetcher already saved a 200 to disk before it returned (when it
|
||||
/// returned Ok). If it returned Err, an existing mirror is good enough
|
||||
/// to pick from, even if the latest sync failed.
|
||||
///
|
||||
/// A private route must fail closed on a `401`/`403`/private-`404`: a
|
||||
/// revoked credential or a hidden private package must not keep serving
|
||||
/// the last cached packument, even from its own (same-namespace) mirror.
|
||||
/// Only a transport failure (`5xx`/timeout/network) falls back, and only
|
||||
/// within the scoped mirror `pkg_mirror` already points at. A public
|
||||
/// route (the CLI / public registries) keeps the original
|
||||
/// fall-back-on-any-error behavior.
|
||||
async fn disk_fallback(
|
||||
&self,
|
||||
error: &FetchMetadataError,
|
||||
disk_meta: Option<Arc<Package>>,
|
||||
) -> Option<Arc<Package>> {
|
||||
let allow_fallback =
|
||||
matches!(self.scope, MetadataCacheScope::Public) || !error.is_access_denied();
|
||||
if !allow_fallback {
|
||||
return None;
|
||||
}
|
||||
match disk_meta {
|
||||
Some(meta) => Some(meta),
|
||||
None => load_meta_async(self.pkg_mirror.as_deref()).await.map(Arc::new),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
errors::legacy_mirror_hint,
|
||||
mirror::{MetaHeaders, get_legacy_pkg_mirror_path, load_meta_headers_async},
|
||||
mirror::{MetaHeaders, find_legacy_pkg_mirror, load_meta_headers_async},
|
||||
};
|
||||
|
||||
/// Registries that omit `ETag` cannot answer `If-None-Match` with 304, so a
|
||||
@@ -80,6 +80,9 @@ impl PickState<'_> {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if picked_meta.versions.has_corrupt_mirror_fragment() {
|
||||
return None;
|
||||
}
|
||||
self.promote_unverified(ctx, opts, &meta);
|
||||
Some(PickPackageResult { meta: picked_meta, picked_package: Some(picked) })
|
||||
}
|
||||
@@ -125,6 +128,9 @@ impl PickState<'_> {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if picked_meta.versions.has_corrupt_mirror_fragment() {
|
||||
return None;
|
||||
}
|
||||
if picked.version.to_string() != stable_version {
|
||||
return None;
|
||||
}
|
||||
@@ -177,6 +183,9 @@ impl PickState<'_> {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if picked_meta.versions.has_corrupt_mirror_fragment() {
|
||||
return None;
|
||||
}
|
||||
self.promote_unverified(ctx, opts, &meta);
|
||||
Some(PickPackageResult { meta: picked_meta, picked_package: Some(picked) })
|
||||
}
|
||||
@@ -237,6 +246,9 @@ impl PickState<'_> {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if picked_meta.versions.has_corrupt_mirror_fragment() {
|
||||
return None;
|
||||
}
|
||||
// Same rationale as the version-spec fast path — promote the
|
||||
// disk-loaded packument into the install-scoped in-memory cache.
|
||||
if !opts.request.dry_run {
|
||||
@@ -285,14 +297,13 @@ impl PickState<'_> {
|
||||
let meta = self.upgraded_meta(ctx, spec, opts, meta).await?;
|
||||
let (picked_meta, picked) =
|
||||
pick_from_meta(&self.picker_opts, spec, Arc::clone(&meta), opts.blocked_versions)?;
|
||||
if picked.is_some() {
|
||||
let corrupt_mirror = picked_meta.versions.has_corrupt_mirror_fragment();
|
||||
if picked.is_some() && !corrupt_mirror {
|
||||
return Ok(Some(PickPackageResult { meta: picked_meta, picked_package: picked }));
|
||||
}
|
||||
// Fall through to fetch when disk had the meta but no version
|
||||
// satisfied the spec — the disk copy may be stale. Restore the
|
||||
// (possibly upgraded) meta for later paths that reuse the in-store
|
||||
// load.
|
||||
*disk_meta = Some(meta);
|
||||
if !corrupt_mirror {
|
||||
*disk_meta = Some(meta);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -302,15 +313,13 @@ impl PickState<'_> {
|
||||
spec: &RegistryPackageSpec,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
) -> PickPackageError {
|
||||
let legacy_mirror = ctx.metadata.cache_dir.and_then(|dir| {
|
||||
get_legacy_pkg_mirror_path(dir, self.base_meta_dir, opts.registry, &spec.name)
|
||||
});
|
||||
let hint = match legacy_mirror {
|
||||
Some(path) if tokio::fs::try_exists(&path).await.unwrap_or(false) => {
|
||||
Some(legacy_mirror_hint(&path))
|
||||
let legacy_mirror = match ctx.metadata.cache_dir {
|
||||
Some(dir) => {
|
||||
find_legacy_pkg_mirror(dir, self.base_meta_dir, opts.registry, &spec.name).await
|
||||
}
|
||||
_ => None,
|
||||
None => None,
|
||||
};
|
||||
let hint = legacy_mirror.map(|path| legacy_mirror_hint(&path));
|
||||
PickPackageError::NoOfflineMeta {
|
||||
spec_name: spec.name.clone(),
|
||||
spec_fetch_spec: spec.fetch_spec.clone(),
|
||||
@@ -327,10 +336,6 @@ impl PickState<'_> {
|
||||
opts: &PickPackageOptions<'_>,
|
||||
meta: Arc<Package>,
|
||||
) -> Result<PickPackageResult, PickPackageError> {
|
||||
// `maybe_upgrade_abbreviated_meta_for_release_age` short-circuits
|
||||
// when offline, so a later cache hit returns this same meta
|
||||
// without any network access.
|
||||
self.promote_unverified(ctx, opts, &meta);
|
||||
let unfiltered_meta = Arc::clone(&meta);
|
||||
let (meta, picked) = pick_from_meta(&self.picker_opts, spec, meta, opts.blocked_versions)?;
|
||||
let (meta, picked) = pick_from_meta_offline(
|
||||
@@ -344,6 +349,18 @@ impl PickState<'_> {
|
||||
opts.blocked_versions,
|
||||
)
|
||||
.await?;
|
||||
if meta.versions.has_corrupt_mirror_fragment() {
|
||||
return Err(PickPackageError::NoOfflineMeta {
|
||||
spec_name: spec.name.clone(),
|
||||
spec_fetch_spec: spec.fetch_spec.clone(),
|
||||
pkg_mirror: self.pkg_mirror.clone().unwrap_or_default(),
|
||||
hint: None,
|
||||
});
|
||||
}
|
||||
// `maybe_upgrade_abbreviated_meta_for_release_age` short-circuits
|
||||
// when offline, so a later cache hit returns this same meta
|
||||
// without any network access.
|
||||
self.promote_unverified(ctx, opts, &unfiltered_meta);
|
||||
Ok(PickPackageResult { meta, picked_package: picked })
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,9 @@ use pnpm_network::MetadataCacheScope;
|
||||
use pnpm_registry::Package;
|
||||
|
||||
use crate::{
|
||||
FetchFullMetadataCachedOptions, FetchMetadataError, fetch_full_metadata_cached,
|
||||
mirror::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, get_pkg_mirror_path,
|
||||
load_meta_async, scoped_meta_dir,
|
||||
scoped_meta_dir,
|
||||
},
|
||||
pick_package_from_meta::RegistryPackageSpec,
|
||||
registry_url::to_registry_url,
|
||||
@@ -18,8 +17,8 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
PackageMetaCache, PickPackageContext, PickPackageError, PickPackageOptions, PickPackageResult,
|
||||
PickerOpts, UpgradeOutcome, handle_cache_hit, maybe_upgrade_abbreviated_meta_for_release_age,
|
||||
metadata_cache_key, persist_upgraded_to_mirror, pick_from_meta,
|
||||
PickerOpts, handle_cache_hit, maybe_upgrade_abbreviated_meta_for_release_age,
|
||||
metadata_cache_key, persist_upgraded_to_mirror,
|
||||
};
|
||||
|
||||
/// The route classification and cache keys every layer of one pick shares.
|
||||
@@ -193,127 +192,4 @@ impl<'a> PickState<'a> {
|
||||
}
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// The network fetch via the cached fetcher (step 5). The cached
|
||||
/// fetcher handles conditional headers + 200 cache write internally; on
|
||||
/// a 304 it re-reads the mirror body. On the error path, a fetch failure
|
||||
/// with a disk fallback uses it; otherwise the error propagates.
|
||||
pub(super) async fn fetch_and_pick<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
spec: &RegistryPackageSpec,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
disk_meta: Option<Arc<Package>>,
|
||||
) -> Result<PickPackageResult, PickPackageError> {
|
||||
let fetch_opts = self.cached_fetch_options(ctx, opts.registry);
|
||||
|
||||
let meta = match fetch_full_metadata_cached(&spec.name, &fetch_opts).await {
|
||||
Ok(meta) => Arc::new(meta),
|
||||
Err(error) => {
|
||||
let Some(disk) = self.disk_fallback(&error, disk_meta).await else {
|
||||
return Err(error.into());
|
||||
};
|
||||
tracing::debug!(
|
||||
target: "pnpm_resolving_npm_resolver::pick_package",
|
||||
?error,
|
||||
pkg_name = %spec.name,
|
||||
"metadata fetch failed; falling back to on-disk mirror",
|
||||
);
|
||||
let (meta, picked) =
|
||||
pick_from_meta(&self.picker_opts, spec, disk, opts.blocked_versions)?;
|
||||
return Ok(PickPackageResult { meta, picked_package: picked });
|
||||
}
|
||||
};
|
||||
|
||||
let upgrade = maybe_upgrade_abbreviated_meta_for_release_age(
|
||||
ctx,
|
||||
spec,
|
||||
opts,
|
||||
self.full_metadata,
|
||||
&self.cache_key,
|
||||
meta,
|
||||
)
|
||||
.await?;
|
||||
let meta = self.persist_release_age_upgrade(ctx, opts, &upgrade);
|
||||
|
||||
// Worth flagging: a dry-run is meant to gate the on-disk save, but
|
||||
// `fetch_full_metadata_cached` already wrote the response body to
|
||||
// the mirror by the time it returned, so `opts.dry_run` only
|
||||
// suppresses the in-memory cache write. A future refactor that
|
||||
// threads `dry_run` into the fetcher can restore a fully
|
||||
// no-disk-side-effect dry-run.
|
||||
if !opts.request.dry_run {
|
||||
ctx.metadata.meta_cache.set(self.cache_key.clone(), Arc::clone(&meta));
|
||||
}
|
||||
let (meta, picked) = pick_from_meta(&self.picker_opts, spec, meta, opts.blocked_versions)?;
|
||||
Ok(PickPackageResult { meta, picked_package: picked })
|
||||
}
|
||||
|
||||
pub(super) fn persist_release_age_upgrade<Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'_, Cache>,
|
||||
opts: &PickPackageOptions<'_>,
|
||||
upgrade: &UpgradeOutcome,
|
||||
) -> Arc<Package> {
|
||||
let mut meta = Arc::clone(&upgrade.meta);
|
||||
if upgrade.upgraded {
|
||||
if !opts.request.dry_run
|
||||
&& let Some(reloaded) = self.pkg_mirror
|
||||
.as_deref()
|
||||
.and_then(|path| {
|
||||
persist_upgraded_to_mirror(path, upgrade, self.use_filtered_full_metadata)
|
||||
})
|
||||
{
|
||||
meta = Arc::new(reloaded);
|
||||
}
|
||||
ctx.metadata.fetch_locker.mark_release_age_upgrade_checked(&self.cache_key, &meta);
|
||||
}
|
||||
|
||||
meta
|
||||
}
|
||||
|
||||
pub(super) fn cached_fetch_options<'ctx, Cache: PackageMetaCache>(
|
||||
&self,
|
||||
ctx: &PickPackageContext<'ctx, Cache>,
|
||||
registry: &'ctx str,
|
||||
) -> FetchFullMetadataCachedOptions<'ctx> {
|
||||
FetchFullMetadataCachedOptions {
|
||||
registry,
|
||||
cache_dir: ctx.metadata.cache_dir,
|
||||
full_metadata: self.full_metadata,
|
||||
filter_metadata: self.use_filtered_full_metadata,
|
||||
offline: ctx.cache_policy.offline,
|
||||
priority: pnpm_network::UNPRIORITIZED,
|
||||
http: ctx.metadata.http,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror a failed fetch may fall back to.
|
||||
///
|
||||
/// The fetcher already saved a 200 to disk before it returned (when it
|
||||
/// returned Ok). If it returned Err, an existing mirror is good enough
|
||||
/// to pick from, even if the latest sync failed.
|
||||
///
|
||||
/// A private route must fail closed on a `401`/`403`/private-`404`: a
|
||||
/// revoked credential or a hidden private package must not keep serving
|
||||
/// the last cached packument, even from its own (same-namespace) mirror.
|
||||
/// Only a transport failure (`5xx`/timeout/network) falls back, and only
|
||||
/// within the scoped mirror `pkg_mirror` already points at. A public
|
||||
/// route (the CLI / public registries) keeps the original
|
||||
/// fall-back-on-any-error behavior.
|
||||
pub(super) async fn disk_fallback(
|
||||
&self,
|
||||
error: &FetchMetadataError,
|
||||
disk_meta: Option<Arc<Package>>,
|
||||
) -> Option<Arc<Package>> {
|
||||
let allow_fallback =
|
||||
matches!(self.scope, MetadataCacheScope::Public) || !error.is_access_denied();
|
||||
if !allow_fallback {
|
||||
return None;
|
||||
}
|
||||
match disk_meta {
|
||||
Some(meta) => Some(meta),
|
||||
None => load_meta_async(self.pkg_mirror.as_deref()).await.map(Arc::new),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ mod cache_partitions;
|
||||
|
||||
mod cache_read_modes;
|
||||
|
||||
mod corrupt_mirror;
|
||||
mod stable_range_mirror;
|
||||
|
||||
mod version_selection;
|
||||
|
||||
mod offline_store_pick;
|
||||
@@ -38,7 +40,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
mirror::{
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, get_legacy_pkg_mirror_path,
|
||||
ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR, get_legacy_pkg_mirror_paths,
|
||||
get_pkg_mirror_path, load_meta,
|
||||
},
|
||||
pick_package_from_meta::{RegistryPackageSpec, RegistryPackageSpecType},
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn private_scope_fails_closed_on_401_without_disk_fallback() {
|
||||
// serve from.
|
||||
persist_meta_to_mirror(
|
||||
cache_dir.path(),
|
||||
"v11/metadata-private/deadbeef/metadata",
|
||||
"v12/metadata-private/deadbeef/metadata",
|
||||
®istry,
|
||||
&preloaded,
|
||||
)
|
||||
|
||||
@@ -456,7 +456,7 @@ async fn private_scope_writes_descriptor_namespaced_mirror() {
|
||||
|
||||
let scoped = get_pkg_mirror_path(
|
||||
cache_dir.path(),
|
||||
"v11/metadata-private/deadbeef/metadata",
|
||||
"v12/metadata-private/deadbeef/metadata",
|
||||
®istry,
|
||||
"acme",
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::{
|
||||
FULL_FILTERED_META_DIR, FULL_META_DIR, InMemoryPackageMetaCache, MetadataCacheScope,
|
||||
PACKAGE_BODY, PickPackageContext, PickPackageError, RetryOpts, STALE_PACKAGE_BODY, TempDir,
|
||||
ThrottledClient, VersionSelectorEntry, VersionSelectorType, VersionSelectorWithWeight,
|
||||
VersionSelectors, assert_eq, default_opts, get_legacy_pkg_mirror_path, get_pkg_mirror_path,
|
||||
VersionSelectors, assert_eq, default_opts, get_legacy_pkg_mirror_paths, get_pkg_mirror_path,
|
||||
metadata_cache_key, persist_meta_to_mirror, pick_package, range_spec,
|
||||
shared_packument_fetch_locker, version_spec,
|
||||
};
|
||||
@@ -252,8 +252,9 @@ async fn offline_without_mirror_names_the_legacy_mirror_when_it_predates_the_ren
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let registry = "https://registry.example.com/".to_string();
|
||||
let legacy_mirror =
|
||||
get_legacy_pkg_mirror_path(cache_dir.path(), ABBREVIATED_META_DIR, ®istry, "acme")
|
||||
.expect("legacy mirror path");
|
||||
get_legacy_pkg_mirror_paths(cache_dir.path(), ABBREVIATED_META_DIR, ®istry, "acme")
|
||||
.pop()
|
||||
.expect("host-only legacy mirror path");
|
||||
std::fs::create_dir_all(legacy_mirror.parent().expect("legacy mirror parent")).expect("mkdir");
|
||||
std::fs::write(&legacy_mirror, "{}\n{}").expect("write legacy mirror");
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::{
|
||||
ABBREVIATED_META_DIR, AuthHeaders, InMemoryPackageMetaCache, PACKAGE_BODY, PickPackageContext,
|
||||
PickPackageError, RetryOpts, TempDir, ThrottledClient, assert_eq, default_opts,
|
||||
get_pkg_mirror_path, load_meta, persist_meta_to_mirror, pick_package, range_spec,
|
||||
shared_packument_fetch_locker,
|
||||
};
|
||||
|
||||
/// Damages the fragment of `acme@1.1.0` in the mirror `persist_meta_to_mirror`
|
||||
/// wrote, leaving the file length and the index spans intact so only the
|
||||
/// fragment's own bytes fail to parse. Returns the damaged file's bytes.
|
||||
fn damage_mirror_fragment(cache_dir: &Path, registry: &str) -> Vec<u8> {
|
||||
let path = get_pkg_mirror_path(cache_dir, ABBREVIATED_META_DIR, registry, "acme")
|
||||
.expect("mirror path");
|
||||
let mut bytes = std::fs::read(&path).expect("read mirror");
|
||||
// An unescaped quote inside 1.1.0's integrity string: still the same
|
||||
// number of bytes, no longer parsable JSON.
|
||||
let marker = b"sha512-BBBB";
|
||||
let at = bytes
|
||||
.windows(marker.len())
|
||||
.position(|window| window == marker)
|
||||
.expect("1.1.0 fragment in the mirror");
|
||||
bytes[at + 3] = b'"';
|
||||
std::fs::write(&path, &bytes).expect("write damaged mirror");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Offline, a damaged fragment leaves the mirror as unusable as a missing
|
||||
/// one — and there is no network to replace it from.
|
||||
#[tokio::test]
|
||||
async fn offline_over_a_damaged_mirror_fragment_errors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/acme")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let preloaded: pnpm_registry::Package =
|
||||
serde_json::from_str(PACKAGE_BODY).expect("parse packument");
|
||||
persist_meta_to_mirror(cache_dir.path(), ABBREVIATED_META_DIR, ®istry, &preloaded)
|
||||
.expect("warm mirror");
|
||||
damage_mirror_fragment(cache_dir.path(), ®istry);
|
||||
|
||||
let http_client = ThrottledClient::default();
|
||||
let auth_headers = AuthHeaders::default();
|
||||
let meta_cache = InMemoryPackageMetaCache::default();
|
||||
let fetch_locker = shared_packument_fetch_locker();
|
||||
let ctx = PickPackageContext {
|
||||
full_metadata: false,
|
||||
needs_full_metadata_for: None,
|
||||
filter_metadata: false,
|
||||
cache_policy: crate::MetadataCachePolicy {
|
||||
offline: true,
|
||||
prefer_offline: false,
|
||||
ignore_missing_time_field: false,
|
||||
},
|
||||
store_view: None,
|
||||
metadata: crate::MetadataRequestContext {
|
||||
meta_cache: &meta_cache,
|
||||
fetch_locker: &fetch_locker,
|
||||
cache_dir: Some(cache_dir.path()),
|
||||
http: crate::MetadataHttpClient {
|
||||
http_client: &http_client,
|
||||
auth_headers: &auth_headers,
|
||||
retry_opts: RetryOpts::default(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let err = pick_package(&ctx, &range_spec("acme", "^1.0.0"), &default_opts(®istry))
|
||||
.await
|
||||
.expect_err("offline + damaged mirror = error");
|
||||
assert!(matches!(err, PickPackageError::NoOfflineMeta { .. }), "got {err:?}");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// The etag lives in the mirror's intact headers record, so a conditional
|
||||
/// request keeps answering `304` over a damaged body. Online, the resolver
|
||||
/// has to ask again with the cache bypassed and rewrite the file.
|
||||
#[tokio::test]
|
||||
async fn a_damaged_fragment_behind_a_304_is_replaced_by_a_bypassing_refetch() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let conditional = server
|
||||
.mock("GET", "/acme")
|
||||
.match_header("if-none-match", r#"W/"cached""#)
|
||||
.match_header("cache-control", mockito::Matcher::Missing)
|
||||
.with_status(304)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let bypassing = server
|
||||
.mock("GET", "/acme")
|
||||
.match_header("if-none-match", mockito::Matcher::Missing)
|
||||
.match_header("cache-control", "no-cache")
|
||||
.with_status(200)
|
||||
.with_header("etag", r#"W/"fresh""#)
|
||||
.with_body(PACKAGE_BODY)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let mut preloaded: pnpm_registry::Package =
|
||||
serde_json::from_str(PACKAGE_BODY).expect("parse packument");
|
||||
preloaded.etag = Some(r#"W/"cached""#.to_string());
|
||||
persist_meta_to_mirror(cache_dir.path(), ABBREVIATED_META_DIR, ®istry, &preloaded)
|
||||
.expect("warm mirror");
|
||||
let damaged = damage_mirror_fragment(cache_dir.path(), ®istry);
|
||||
|
||||
let http_client = ThrottledClient::default();
|
||||
let auth_headers = AuthHeaders::default();
|
||||
let meta_cache = InMemoryPackageMetaCache::default();
|
||||
let fetch_locker = shared_packument_fetch_locker();
|
||||
let ctx = PickPackageContext {
|
||||
full_metadata: false,
|
||||
needs_full_metadata_for: None,
|
||||
filter_metadata: false,
|
||||
cache_policy: crate::MetadataCachePolicy {
|
||||
offline: false,
|
||||
prefer_offline: false,
|
||||
ignore_missing_time_field: false,
|
||||
},
|
||||
store_view: None,
|
||||
metadata: crate::MetadataRequestContext {
|
||||
meta_cache: &meta_cache,
|
||||
fetch_locker: &fetch_locker,
|
||||
cache_dir: Some(cache_dir.path()),
|
||||
http: crate::MetadataHttpClient {
|
||||
http_client: &http_client,
|
||||
auth_headers: &auth_headers,
|
||||
retry_opts: RetryOpts::default(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = pick_package(&ctx, &range_spec("acme", "^1.0.0"), &default_opts(®istry))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(result.picked_package.expect("picked").version.to_string(), "1.1.0");
|
||||
conditional.assert_async().await;
|
||||
bypassing.assert_async().await;
|
||||
|
||||
let path = get_pkg_mirror_path(cache_dir.path(), ABBREVIATED_META_DIR, ®istry, "acme")
|
||||
.expect("mirror path");
|
||||
let healed = std::fs::read(&path).expect("read mirror");
|
||||
assert_ne!(healed, damaged, "the bypassing refetch rewrites the damaged mirror");
|
||||
let reloaded = load_meta(&path).expect("reload the healed mirror");
|
||||
assert!(reloaded.versions.get("1.1.0").is_some());
|
||||
assert!(!reloaded.versions.has_corrupt_mirror_fragment());
|
||||
}
|
||||
|
||||
const UNPARSABLE_VERSION_PACKAGE_BODY: &str = r#"{
|
||||
"name": "acme",
|
||||
"dist-tags": { "latest": "1.1.0" },
|
||||
"modified": "2025-01-15T12:00:00.000Z",
|
||||
"time": {
|
||||
"1.0.0": "2024-01-10T08:30:00.000Z",
|
||||
"1.1.0": "2024-12-10T08:30:00.000Z"
|
||||
},
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"name": "acme",
|
||||
"version": "1.0.0",
|
||||
"dist": {
|
||||
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"shasum": "0000000000000000000000000000000000000000",
|
||||
"tarball": "https://registry/acme-1.0.0.tgz"
|
||||
}
|
||||
},
|
||||
"1.1.0": {
|
||||
"name": "acme",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// The mirror stores the registry's fragment verbatim, so a version the
|
||||
/// registry served in the wrong shape is skipped as it is off the network,
|
||||
/// not taken for a damaged mirror that a refetch could repair.
|
||||
#[tokio::test]
|
||||
async fn a_registry_version_of_the_wrong_shape_is_skipped_without_a_refetch() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/acme")
|
||||
.with_status(200)
|
||||
.with_header("etag", r#"W/"fresh""#)
|
||||
.with_body(UNPARSABLE_VERSION_PACKAGE_BODY)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let cache_dir = TempDir::new().expect("tempdir");
|
||||
let registry = format!("{}/", server.url());
|
||||
let http_client = ThrottledClient::default();
|
||||
let auth_headers = AuthHeaders::default();
|
||||
let meta_cache = InMemoryPackageMetaCache::default();
|
||||
let fetch_locker = shared_packument_fetch_locker();
|
||||
let ctx = PickPackageContext {
|
||||
full_metadata: false,
|
||||
needs_full_metadata_for: None,
|
||||
filter_metadata: false,
|
||||
cache_policy: crate::MetadataCachePolicy {
|
||||
offline: false,
|
||||
prefer_offline: false,
|
||||
ignore_missing_time_field: false,
|
||||
},
|
||||
store_view: None,
|
||||
metadata: crate::MetadataRequestContext {
|
||||
meta_cache: &meta_cache,
|
||||
fetch_locker: &fetch_locker,
|
||||
cache_dir: Some(cache_dir.path()),
|
||||
http: crate::MetadataHttpClient {
|
||||
http_client: &http_client,
|
||||
auth_headers: &auth_headers,
|
||||
retry_opts: RetryOpts::default(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = pick_package(&ctx, &range_spec("acme", "^1.0.0"), &default_opts(®istry))
|
||||
.await
|
||||
.expect("the well-formed versions stay pickable");
|
||||
assert_eq!(result.picked_package.expect("picked").version.to_string(), "1.0.0");
|
||||
assert!(!result.meta.versions.has_corrupt_mirror_fragment());
|
||||
mock.assert_async().await;
|
||||
}
|
||||
@@ -17,11 +17,14 @@ Deletes the registry metadata cache directories that this version of pnpm can no
|
||||
pnpm cache prune
|
||||
```
|
||||
|
||||
pnpm v12.4.0 changed the metadata cache directory name of every registry, so the first install after upgrading refetched metadata into a new directory and left the old one in place. Nothing reads it, and no other subcommand removes it, because [`cache list`](./cache-list.md), [`cache view`](./cache-view.md) and [`cache delete`](./cache-delete.md) all search under the configured registry's current directory name. `cache prune` goes through all three metadata roots, `v11/metadata`, `v11/metadata-full` and `v11/metadata-full-filtered`, and removes the directories whose names this version cannot read.
|
||||
pnpm v12.4.0 changed the metadata cache directory name of every registry, so the first install after upgrading refetched metadata into a new directory and left the old one in place. Nothing reads it, and no other subcommand removes it, because [`cache list`](./cache-list.md), [`cache view`](./cache-view.md) and [`cache delete`](./cache-delete.md) all search under the configured registry's current directory name. `cache prune` goes through all three metadata roots, `v12/metadata`, `v12/metadata-full` and `v12/metadata-full-filtered`, and removes the directories whose names this version cannot read.
|
||||
|
||||
A later version moved the metadata cache from `v11/` to `v12/`, because the cache file format changed. `cache prune` removes every directory under `v11/metadata`, `v11/metadata-full`, `v11/metadata-full-filtered` and `v11/metadata-private`.
|
||||
|
||||
Each removed directory is printed as the metadata root followed by the directory name:
|
||||
|
||||
```
|
||||
v11/metadata/https%3A+registry.npmjs.org
|
||||
v11/metadata/registry.npmjs.org
|
||||
v11/metadata-full/npm.example.com
|
||||
```
|
||||
@@ -42,7 +45,7 @@ The list on stdout is the same as a real prune's, so you can diff the two runs o
|
||||
|
||||
:::warning
|
||||
|
||||
The cache directory is shared by every pnpm on the machine, and the directory naming change shipped in v11.27.0 as well as v12.4.0. If pnpm v11.26 or earlier runs on the same machine under the same [`cacheDir`](../settings/other.md#cachedir), it still reads and writes the directories that prune removes, and pruning from v12 costs that CLI one metadata refetch. Only the cache is affected, and it refills itself. Use `--dry-run` to see the list before removing anything.
|
||||
The cache directory is shared by every pnpm on the machine. If an older pnpm runs on the same machine under the same [`cacheDir`](../settings/other.md#cachedir), it may still read and write the directories that prune removes, and pruning costs that CLI one metadata refetch. Only the cache is affected, and it refills itself. Use `--dry-run` to see the list before removing anything.
|
||||
|
||||
:::
|
||||
|
||||
@@ -50,6 +53,6 @@ The cache directory is shared by every pnpm on the machine, and the directory na
|
||||
|
||||
Prune removes only the directories it can prove are dead, so a few things that look reclaimable stay where they are.
|
||||
|
||||
- The descriptor-scoped caches under `v11/metadata-private`, which every `pnpm cache` subcommand leaves alone.
|
||||
- The descriptor-scoped caches under `v12/metadata-private`, which every `pnpm cache` subcommand leaves alone. The ones under `v11/metadata-private` are removed.
|
||||
- Directories whose names are in the current shape, including the ones that pile up from registries on an ephemeral port, such as a local test registry. Removing those needs a retention policy, an age cutoff for example, rather than a rule about the name.
|
||||
- A 64-character hexadecimal directory name. A registry URL too long to fit a directory name becomes a bare sha256 hash of itself, and a live cache under such a name cannot be told apart from a stale one, so prune keeps it.
|
||||
@@ -24,7 +24,7 @@ use std::collections::BTreeMap;
|
||||
pub struct MinimalWorkspaceManifest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store_dir: Option<String>,
|
||||
/// Packument-metadata cache location (`<cacheDir>/v11/metadata-full`).
|
||||
/// Packument-metadata cache location (`<cacheDir>/v12/metadata-full`).
|
||||
/// The benchmark forces this bench-local (`./cache-dir`) for the same
|
||||
/// reason it forces `storeDir` local: so the cold-cache scenarios can
|
||||
/// wipe it per-iteration. Left at the global default it would survive
|
||||
|
||||
@@ -86,7 +86,7 @@ fn synthetic_versions(file_backed: bool) -> PackageVersions {
|
||||
fragments.push((version, RawValue::from_string(json).unwrap()));
|
||||
}
|
||||
if !file_backed {
|
||||
return PackageVersions::from_raw_fragments(fragments);
|
||||
return PackageVersions::from_buffered_mirror_fragments(fragments);
|
||||
}
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
file.write_all(&bytes).unwrap();
|
||||
|
||||
Vendored
+14
-18
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { decodeRegistry, encodeRegistry, type PackageMeta } from '@pnpm/resolving.npm-resolver'
|
||||
import { decodeRegistry, encodeRegistry, loadMeta, type PackageMeta } from '@pnpm/resolving.npm-resolver'
|
||||
import { StoreIndex, storeIndexKey } from '@pnpm/store.index'
|
||||
import { glob } from 'tinyglobby'
|
||||
|
||||
@@ -13,7 +13,7 @@ interface CachedVersions {
|
||||
}
|
||||
|
||||
interface CachedMetaFile {
|
||||
metaObject: PackageMeta | null
|
||||
metaObject: PackageMeta
|
||||
mtime: Date
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ export async function cacheView (opts: { cacheDir: string, storeDir: string, reg
|
||||
const storeIndex = new StoreIndex(opts.storeDir)
|
||||
try {
|
||||
for (const filePath of metaFilePaths) {
|
||||
const metaFile = readCachedMetaFile(path.join(opts.cacheDir, filePath))
|
||||
if (!metaFile?.metaObject) continue
|
||||
// eslint-disable-next-line no-await-in-loop -- sequential, so only one packument is held in memory at a time
|
||||
const metaFile = await readCachedMetaFile(path.join(opts.cacheDir, filePath))
|
||||
if (metaFile == null) continue
|
||||
const { metaObject, mtime } = metaFile
|
||||
metaFilesByPath[decodeRegistry(getTopLevelDir(filePath))] = {
|
||||
...partitionVersionsByStorePresence(storeIndex, metaObject),
|
||||
@@ -42,25 +43,19 @@ export async function cacheView (opts: { cacheDir: string, storeDir: string, reg
|
||||
return JSON.stringify(metaFilesByPath, null, 2)
|
||||
}
|
||||
|
||||
function readCachedMetaFile (fullPath: string): CachedMetaFile | undefined {
|
||||
async function readCachedMetaFile (fullPath: string): Promise<CachedMetaFile | undefined> {
|
||||
// Every version is read, so hydrate up front: the loader then reports a
|
||||
// damaged mirror as a miss instead of throwing partway through, and the
|
||||
// file is skipped like an unreadable one.
|
||||
const metaObject = await loadMeta(fullPath, { hydrateEagerly: true })
|
||||
if (metaObject == null) return undefined
|
||||
try {
|
||||
const raw = fs.readFileSync(fullPath, 'utf8')
|
||||
const mtime = fs.statSync(fullPath).mtime
|
||||
return { metaObject: parseCachedMeta(raw), mtime }
|
||||
return { metaObject, mtime: fs.statSync(fullPath).mtime }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseCachedMeta (raw: string): PackageMeta | null {
|
||||
const newlineIdx = raw.indexOf('\n')
|
||||
if (newlineIdx !== -1) {
|
||||
// NDJSON format: line 1 = headers, line 2 = metadata
|
||||
return JSON.parse(raw.slice(newlineIdx + 1)) as PackageMeta
|
||||
}
|
||||
return JSON.parse(raw) as PackageMeta
|
||||
}
|
||||
|
||||
function partitionVersionsByStorePresence (
|
||||
storeIndex: StoreIndex,
|
||||
metaObject: PackageMeta
|
||||
@@ -68,7 +63,8 @@ function partitionVersionsByStorePresence (
|
||||
const cachedVersions: string[] = []
|
||||
const nonCachedVersions: string[] = []
|
||||
for (const [version, manifest] of Object.entries(metaObject.versions)) {
|
||||
if (!manifest.dist.integrity) continue
|
||||
// A version without a manifest (a fragment of the wrong shape) is skipped.
|
||||
if (!manifest?.dist?.integrity) continue
|
||||
const key = storeIndexKey(manifest.dist.integrity, `${manifest.name}@${manifest.version}`)
|
||||
if (storeIndex.has(key)) {
|
||||
cachedVersions.push(version)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { beforeEach, describe, expect, test } from '@jest/globals'
|
||||
import { cache } from '@pnpm/cache.commands'
|
||||
import { ABBREVIATED_META_DIR } from '@pnpm/constants'
|
||||
import { prepare } from '@pnpm/prepare'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
|
||||
import { rimrafSync } from '@zkochan/rimraf'
|
||||
@@ -93,6 +95,54 @@ describe('cache view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A damaged file is what the resolver refuses to read, so listing the
|
||||
// versions around the damage would report a cache no install will use.
|
||||
test('omits a package whose cache file is damaged', async () => {
|
||||
const mirror = path.join(cacheDir, ABBREVIATED_META_DIR, 'https%3A+registry.npmjs.org', 'is-negative.jsonl')
|
||||
const bytes = fs.readFileSync(mirror)
|
||||
const at = bytes.indexOf(Buffer.from('"integrity"'))
|
||||
expect(at).toBeGreaterThan(-1)
|
||||
// Same byte count, so the index spans still address the fragment, but
|
||||
// its own bytes no longer parse.
|
||||
bytes.fill('x', at, at + 11)
|
||||
fs.writeFileSync(mirror, bytes)
|
||||
|
||||
const result = await cache.handler({
|
||||
cacheDir,
|
||||
cliOptions: {},
|
||||
pnpmHomeDir: process.cwd(),
|
||||
storeDir,
|
||||
}, ['view', 'is-negative'])
|
||||
|
||||
expect(JSON.parse(result!)['https://registry.npmjs.org/']).toBeUndefined()
|
||||
})
|
||||
|
||||
// The registry served that version in the wrong shape, which the resolver
|
||||
// reads as a version without a manifest rather than as a damaged file.
|
||||
test('skips a version whose cached manifest has the wrong shape', async () => {
|
||||
const mirror = path.join(cacheDir, ABBREVIATED_META_DIR, 'https%3A+registry.npmjs.org', 'is-negative.jsonl')
|
||||
const bytes = fs.readFileSync(mirror)
|
||||
const lineEnd = bytes.indexOf(10)
|
||||
const [, headersLen, indexLen] = bytes.toString('utf8', 0, lineEnd).split(' ').map(Number)
|
||||
const indexStart = lineEnd + 1 + headersLen
|
||||
const index = JSON.parse(bytes.toString('utf8', indexStart, indexStart + indexLen)) as { versions: Array<[string, number, number]> }
|
||||
const [version, offset, length] = index.versions.find(([candidate]) => candidate === '2.0.2')!
|
||||
bytes.write(`[${' '.repeat(length - 2)}]`, indexStart + indexLen + offset)
|
||||
fs.writeFileSync(mirror, bytes)
|
||||
|
||||
const result = await cache.handler({
|
||||
cacheDir,
|
||||
cliOptions: {},
|
||||
pnpmHomeDir: process.cwd(),
|
||||
storeDir,
|
||||
}, ['view', 'is-negative'])
|
||||
|
||||
const view = JSON.parse(result!)['https://registry.npmjs.org/']
|
||||
expect(view.cachedVersions).not.toContain(version)
|
||||
expect(view.nonCachedVersions).not.toContain(version)
|
||||
expect(view.cachedVersions.length + view.nonCachedVersions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('lists all metadata for requested package should specify a package name', async () => {
|
||||
await expect(
|
||||
cache.handler({
|
||||
|
||||
@@ -35,8 +35,11 @@ export const BUILTIN_REGISTRIES_BY_PREFIX: Readonly<Record<string, string>> = Ob
|
||||
// about all the packages published by the same name, not just the manifest
|
||||
// of one package/version
|
||||
//
|
||||
// Cache files use NDJSON format: line 1 is cache headers (etag, modified),
|
||||
// line 2 is the registry metadata JSON.
|
||||
export const ABBREVIATED_META_DIR = 'v11/metadata'
|
||||
export const FULL_META_DIR = 'v11/metadata-full'
|
||||
export const FULL_FILTERED_META_DIR = 'v11/metadata-full-filtered'
|
||||
// The version prefix is bumped whenever the file format changes, so a pnpm
|
||||
// version that predates the change never reads files it cannot parse (and
|
||||
// never overwrites them with the format it knows). The format is defined in
|
||||
// `resolving/npm-resolver`'s mirrorLayout module.
|
||||
export const ABBREVIATED_META_DIR = 'v12/metadata'
|
||||
export const FULL_META_DIR = 'v12/metadata-full'
|
||||
export const FULL_FILTERED_META_DIR = 'v12/metadata-full-filtered'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises'
|
||||
import util from 'node:util'
|
||||
|
||||
import { ABBREVIATED_META_DIR } from '@pnpm/constants'
|
||||
import { loadMeta } from '@pnpm/resolving.npm-resolver'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
|
||||
import type { PackageManifest } from '@pnpm/types'
|
||||
|
||||
@@ -31,7 +32,12 @@ export async function readMetadataMirror (cacheDir: string, pkgName: string): Pr
|
||||
await delay(100)
|
||||
continue
|
||||
}
|
||||
return { raw, meta: JSON.parse(raw.slice(raw.indexOf('\n') + 1)) }
|
||||
const meta = await loadMeta(mirrorPath)
|
||||
if (meta == null) {
|
||||
await delay(100)
|
||||
continue
|
||||
}
|
||||
return { raw, meta: meta as unknown as { versions: Record<string, PackageManifest> } }
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
throw lastError
|
||||
|
||||
@@ -77,11 +77,21 @@ export function clearMeta (pkg: PackageMeta): PackageMeta {
|
||||
return condensed
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a document as already condensed, so {@link clearMeta} returns it
|
||||
* unchanged. A lazily-loaded indexed mirror (see `mirrorLayout.ts`) condenses each
|
||||
* version manifest as it is hydrated; running `clearMeta` over it would parse
|
||||
* every version just to rebuild what the getters already provide.
|
||||
*/
|
||||
export function markMetaCondensed (meta: PackageMeta): void {
|
||||
condensedPackuments.set(meta, meta)
|
||||
}
|
||||
|
||||
// Hand-rolled rather than delegated to a generic field picker because it runs
|
||||
// for every version of every packument an install parses. Testing the value
|
||||
// for `undefined` is equivalent to testing for the key's presence: version
|
||||
// objects come from JSON, which cannot encode undefined.
|
||||
function pickAbbreviatedVersionFields (info: PackageInRegistry): PackageInRegistry {
|
||||
export function pickAbbreviatedVersionFields (info: PackageInRegistry): PackageInRegistry {
|
||||
const picked: Partial<Record<keyof PackageInRegistry, unknown>> = {}
|
||||
for (const field of ABBREVIATED_VERSION_FIELDS) {
|
||||
const value = info[field]
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type VersionSelectorType,
|
||||
} from '@pnpm/resolving.resolver-base'
|
||||
|
||||
import { hasVersionManifest } from './mirrorLayout.js'
|
||||
import { semverSatisfiesLoose } from './semverLoose.js'
|
||||
|
||||
/**
|
||||
@@ -19,7 +20,7 @@ export function cachedMetaMissesPreferredVersion (
|
||||
if (preferredVersionSelectors == null) return false
|
||||
for (const [selector, value] of Object.entries(preferredVersionSelectors)) {
|
||||
if (!isExactVersionSelectorInRange(selector, value, versionRange)) continue
|
||||
if (meta.versions[selector] == null) return true
|
||||
if (!hasVersionManifest(meta.versions, selector)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
type FetchMetadataFromFromRegistryOptions,
|
||||
type FetchMetadataResult,
|
||||
} from './fetch.js'
|
||||
import { fullEtagOfAbbreviatedMirror, holdsFullMetaInAbbreviatedMirror, mirrorEtags } from './metaMirror.js'
|
||||
import { encodeMirror, fullEtagOfAbbreviatedMirror, holdsFullMetaInAbbreviatedMirror, mirrorEtags } from './metaMirror.js'
|
||||
import {
|
||||
discardMirrorAfterFailedUncacheableWrite,
|
||||
getPkgMirrorPath,
|
||||
legacyMirrorHint,
|
||||
loadMeta,
|
||||
loadMetaHeaders,
|
||||
prepareJsonForDisk,
|
||||
saveMeta,
|
||||
} from './pickPackage.js'
|
||||
|
||||
@@ -96,11 +95,12 @@ async function fetchMetadataCached (
|
||||
// requires cache headers loaded from a mirror — so a null mirror here is an
|
||||
// unreachable invariant breach.
|
||||
if (pkgMirror == null) throw new Error(`Unexpected 304 for ${pkgName} without a metadata cache`)
|
||||
const cached = await loadMeta(pkgMirror)
|
||||
const cached = await loadMeta(pkgMirror, { hydrateEagerly: true })
|
||||
if (cached != null) return cached
|
||||
|
||||
// The mirror vanished between the headers read and this read (concurrent
|
||||
// store cleanup, antivirus, ...), so the 304 now validates nothing. Ask again
|
||||
// Either the mirror vanished between the headers read and this read
|
||||
// (concurrent store cleanup, antivirus, ...) or a version fragment in it is
|
||||
// corrupt, so the 304 now validates nothing. Ask again
|
||||
// as a cold cache would, which the registry can only answer with a body or an
|
||||
// error — never another 304.
|
||||
return persistFetchedMeta(pkgMirror, await refetchBypassingCache(fetchOpts, pkgName, opts), opts.fullMetadata)
|
||||
@@ -112,7 +112,7 @@ async function loadOfflineMeta (
|
||||
opts: MetadataCacheRequest
|
||||
): Promise<PackageMeta> {
|
||||
if (pkgMirror != null) {
|
||||
const cached = await loadMeta(pkgMirror)
|
||||
const cached = await loadMeta(pkgMirror, { hydrateEagerly: true })
|
||||
if (cached != null) return cached
|
||||
}
|
||||
throw new PnpmError('NO_OFFLINE_META', `Failed to resolve ${pkgName} in package mirror ${pkgMirror ?? ''}`, {
|
||||
@@ -144,10 +144,12 @@ async function refetchBypassingCache (
|
||||
function persistFetchedMeta (pkgMirror: string | null, fetched: FetchMetadataResult, mirrorFullMetadata: boolean): PackageMeta {
|
||||
if (pkgMirror != null) {
|
||||
const { etag, fullEtag } = mirrorEtags(fetched, mirrorFullMetadata)
|
||||
const jsonForDisk = holdsFullMetaInAbbreviatedMirror(fetched, mirrorFullMetadata)
|
||||
? prepareJsonForDisk(clearMeta(fetched.meta), etag, { uncacheable: fetched.uncacheable, fullEtag })
|
||||
: prepareJsonForDisk(fetched.meta, etag, fetched)
|
||||
saveMeta(pkgMirror, jsonForDisk).catch(() => {
|
||||
const content = encodeMirror({}, fetched, {
|
||||
meta: holdsFullMetaInAbbreviatedMirror(fetched, mirrorFullMetadata) ? clearMeta(fetched.meta) : fetched.meta,
|
||||
etag,
|
||||
body: { uncacheable: fetched.uncacheable, fullEtag },
|
||||
})
|
||||
saveMeta(pkgMirror, content).catch(() => {
|
||||
return discardMirrorAfterFailedUncacheableWrite(pkgMirror, fetched.uncacheable === true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export {
|
||||
export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js'
|
||||
export { decodeRegistry, encodeRegistry } from './encodeRegistry.js'
|
||||
export { formatTimeAgo } from './formatTimeAgo.js'
|
||||
export { loadMeta, type LoadMetaOptions } from './metaMirror.js'
|
||||
export { NoMatchingVersionError, type NoMatchingVersionErrorOptions } from './NoMatchingVersionError.js'
|
||||
export { detectMinReleaseAgeViolation } from './releaseAgePolicy.js'
|
||||
export type {
|
||||
|
||||
@@ -29,14 +29,9 @@ export interface MemoizeFetchMetadataOptions {
|
||||
* requests for the same package.
|
||||
*
|
||||
* Unlike plain memoization, the entry is swapped for a body-less clone once
|
||||
* the request settles. `jsonText` — the raw registry response body, up to tens
|
||||
* of MB for a popular package — reaches every caller sharing the in-flight
|
||||
* request, so a package resolved by many workspace projects at once mirrors
|
||||
* that one body to disk instead of each project separately re-serializing
|
||||
* `meta`. Retaining bodies past settlement would pin hundreds of MB on large
|
||||
* cold-cache graphs, so a later cache-hit caller that writes the mirror falls
|
||||
* back to `JSON.stringify(meta)` in `prepareJsonForDisk`, which is equivalent
|
||||
* on read: `loadMeta` re-derives `etag` from the headers line.
|
||||
* the request settles: `jsonText` is the raw registry response body, up to
|
||||
* tens of MB for a popular package, and retaining one per package would pin
|
||||
* hundreds of MB on large cold-cache graphs.
|
||||
*
|
||||
* Because that swap lands a turn after the request settles, both settlement
|
||||
* paths write back only while the entry is still their own promise — a `clear`
|
||||
|
||||
@@ -2,16 +2,32 @@ import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { createHexHash } from '@pnpm/crypto.hash'
|
||||
import gfs from '@pnpm/fs.graceful-fs'
|
||||
import { logger } from '@pnpm/logger'
|
||||
import type { PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
import pLimit, { type LimitFunction } from 'p-limit'
|
||||
import { fastPathTemp as pathTemp } from 'path-temp'
|
||||
import { renameOverwrite } from 'rename-overwrite'
|
||||
|
||||
import { clearMeta, retainsFullMeta } from './clearMeta.js'
|
||||
import { encodeRegistry } from './encodeRegistry.js'
|
||||
import { dropIncompletePublishTimes } from './publishTimes.js'
|
||||
import type { FetchMetadataResult } from './fetch.js'
|
||||
import {
|
||||
type MetaHeaders,
|
||||
type MirrorHeadersBody,
|
||||
prepareIndexedForDisk,
|
||||
prepareJsonForDisk,
|
||||
saveMeta,
|
||||
} from './mirrorLayout.js'
|
||||
|
||||
export {
|
||||
hasVersionManifest,
|
||||
isMalformedMirrorFragmentError,
|
||||
loadMeta,
|
||||
loadMetaHeaders,
|
||||
type LoadMetaOptions,
|
||||
type MetaHeaders,
|
||||
prepareIndexedForDisk,
|
||||
prepareJsonForDisk,
|
||||
saveMeta,
|
||||
} from './mirrorLayout.js'
|
||||
|
||||
interface RefCountedLimiter {
|
||||
count: number
|
||||
@@ -63,10 +79,10 @@ export function condenseMetaForCache (
|
||||
* The mirror is an optimization, so a write failure only gets a debug log
|
||||
* with the mirror path and the install continues.
|
||||
*/
|
||||
export function saveMetaBestEffort (pkgMirror: string, json: string, uncacheable = false): void {
|
||||
export function saveMetaBestEffort (pkgMirror: string, content: string | Buffer, uncacheable = false): void {
|
||||
void runLimited(pkgMirror, (limit) => limit(async () => {
|
||||
try {
|
||||
await saveMeta(pkgMirror, json)
|
||||
await saveMeta(pkgMirror, content)
|
||||
} catch (err: unknown) {
|
||||
logger.debug({ message: `Failed to write the package metadata mirror at ${pkgMirror}`, err })
|
||||
await discardMirrorAfterFailedUncacheableWrite(pkgMirror, uncacheable)
|
||||
@@ -83,6 +99,43 @@ export async function discardMirrorAfterFailedUncacheableWrite (pkgMirror: strin
|
||||
await fs.rm(pkgMirror, { force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every project of a workspace that joins one in-flight fetch mirrors its
|
||||
* result, so the encoded form is memoized on the result object. Encoding it
|
||||
* per project would hold as many copies of a body reaching tens of MB as
|
||||
* there are projects. Keyed on the result rather than its `meta`, so the copy
|
||||
* is released with the shared body: `memoizeFetchMetadata` drops the result
|
||||
* once the request settles.
|
||||
*/
|
||||
const encodedMirrors = new WeakMap<FetchMetadataResult, Map<string, string | Buffer>>()
|
||||
|
||||
/**
|
||||
* The mirror form of `meta`, a document fetched as `result`. A
|
||||
* `filterMetadata` resolver mirrors the stripped NDJSON form, because that
|
||||
* mirror only serves equally stripped resolutions. Everything else uses the
|
||||
* indexed layout, whose per-version spans later loads hydrate lazily.
|
||||
*/
|
||||
export function encodeMirror (
|
||||
ctx: { filterMetadata?: boolean },
|
||||
result: FetchMetadataResult,
|
||||
{ meta, etag, body }: { meta: PackageMeta, etag: string | undefined, body: MirrorHeadersBody }
|
||||
): string | Buffer {
|
||||
let encoded = encodedMirrors.get(result)
|
||||
if (encoded == null) {
|
||||
encoded = new Map()
|
||||
encodedMirrors.set(result, encoded)
|
||||
}
|
||||
const key = JSON.stringify([ctx.filterMetadata === true, meta === result.meta, etag, body.fullEtag, body.uncacheable === true])
|
||||
let content = encoded.get(key)
|
||||
if (content == null) {
|
||||
content = ctx.filterMetadata === true
|
||||
? prepareJsonForDisk(meta, etag, body)
|
||||
: prepareIndexedForDisk(meta, etag, body)
|
||||
encoded.set(key, content)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
export function encodePkgName (pkgName: string): string {
|
||||
if (pkgName !== pkgName.toLowerCase()) {
|
||||
return `${pkgName}_${createHexHash(pkgName)}`
|
||||
@@ -137,74 +190,51 @@ export function getPkgMirrorPath (cacheDir: string, metaDir: string, registry: s
|
||||
|
||||
/**
|
||||
* Hint for `NO_OFFLINE_META`: whether the package's metadata sits on disk
|
||||
* under the legacy mirror path, which this pnpm version no longer reads.
|
||||
* under a legacy mirror path, which this pnpm version no longer reads.
|
||||
* `undefined` when no such mirror exists, so the base error message stands
|
||||
* on its own.
|
||||
*/
|
||||
export async function legacyMirrorHint (cacheDir: string, metaDir: string, registry: string, pkgName: string): Promise<string | undefined> {
|
||||
const legacyMirror = getLegacyPkgMirrorPath(cacheDir, metaDir, registry, pkgName)
|
||||
if (legacyMirror == null) return undefined
|
||||
try {
|
||||
await fs.access(legacyMirror)
|
||||
} catch {
|
||||
return undefined
|
||||
for (const legacyMirror of getLegacyPkgMirrorPaths(cacheDir, metaDir, registry, pkgName)) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop -- the candidates are probed in order and the first hit wins
|
||||
await fs.access(legacyMirror)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
return `The cache layout for registry metadata has changed. ${legacyMirror} holds a mirror ` +
|
||||
'from an older pnpm version, which this offline install cannot read. Run one online install to repopulate ' +
|
||||
'the cache under the new layout, then retry offline.'
|
||||
}
|
||||
return `The cache layout for registry metadata changed in pnpm 11.27 and 12.4. ${legacyMirror} holds a mirror ` +
|
||||
'from an older pnpm version, which this offline install cannot read. Run one online install to repopulate ' +
|
||||
'the cache under the new layout, then retry offline.'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy mirror path for a registry: `<host>[:<port>]` with `:`
|
||||
* replaced by `+`, and no scheme, path segments, or hash suffix.
|
||||
* `null` for a registry URL {@link getPkgMirrorPath} would itself reject.
|
||||
* The version directory of the NDJSON-era cache layout. The indexed layout
|
||||
* moved every mirror to a new version directory, so nothing reads it.
|
||||
*/
|
||||
function getLegacyPkgMirrorPath (cacheDir: string, metaDir: string, registry: string, pkgName: string): string | null {
|
||||
const LEGACY_META_VERSION_DIR = 'v11'
|
||||
|
||||
/**
|
||||
* Where an older pnpm may have mirrored the package for `metaDir`: the
|
||||
* legacy version directory, under the current registry key and under the
|
||||
* legacy one (`<host>[:<port>]` with `:` replaced by `+`, and no scheme, path
|
||||
* segments, or hash suffix).
|
||||
*/
|
||||
function getLegacyPkgMirrorPaths (cacheDir: string, metaDir: string, registry: string, pkgName: string): string[] {
|
||||
const legacyMetaDir = path.join(LEGACY_META_VERSION_DIR, path.basename(metaDir))
|
||||
const fileName = `${encodePkgName(pkgName)}.jsonl`
|
||||
const paths = [path.join(cacheDir, legacyMetaDir, encodeRegistry(registry), fileName)]
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(registry)
|
||||
} catch {
|
||||
return null
|
||||
return paths
|
||||
}
|
||||
if (url.host === '') return null
|
||||
return path.join(cacheDir, metaDir, url.host.replace(':', '+'), `${encodePkgName(pkgName)}.jsonl`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats metadata for disk storage as two-line NDJSON:
|
||||
* Line 1: cache headers (etag, fullEtag, modified) — small, fast to read
|
||||
* Line 2: the registry metadata JSON
|
||||
*
|
||||
* The ETags live only in the headers line (`loadMeta` re-attaches them from
|
||||
* there), so a `meta` that carries one is serialized without it.
|
||||
*
|
||||
* An ETag identifies one representation. `etag` tags the representation the
|
||||
* mirror's directory holds. `body.fullEtag` tags a full document that the
|
||||
* release-age upgrade stored in the abbreviated mirror (see
|
||||
* {@link mirrorEtags}). `modified` is always written: it comes from the
|
||||
* packument's own `time.modified`, which both representations report
|
||||
* identically.
|
||||
*
|
||||
* `body.jsonText` is the raw registry body, written as is when given.
|
||||
* `body.uncacheable` records that the response forbade caching, so the next
|
||||
* online lookup refetches instead of revalidating.
|
||||
*/
|
||||
export function prepareJsonForDisk (
|
||||
meta: PackageMeta,
|
||||
etag: string | undefined,
|
||||
body: { jsonText?: string, uncacheable?: boolean, fullEtag?: string } = {}
|
||||
): string {
|
||||
const modified = meta.modified ?? meta.time?.modified
|
||||
const headers = JSON.stringify({
|
||||
etag,
|
||||
fullEtag: body.fullEtag,
|
||||
modified,
|
||||
uncacheable: body.uncacheable === true ? true : undefined,
|
||||
})
|
||||
const bodyMeta = meta.etag == null && meta.fullEtag == null && meta.uncacheable == null
|
||||
? meta
|
||||
: { ...meta, etag: undefined, fullEtag: undefined, uncacheable: undefined }
|
||||
return `${headers}\n${body.jsonText ?? JSON.stringify(bodyMeta)}`
|
||||
if (url.host !== '') {
|
||||
paths.push(path.join(cacheDir, legacyMetaDir, url.host.replace(':', '+'), fileName))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,13 +282,6 @@ export async function getFileMtime (filePath: string): Promise<Date | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface MetaHeaders {
|
||||
etag?: string
|
||||
fullEtag?: string
|
||||
modified?: string
|
||||
uncacheable?: boolean
|
||||
}
|
||||
|
||||
export function metaHeadersOf (meta: PackageMeta): MetaHeaders {
|
||||
return {
|
||||
etag: meta.etag,
|
||||
@@ -267,61 +290,3 @@ export function metaHeadersOf (meta: PackageMeta): MetaHeaders {
|
||||
uncacheable: meta.uncacheable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads only the first line of the cached NDJSON metadata file to extract
|
||||
* the cache headers (etag, modified). This avoids reading and
|
||||
* parsing the full metadata (which can be megabytes for popular packages)
|
||||
* when we only need conditional-request headers.
|
||||
*/
|
||||
export async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
|
||||
let fh: fs.FileHandle | undefined
|
||||
try {
|
||||
fh = await fs.open(pkgMirror, 'r')
|
||||
// The first line (headers JSON) is typically ~100 bytes; 1 KB is plenty.
|
||||
const buf = Buffer.alloc(1024)
|
||||
const { bytesRead } = await fh.read(buf, 0, 1024, 0)
|
||||
if (bytesRead === 0) return null
|
||||
const chunk = buf.toString('utf8', 0, bytesRead)
|
||||
const newlineIdx = chunk.indexOf('\n')
|
||||
if (newlineIdx === -1) return null
|
||||
return JSON.parse(chunk.slice(0, newlineIdx)) as MetaHeaders
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await fh?.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the full metadata from the cached NDJSON file.
|
||||
*/
|
||||
export async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
|
||||
try {
|
||||
const data = await gfs.readFile(pkgMirror, 'utf8')
|
||||
const newlineIdx = data.indexOf('\n')
|
||||
if (newlineIdx === -1) return null
|
||||
const headers = JSON.parse(data.slice(0, newlineIdx)) as MetaHeaders
|
||||
const meta = JSON.parse(data.slice(newlineIdx + 1)) as PackageMeta
|
||||
dropIncompletePublishTimes(meta)
|
||||
meta.etag = headers.etag
|
||||
meta.fullEtag = headers.fullEtag
|
||||
meta.uncacheable = headers.uncacheable === true ? true : undefined
|
||||
return meta
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const createdDirs = new Set<string>()
|
||||
|
||||
export async function saveMeta (pkgMirror: string, json: string): Promise<void> {
|
||||
const dir = path.dirname(pkgMirror)
|
||||
if (!createdDirs.has(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
createdDirs.add(dir)
|
||||
}
|
||||
const temp = pathTemp(pkgMirror)
|
||||
await gfs.writeFile(temp, json, 'utf8')
|
||||
await renameOverwrite(temp, pkgMirror)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isError, PnpmError } from '@pnpm/error'
|
||||
import gfs from '@pnpm/fs.graceful-fs'
|
||||
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
import { fastPathTemp as pathTemp } from 'path-temp'
|
||||
import { renameOverwrite } from 'rename-overwrite'
|
||||
|
||||
import { markMetaCondensed, pickAbbreviatedVersionFields } from './clearMeta.js'
|
||||
import { dropIncompletePublishTimes } from './publishTimes.js'
|
||||
|
||||
/**
|
||||
* Reading a lazily-hydrated version manifest throws this when its fragment
|
||||
* bytes are not valid JSON — the index vouched for the span, so the file is
|
||||
* corrupt. Callers treat the whole document like an unreadable mirror (a
|
||||
* cache miss), the same way whole-file corruption reads as `null` from
|
||||
* {@link loadMeta}; corruption discovered lazily must not degrade into
|
||||
* silently missing versions, or a poisoned mirror could keep 304-validating
|
||||
* forever (the etag lives in the intact headers record) and never self-heal.
|
||||
*/
|
||||
const MALFORMED_FRAGMENT_ERROR_CODE = 'ERR_PNPM_MALFORMED_META_FRAGMENT'
|
||||
|
||||
export function isMalformedMirrorFragmentError (err: unknown): boolean {
|
||||
return isError(err) && 'code' in err && err.code === MALFORMED_FRAGMENT_ERROR_CODE
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `versions` holds a manifest for `version`, without invoking the
|
||||
* getter that parses a lazily-loaded manifest. A version listed with a
|
||||
* `null` value has no manifest.
|
||||
*/
|
||||
export function hasVersionManifest (versions: PackageMeta['versions'], version: string): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(versions, version)
|
||||
return descriptor != null && (descriptor.get != null || descriptor.value != null)
|
||||
}
|
||||
|
||||
export interface MetaHeaders {
|
||||
etag?: string
|
||||
fullEtag?: string
|
||||
modified?: string
|
||||
uncacheable?: boolean
|
||||
}
|
||||
|
||||
/** What the headers record carries besides the document's own `modified`. */
|
||||
export interface MirrorHeadersBody {
|
||||
uncacheable?: boolean
|
||||
fullEtag?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the indexed mirror layout, shared with the Rust stack
|
||||
* (`pnpm/crates/resolving-npm-resolver/src/mirror.rs`). The file starts with
|
||||
* `pnpm-meta-v1 <headersLen> <indexLen>\n`, followed by the headers JSON
|
||||
* record, the index JSON record, and the concatenated per-version JSON
|
||||
* fragments the index's `versions` spans point into. Loading such a file only
|
||||
* parses the two records; a version's manifest is parsed on first access.
|
||||
*
|
||||
* A file whose first line isn't this is the two-line NDJSON layout (headers
|
||||
* JSON, newline, packument JSON), which loads eagerly — that is how a
|
||||
* `filterMetadata` document is still written. Reading an unrecognized line as
|
||||
* a cache miss is a backstop, not the compatibility story: a format change
|
||||
* bumps the cache directory (see `ABBREVIATED_META_DIR`) so a pnpm version
|
||||
* that predates it never opens these files at all.
|
||||
*/
|
||||
const MIRROR_FORMAT_ID = 'pnpm-meta-v1'
|
||||
|
||||
interface MirrorIndex {
|
||||
name: string
|
||||
distTags?: Record<string, string>
|
||||
time?: PackageMeta['time']
|
||||
/**
|
||||
* Not part of `PackageMeta`, so nothing on this side reads it back — it is
|
||||
* carried through because the Rust stack's `outdated --long` reads it out
|
||||
* of the shared mirror, and rewriting the file without it would blank that
|
||||
* column until the next full response.
|
||||
*/
|
||||
homepage?: string
|
||||
versions: Array<[version: string, offset: number, length: number]>
|
||||
}
|
||||
|
||||
/** Parse the `pnpm-meta-v1 <headersLen> <indexLen>` line; `null` for anything else. */
|
||||
function parseFormatLine (line: string): { headersLen: number, indexLen: number } | null {
|
||||
if (!line.startsWith(`${MIRROR_FORMAT_ID} `)) return null
|
||||
const [headersLen, indexLen, extra] = line.slice(MIRROR_FORMAT_ID.length + 1).split(' ')
|
||||
if (extra != null) return null
|
||||
const parsedHeadersLen = parseRecordLength(headersLen)
|
||||
const parsedIndexLen = parseRecordLength(indexLen)
|
||||
if (parsedHeadersLen == null || parsedIndexLen == null) return null
|
||||
return { headersLen: parsedHeadersLen, indexLen: parsedIndexLen }
|
||||
}
|
||||
|
||||
/** A whole token of decimal digits, as the Rust reader requires. */
|
||||
function parseRecordLength (token: string | undefined): number | null {
|
||||
if (token == null || !/^\d+$/.test(token)) return null
|
||||
const length = Number(token)
|
||||
return Number.isSafeInteger(length) ? length : null
|
||||
}
|
||||
|
||||
export interface LoadMetaOptions {
|
||||
/**
|
||||
* Condense hydrated version manifests to the abbreviated field set (see
|
||||
* `clearMeta`) and mark the returned document as already condensed. Only
|
||||
* affects indexed mirrors: an NDJSON mirror loads eagerly and the caller
|
||||
* condenses the whole document itself.
|
||||
*/
|
||||
condense?: boolean
|
||||
/**
|
||||
* Parse every fragment before returning, so a corrupt one reads as a cache
|
||||
* miss here instead of throwing at the caller's first access. For callers
|
||||
* that read across all versions anyway and have no fall-through path of
|
||||
* their own, unlike the resolver (see the malformed-fragment error above).
|
||||
*/
|
||||
hydrateEagerly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a package's mirrored registry metadata, in either mirror layout.
|
||||
* An indexed mirror yields a document whose `versions` values are parsed
|
||||
* lazily on first property access; enumeration of the version keys does not
|
||||
* parse any manifest. Returns `null` when the file is missing, malformed, or
|
||||
* truncated — the caller treats all three as a cache miss.
|
||||
*/
|
||||
export async function loadMeta (pkgMirror: string, opts?: LoadMetaOptions): Promise<PackageMeta | null> {
|
||||
let data: Buffer
|
||||
try {
|
||||
data = await gfs.readFile(pkgMirror)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const newlineIdx = data.indexOf(10)
|
||||
if (newlineIdx === -1) return null
|
||||
const firstLine = data.toString('utf8', 0, newlineIdx)
|
||||
const format = parseFormatLine(firstLine)
|
||||
const meta = format == null
|
||||
? parseNdjsonMeta(data, firstLine, newlineIdx)
|
||||
: parseIndexedMeta({ pkgMirror, data, recordsStart: newlineIdx + 1, format }, opts)
|
||||
if (meta != null) {
|
||||
dropIncompletePublishTimes(meta)
|
||||
}
|
||||
return meta
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseNdjsonMeta (data: Buffer, firstLine: string, newlineIdx: number): PackageMeta {
|
||||
const headers = JSON.parse(firstLine) as MetaHeaders
|
||||
const meta = JSON.parse(data.toString('utf8', newlineIdx + 1)) as PackageMeta
|
||||
return attachHeaders(meta, headers)
|
||||
}
|
||||
|
||||
interface IndexedMirrorFile {
|
||||
pkgMirror: string
|
||||
data: Buffer
|
||||
recordsStart: number
|
||||
format: { headersLen: number, indexLen: number }
|
||||
}
|
||||
|
||||
function parseIndexedMeta (
|
||||
{ pkgMirror, data, recordsStart, format }: IndexedMirrorFile,
|
||||
opts?: LoadMetaOptions
|
||||
): PackageMeta | null {
|
||||
const indexStart = recordsStart + format.headersLen
|
||||
const fragmentBase = indexStart + format.indexLen
|
||||
if (fragmentBase > data.length || format.headersLen > MAX_HEADERS_LEN || format.indexLen > MAX_INDEX_LEN) return null
|
||||
const headers = JSON.parse(data.toString('utf8', recordsStart, indexStart)) as MetaHeaders
|
||||
const index = JSON.parse(data.toString('utf8', indexStart, fragmentBase)) as MirrorIndex
|
||||
const versions = buildLazyVersions({ pkgMirror, data, fragmentBase }, index.versions, opts?.condense === true)
|
||||
if (versions == null) return null
|
||||
if (opts?.hydrateEagerly === true) {
|
||||
for (const version in versions) {
|
||||
void versions[version]
|
||||
}
|
||||
}
|
||||
const meta: PackageMeta = {
|
||||
name: index.name,
|
||||
'dist-tags': index.distTags ?? {},
|
||||
versions,
|
||||
time: index.time,
|
||||
modified: headers.modified,
|
||||
}
|
||||
if (opts?.condense === true) {
|
||||
markMetaCondensed(meta)
|
||||
}
|
||||
return attachHeaders(meta, headers)
|
||||
}
|
||||
|
||||
function attachHeaders (meta: PackageMeta, headers: MetaHeaders): PackageMeta {
|
||||
meta.etag = headers.etag
|
||||
meta.fullEtag = headers.fullEtag
|
||||
meta.uncacheable = headers.uncacheable === true ? true : undefined
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* A versions map whose values parse from their fragment span on first access.
|
||||
* The getters cache in a map shared across the whole document, so a manifest
|
||||
* keeps one identity even when a property descriptor is copied onto a
|
||||
* filtered view of the document, and mutations of a hydrated manifest (the
|
||||
* picker's `name` back-fill, `readPackage` hooks) stick. Returns `null` when
|
||||
* a span falls outside the file, so a truncated mirror reads as a cache miss
|
||||
* rather than handing out garbage fragments later; a fragment whose bytes
|
||||
* don't parse throws on access (see the malformed-fragment error above).
|
||||
*/
|
||||
function buildLazyVersions (
|
||||
file: { pkgMirror: string, data: Buffer, fragmentBase: number },
|
||||
spans: MirrorIndex['versions'],
|
||||
condense: boolean
|
||||
): PackageMeta['versions'] | null {
|
||||
// A null prototype so a registry-controlled version key named `__proto__`
|
||||
// becomes a regular own property (see the same pattern in clearMeta).
|
||||
const versions: PackageMeta['versions'] = Object.create(null)
|
||||
const hydrated = new Map<string, PackageInRegistry | PnpmError | null>()
|
||||
for (const [version, offset, length] of spans) {
|
||||
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0) return null
|
||||
const start = file.fragmentBase + offset
|
||||
const end = start + length
|
||||
if (end > file.data.length) return null
|
||||
// An oversized span is absent, as in the Rust reader.
|
||||
if (length > MAX_FRAGMENT_LEN) continue
|
||||
Object.defineProperty(versions, version, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get (): PackageInRegistry {
|
||||
let manifest = hydrated.get(version)
|
||||
if (manifest === undefined) {
|
||||
manifest = parseFragment(file, { version, start, end }, condense)
|
||||
hydrated.set(version, manifest)
|
||||
}
|
||||
if (manifest instanceof PnpmError) throw manifest
|
||||
// `null` for a well-formed fragment of the wrong shape, which the
|
||||
// pickers treat as a version without a manifest.
|
||||
return manifest as PackageInRegistry
|
||||
},
|
||||
})
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
/** Matches the Rust reader's `MAX_FRAGMENT_LEN`. */
|
||||
const MAX_FRAGMENT_LEN = 16 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Only bytes that are not UTF-8 JSON mean a damaged mirror. The mirror stores
|
||||
* registry fragments verbatim, so a well-formed fragment of the wrong shape is
|
||||
* how the registry served that version, and it reads as no manifest.
|
||||
*/
|
||||
function parseFragment (
|
||||
file: { pkgMirror: string, data: Buffer },
|
||||
{ version, start, end }: { version: string, start: number, end: number },
|
||||
condense: boolean
|
||||
): PackageInRegistry | PnpmError | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(strictUtf8.decode(file.data.subarray(start, end)))
|
||||
} catch {
|
||||
return new PnpmError('MALFORMED_META_FRAGMENT', `Failed to parse the manifest of ${version} in the package metadata mirror at ${file.pkgMirror}`)
|
||||
}
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
return condense ? pickAbbreviatedVersionFields(parsed as PackageInRegistry) : parsed as PackageInRegistry
|
||||
}
|
||||
|
||||
/** Fails on malformed UTF-8 rather than substituting U+FFFD, as the Rust reader does. */
|
||||
const strictUtf8 = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
/** Match the bounds the Rust reader applies to the two records. */
|
||||
const MAX_HEADERS_LEN = 64 * 1024
|
||||
const MAX_INDEX_LEN = 64 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Reads only the leading records of a mirror file to extract the cache
|
||||
* headers for conditional requests. This avoids reading and
|
||||
* parsing the version data (which can be megabytes for popular packages).
|
||||
*/
|
||||
export async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
|
||||
let fh: fs.FileHandle | undefined
|
||||
try {
|
||||
fh = await fs.open(pkgMirror, 'r')
|
||||
// Format line plus headers record is ~200 bytes; 1 KB is plenty.
|
||||
const buf = Buffer.alloc(1024)
|
||||
const { bytesRead } = await fh.read(buf, 0, 1024, 0)
|
||||
if (bytesRead === 0) return null
|
||||
const newlineIdx = buf.subarray(0, bytesRead).indexOf(10)
|
||||
if (newlineIdx === -1) return null
|
||||
const firstLine = buf.toString('utf8', 0, newlineIdx)
|
||||
const format = parseFormatLine(firstLine)
|
||||
if (format == null) {
|
||||
return JSON.parse(firstLine) as MetaHeaders
|
||||
}
|
||||
// The headers record is two etags and a timestamp. Bound the declared
|
||||
// length before allocating from it, so a corrupt or hostile mirror can't
|
||||
// turn a cache read into an arbitrarily large allocation.
|
||||
if (format.headersLen > MAX_HEADERS_LEN) return null
|
||||
const headersStart = newlineIdx + 1
|
||||
const headersEnd = headersStart + format.headersLen
|
||||
if (headersEnd <= bytesRead) {
|
||||
return JSON.parse(buf.toString('utf8', headersStart, headersEnd)) as MetaHeaders
|
||||
}
|
||||
const rest = Buffer.alloc(headersEnd - bytesRead)
|
||||
await fh.read(rest, 0, rest.length, bytesRead)
|
||||
return JSON.parse(buf.toString('utf8', headersStart, bytesRead) + rest.toString('utf8')) as MetaHeaders
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await fh?.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats metadata for disk storage as two-line NDJSON:
|
||||
* Line 1: cache headers (etag, fullEtag, modified) — small, fast to read
|
||||
* Line 2: the registry metadata JSON
|
||||
*
|
||||
* This layout is kept for `filterMetadata` documents; everything else is
|
||||
* mirrored in the indexed layout (see {@link MIRROR_FORMAT_ID}).
|
||||
* `body.jsonText` is the raw registry body, written as is when given.
|
||||
*/
|
||||
export function prepareJsonForDisk (
|
||||
meta: PackageMeta,
|
||||
etag: string | undefined,
|
||||
body: MirrorHeadersBody & { jsonText?: string } = {}
|
||||
): string {
|
||||
const bodyMeta = meta.etag == null && meta.fullEtag == null && meta.uncacheable == null
|
||||
? meta
|
||||
: { ...meta, etag: undefined, fullEtag: undefined, uncacheable: undefined }
|
||||
return `${encodeHeaders(meta, etag, body)}\n${body.jsonText ?? JSON.stringify(bodyMeta)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The headers record of either layout. The ETags live only here (`loadMeta`
|
||||
* re-attaches them), and `modified` is always written: it comes from the
|
||||
* packument's own `time.modified`, which both representations report
|
||||
* identically. An ETag identifies one representation: `etag` tags the
|
||||
* representation the mirror's directory holds, `body.fullEtag` a full document
|
||||
* the release-age upgrade stored in the abbreviated mirror. `body.uncacheable`
|
||||
* records that the response forbade caching, so the next online lookup
|
||||
* refetches instead of revalidating.
|
||||
*/
|
||||
function encodeHeaders (meta: PackageMeta, etag: string | undefined, body: MirrorHeadersBody): string {
|
||||
return JSON.stringify({
|
||||
etag,
|
||||
fullEtag: body.fullEtag,
|
||||
modified: meta.modified ?? meta.time?.modified,
|
||||
uncacheable: body.uncacheable === true ? true : undefined,
|
||||
} satisfies MetaHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes metadata in the indexed mirror layout (see {@link MIRROR_FORMAT_ID}).
|
||||
* Reading `meta.versions` here materializes a lazily-loaded document; the
|
||||
* documents that reach the mirror writers come from network fetches, which
|
||||
* are always eager.
|
||||
*/
|
||||
export function prepareIndexedForDisk (meta: PackageMeta, etag: string | undefined, body: MirrorHeadersBody = {}): Buffer {
|
||||
const headers = encodeHeaders(meta, etag, body)
|
||||
const spans: MirrorIndex['versions'] = []
|
||||
const fragments: Buffer[] = []
|
||||
let offset = 0
|
||||
for (const version of Object.keys(meta.versions ?? {})) {
|
||||
const manifest = meta.versions[version]
|
||||
if (manifest == null) continue
|
||||
const fragment = Buffer.from(JSON.stringify(manifest), 'utf8')
|
||||
// The reader skips such a span, so the saved and served views agree.
|
||||
if (fragment.length > MAX_FRAGMENT_LEN) continue
|
||||
spans.push([version, offset, fragment.length])
|
||||
offset += fragment.length
|
||||
fragments.push(fragment)
|
||||
}
|
||||
const index: MirrorIndex = {
|
||||
name: meta.name,
|
||||
distTags: meta['dist-tags'] ?? {},
|
||||
versions: spans,
|
||||
}
|
||||
if (meta.time != null) {
|
||||
index.time = meta.time
|
||||
}
|
||||
const { homepage } = meta as PackageMeta & { homepage?: string }
|
||||
if (typeof homepage === 'string') {
|
||||
index.homepage = homepage
|
||||
}
|
||||
const indexJson = JSON.stringify(index)
|
||||
const lead = Buffer.from(
|
||||
`${MIRROR_FORMAT_ID} ${Buffer.byteLength(headers)} ${Buffer.byteLength(indexJson)}\n${headers}${indexJson}`,
|
||||
'utf8'
|
||||
)
|
||||
return Buffer.concat([lead, ...fragments])
|
||||
}
|
||||
|
||||
const createdDirs = new Set<string>()
|
||||
|
||||
export async function saveMeta (pkgMirror: string, content: string | Buffer): Promise<void> {
|
||||
const dir = path.dirname(pkgMirror)
|
||||
if (!createdDirs.has(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
createdDirs.add(dir)
|
||||
}
|
||||
const temp = pathTemp(pkgMirror)
|
||||
await gfs.writeFile(temp, content, typeof content === 'string' ? 'utf8' : undefined)
|
||||
await renameOverwrite(temp, pkgMirror)
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
|
||||
import { logger } from '@pnpm/logger'
|
||||
import type { PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
|
||||
import { type FetchMetadataResult, notModifiedWithoutCacheError } from './fetch.js'
|
||||
import {
|
||||
condenseMetaForCache,
|
||||
encodeMirror,
|
||||
fullEtagOfAbbreviatedMirror,
|
||||
holdsFullMetaInAbbreviatedMirror,
|
||||
isMalformedMirrorFragmentError,
|
||||
loadMetaHeaders,
|
||||
type MetaHeaders,
|
||||
metaHeadersOf,
|
||||
mirrorEtags,
|
||||
prepareJsonForDisk,
|
||||
saveMetaBestEffort,
|
||||
} from './metaMirror.js'
|
||||
import type { RegistryPackageSpec } from './parseBareSpecifier.js'
|
||||
@@ -46,9 +47,8 @@ export async function pickFromRegistry (session: MirrorSession): Promise<PickRes
|
||||
|
||||
// 304: the cached mirror is still current.
|
||||
const diskMeta = await loadSessionDiskMeta(session)
|
||||
if (diskMeta != null) return await serveValidatedMeta(request, diskMeta)
|
||||
|
||||
return await refetchWithoutValidators(request)
|
||||
const validated = diskMeta != null ? await serveValidatedMirror(request, diskMeta) : undefined
|
||||
return validated ?? await refetchWithoutValidators(request)
|
||||
} catch (err: unknown) {
|
||||
return pickFromMirrorAfterFailedFetch(request, err)
|
||||
}
|
||||
@@ -78,8 +78,9 @@ async function fetchConditionally (
|
||||
})
|
||||
}
|
||||
|
||||
// The mirror vanished between the headers read and this read (concurrent
|
||||
// store cleanup, antivirus, ...), so the 304 now validates nothing. Ask
|
||||
// Either the mirror vanished between the headers read and this read
|
||||
// (concurrent store cleanup, antivirus, ...) or its content turned out
|
||||
// corrupt, so the 304 validates nothing. Ask
|
||||
// again as a cold cache would, which the registry can only answer with a
|
||||
// body or an error — never another 304.
|
||||
async function refetchWithoutValidators (request: PickRequest): Promise<PickResult> {
|
||||
@@ -98,10 +99,30 @@ async function pickFromMirrorAfterFailedFetch (request: PickRequest, err: unknow
|
||||
(err as { spec?: RegistryPackageSpec }).spec = request.spec
|
||||
const meta = await loadMetaCondensed(request)
|
||||
if (meta == null) throw err
|
||||
let pickedPackage: PackageInRegistry | null
|
||||
try {
|
||||
pickedPackage = pickMatchingVersionFinal(request.pickerOpts, request.spec, meta)
|
||||
} catch (pickErr: unknown) {
|
||||
// A corrupt fragment makes this fallback as useless as a missing mirror.
|
||||
if (isMalformedMirrorFragmentError(pickErr)) throw err
|
||||
throw pickErr
|
||||
}
|
||||
logger.debug({ message: `Using cached meta from ${request.pkgMirror}` })
|
||||
return {
|
||||
meta,
|
||||
pickedPackage: pickMatchingVersionFinal(request.pickerOpts, request.spec, meta),
|
||||
return { meta, pickedPackage }
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link serveValidatedMeta}, or `undefined` when a version fragment of the
|
||||
* local file is corrupt. The 304 validated the ETag in the intact headers
|
||||
* record, so without a refetch the mirror would keep revalidating and never
|
||||
* be repaired.
|
||||
*/
|
||||
async function serveValidatedMirror (request: PickRequest, cached: PackageMeta): Promise<PickResult | undefined> {
|
||||
try {
|
||||
return await serveValidatedMeta(request, cached)
|
||||
} catch (err: unknown) {
|
||||
if (isMalformedMirrorFragmentError(err)) return undefined
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +144,11 @@ async function serveValidatedMeta (request: PickRequest, cached: PackageMeta): P
|
||||
}
|
||||
const upgrade = await maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, cached)
|
||||
const meta = upgradeMetaForCache(ctx, upgrade, { pkgMirror, dryRun: opts.dryRun })
|
||||
// Pick before caching, so a corrupt fragment never leaves the document in
|
||||
// the in-memory cache.
|
||||
const pickedPackage = pickMatchingVersionFinal(request.pickerOpts, spec, meta)
|
||||
ctx.metaCache.set(request.cacheKey, meta)
|
||||
return {
|
||||
meta,
|
||||
pickedPackage: pickMatchingVersionFinal(request.pickerOpts, spec, meta),
|
||||
}
|
||||
return { meta, pickedPackage }
|
||||
}
|
||||
|
||||
interface FreshMetaUpgrade {
|
||||
@@ -171,7 +192,8 @@ async function upgradeFreshMetaForReleaseAge (request: PickRequest, fetched: Fet
|
||||
// Save the abbreviated metadata to the abbreviated cache before re-fetching full.
|
||||
if (!opts.dryRun) {
|
||||
const { etag, fullEtag } = mirrorEtags(fetched, request.fullMetadata)
|
||||
saveMetaBestEffort(request.pkgMirror, prepareJsonForDisk(fetched.meta, etag, { ...fetched, fullEtag }), fetched.uncacheable === true)
|
||||
const content = encodeMirror(ctx, fetched, { meta: fetched.meta, etag, body: { uncacheable: fetched.uncacheable, fullEtag } })
|
||||
saveMetaBestEffort(request.pkgMirror, content, fetched.uncacheable === true)
|
||||
}
|
||||
const fullFetchResult = await ctx.fetch(spec.name, {
|
||||
authHeaderValue: opts.authHeaderValue,
|
||||
@@ -206,15 +228,17 @@ function mirrorFreshMeta (
|
||||
request: PickRequest,
|
||||
{ resultToSave, meta }: { resultToSave: FetchMetadataResult, meta: PackageMeta }
|
||||
): void {
|
||||
// Mirror the raw registry body, unless the retained form is
|
||||
// deliberately narrower: `filterMetadata` always mirrors the stripped
|
||||
// document, and a full document in the abbreviated slot mirrors the
|
||||
// condensed form — `time` is all the next install needs from this slot.
|
||||
// Mirror the fetched document, unless the retained form is deliberately
|
||||
// narrower: `filterMetadata` always mirrors the stripped document, and a
|
||||
// full document in the abbreviated slot mirrors the condensed form — `time`
|
||||
// is all the next install needs from this slot.
|
||||
const writeCondensed = request.ctx.filterMetadata === true ||
|
||||
(holdsFullMetaInAbbreviatedMirror(resultToSave, request.fullMetadata) && meta !== resultToSave.meta)
|
||||
const { etag, fullEtag } = mirrorEtags(resultToSave, request.fullMetadata)
|
||||
const jsonForDisk = writeCondensed
|
||||
? prepareJsonForDisk(meta, etag, { uncacheable: resultToSave.uncacheable, fullEtag })
|
||||
: prepareJsonForDisk(resultToSave.meta, etag, { ...resultToSave, fullEtag })
|
||||
saveMetaBestEffort(request.pkgMirror, jsonForDisk, resultToSave.uncacheable === true)
|
||||
const content = encodeMirror(request.ctx, resultToSave, {
|
||||
meta: writeCondensed ? meta : resultToSave.meta,
|
||||
etag,
|
||||
body: { uncacheable: resultToSave.uncacheable, fullEtag },
|
||||
})
|
||||
saveMetaBestEffort(request.pkgMirror, content, resultToSave.uncacheable === true)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getFileMtime,
|
||||
getPkgMetaCacheKey,
|
||||
getPkgMirrorPath,
|
||||
hasVersionManifest,
|
||||
isMalformedMirrorFragmentError,
|
||||
legacyMirrorHint,
|
||||
loadMetaHeaders,
|
||||
type MetaHeaders,
|
||||
@@ -208,12 +210,21 @@ async function pickFromMemoryCache (request: PickRequest): Promise<PickResult |
|
||||
// publishedBy and the package was modified recently, upgrade to full
|
||||
// metadata so the maturity check runs properly.
|
||||
const { meta } = await upgradeCachedMetaForReleaseAge(request, cachedMeta)
|
||||
const pickedPackage = pickMatchingVersionFinal(request.pickerOpts, spec, meta)
|
||||
let pickedPackage: PackageInRegistry | null
|
||||
let offlinePickedPackage: PackageInRegistry | null | undefined
|
||||
try {
|
||||
pickedPackage = pickMatchingVersionFinal(request.pickerOpts, spec, meta)
|
||||
offlinePickedPackage = ctx.offline === true
|
||||
? await pickVersionFromStore(ctx, { pickerOpts: request.pickerOpts, spec, meta, pickedPackage })
|
||||
: undefined
|
||||
} catch (err: unknown) {
|
||||
// A disk-promoted entry with a corrupt fragment. Offline, the mirror pick
|
||||
// fails with NO_OFFLINE_META. Online, the 304 handler refetches past it.
|
||||
if (isMalformedMirrorFragmentError(err)) return undefined
|
||||
throw err
|
||||
}
|
||||
const unverified = unverifiedDiskPackuments.has(meta)
|
||||
const unverifiedPickIsSafe = isUnverifiedPickSafe(request, { pickedPackage, unverified })
|
||||
const offlinePickedPackage = ctx.offline === true
|
||||
? await pickVersionFromStore(ctx, { pickerOpts: request.pickerOpts, spec, meta, pickedPackage })
|
||||
: undefined
|
||||
const cacheResultCanReturn =
|
||||
ctx.offline === true ||
|
||||
!unverified ||
|
||||
@@ -303,11 +314,18 @@ async function pickOffline (request: PickRequest, diskMeta: PackageMeta | null):
|
||||
hint: await legacyMirrorHint(ctx.cacheDir, request.metaDir, opts.registry, spec.name),
|
||||
})
|
||||
}
|
||||
const pickedPackage = pickMatchingVersionFinal(request.pickerOpts, spec, diskMeta)
|
||||
const storePicked = await pickVersionFromStore(ctx, { pickerOpts: request.pickerOpts, spec, meta: diskMeta, pickedPackage })
|
||||
return {
|
||||
meta: diskMeta,
|
||||
pickedPackage: storePicked ?? pickedPackage,
|
||||
try {
|
||||
const pickedPackage = pickMatchingVersionFinal(request.pickerOpts, spec, diskMeta)
|
||||
const storePicked = await pickVersionFromStore(ctx, { pickerOpts: request.pickerOpts, spec, meta: diskMeta, pickedPackage })
|
||||
return {
|
||||
meta: diskMeta,
|
||||
pickedPackage: storePicked ?? pickedPackage,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// A corrupt fragment makes the mirror as unusable offline as a missing
|
||||
// one, and there is no network to repair it from.
|
||||
if (!isMalformedMirrorFragmentError(err)) throw err
|
||||
throw new PnpmError('NO_OFFLINE_META', `Failed to resolve ${toRaw(spec)} in package mirror ${request.pkgMirror}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +335,14 @@ async function pickFromUpgradedMirror (session: MirrorSession, diskMeta: Package
|
||||
// before letting pickMatchingVersionFinal warn-and-skip on missing time.
|
||||
const { meta, upgraded } = await upgradeCachedMetaForReleaseAge(request, diskMeta)
|
||||
session.diskMeta = meta
|
||||
const pickedPackage = pickMatchingVersionFinal(request.pickerOpts, request.spec, meta)
|
||||
let pickedPackage: PackageInRegistry | null
|
||||
try {
|
||||
pickedPackage = pickMatchingVersionFinal(request.pickerOpts, request.spec, meta)
|
||||
} catch (err: unknown) {
|
||||
// The registry request that follows replaces a corrupt mirror.
|
||||
if (isMalformedMirrorFragmentError(err)) return undefined
|
||||
throw err
|
||||
}
|
||||
if (!pickedPackage || !canServeCachedMeta(request.ctx, meta)) return undefined
|
||||
// A cache hit re-runs maybeUpgradeAbbreviatedMetaForReleaseAge, so
|
||||
// serving this meta from memory can't bypass the release-age
|
||||
@@ -339,7 +364,7 @@ async function pickExactVersionFromMirror (session: MirrorSession): Promise<Pick
|
||||
if (
|
||||
diskMeta == null ||
|
||||
!canServeCachedMeta(ctx, diskMeta) ||
|
||||
(diskMeta.versions?.[spec.fetchSpec]) == null
|
||||
!hasVersionManifest(diskMeta.versions ?? {}, spec.fetchSpec)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -345,21 +345,38 @@ function nonDeprecatedPick (
|
||||
versionRange: string
|
||||
): string | null {
|
||||
if (!meta.versions[picked]?.deprecated || candidates.length <= 1) return null
|
||||
const nonDeprecatedVersions = candidates.filter((version) => !meta.versions[version]?.deprecated)
|
||||
if (versionRange === '*' && !semverSatisfiesLoose(picked, versionRange)) {
|
||||
const pickedParsed = parseSemverLoose(picked)
|
||||
if (pickedParsed != null) {
|
||||
const sameReleasePrereleases = nonDeprecatedVersions.filter((version) => {
|
||||
const parsed = parseSemverLoose(version)
|
||||
return parsed != null &&
|
||||
parsed.major === pickedParsed.major &&
|
||||
parsed.minor === pickedParsed.minor &&
|
||||
parsed.patch === pickedParsed.patch
|
||||
})
|
||||
const sameRelease = maxVersionLoose(sameReleasePrereleases)
|
||||
if (sameRelease != null) return sameRelease
|
||||
}
|
||||
const isNonDeprecated = (version: string): boolean => {
|
||||
if (version === picked) return false
|
||||
const manifest = meta.versions[version]
|
||||
return manifest != null && !manifest.deprecated
|
||||
}
|
||||
|
||||
if (versionRange === '*' && !semverSatisfiesLoose(picked, versionRange)) {
|
||||
const sameRelease = pickSameReleaseVersion(candidates, picked, isNonDeprecated)
|
||||
if (sameRelease != null) return sameRelease
|
||||
}
|
||||
// Filter by the range before touching manifests, so a lazily-loaded
|
||||
// packument hydrates only the actual candidates instead of every version.
|
||||
const nonDeprecatedVersions = candidates.filter((version) =>
|
||||
semverSatisfiesLoose(version, versionRange) && isNonDeprecated(version)
|
||||
)
|
||||
return maxSatisfyingLoose(nonDeprecatedVersions, versionRange)
|
||||
}
|
||||
|
||||
function pickSameReleaseVersion (
|
||||
candidates: string[],
|
||||
picked: string,
|
||||
isNonDeprecated: (version: string) => boolean
|
||||
): string | null {
|
||||
const pickedParsed = parseSemverLoose(picked)
|
||||
if (pickedParsed == null) return null
|
||||
return maxVersionLoose(candidates.filter((version) =>
|
||||
isSameRelease(parseSemverLoose(version), pickedParsed) && isNonDeprecated(version)
|
||||
))
|
||||
}
|
||||
|
||||
function isSameRelease (parsed: semver.SemVer | null, other: semver.SemVer): boolean {
|
||||
return parsed != null &&
|
||||
parsed.major === other.major &&
|
||||
parsed.minor === other.minor &&
|
||||
parsed.patch === other.patch
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.ty
|
||||
import type { TrustPolicy } from '@pnpm/types'
|
||||
import type { LimitFunction } from 'p-limit'
|
||||
|
||||
import { retainsFullMeta } from './clearMeta.js'
|
||||
import { condenseMetaForCache, loadMeta, type MetaHeaders } from './metaMirror.js'
|
||||
import type { RegistryPackageSpec } from './parseBareSpecifier.js'
|
||||
import type { PeekManifestFromStore, PickerOptions } from './pickMatchingVersion.js'
|
||||
@@ -100,7 +101,7 @@ export interface MirrorSession {
|
||||
}
|
||||
|
||||
export async function loadMetaCondensed (request: PickRequest): Promise<PackageMeta | null> {
|
||||
const meta = await loadMeta(request.pkgMirror)
|
||||
const meta = await loadMeta(request.pkgMirror, { condense: !retainsFullMeta(request.ctx) })
|
||||
return meta == null ? null : condenseMetaForCache(request.ctx, meta)
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,10 @@ function findNewestInstallableNonDeprecated (
|
||||
opts: PublishPolicyOptions
|
||||
): semver.SemVer | undefined {
|
||||
let newest: semver.SemVer | undefined
|
||||
for (const [version, versionMeta] of Object.entries(meta.versions)) {
|
||||
if (versionMeta.deprecated) continue
|
||||
if (!installableUnderPolicy(meta, version, opts)) continue
|
||||
// Keys first, so a lazily-loaded manifest is parsed only for a version the
|
||||
// policy admits. A version without a manifest is skipped.
|
||||
for (const version in meta.versions) {
|
||||
if (!isInstallableNonDeprecated(meta, version, opts)) continue
|
||||
const parsed = semver.parse(version, true)
|
||||
if (parsed != null && (newest == null || parsed.compare(newest) > 0)) {
|
||||
newest = parsed
|
||||
@@ -113,6 +114,12 @@ function findNewestInstallableNonDeprecated (
|
||||
return newest
|
||||
}
|
||||
|
||||
function isInstallableNonDeprecated (meta: PackageMeta, version: string, opts: PublishPolicyOptions): boolean {
|
||||
if (!Object.hasOwn(meta.versions, version) || !installableUnderPolicy(meta, version, opts)) return false
|
||||
const versionMeta = meta.versions[version]
|
||||
return versionMeta != null && !versionMeta.deprecated
|
||||
}
|
||||
|
||||
export function parseModifiedDate (modified: string | undefined): Date | null {
|
||||
if (!modified) return null
|
||||
const date = new Date(modified)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
|
||||
import type { FetchMetadataNotModifiedResult, FetchMetadataResult } from './fetch.js'
|
||||
import { condenseMetaForCache, prepareJsonForDisk, saveMetaBestEffort } from './metaMirror.js'
|
||||
import { condenseMetaForCache, encodeMirror, saveMetaBestEffort } from './metaMirror.js'
|
||||
import type { RegistryPackageSpec } from './parseBareSpecifier.js'
|
||||
import type { PickPackageFromMetaOptions } from './pickPackageFromMeta.js'
|
||||
|
||||
@@ -138,8 +138,8 @@ export function upgradeMetaForCache (
|
||||
}
|
||||
|
||||
// A condensing resolver keeps and mirrors the condensed form — the mirror
|
||||
// only has to carry `time` into the next install; otherwise the raw response
|
||||
// body is written and the unstripped meta is kept. Either way the abbreviated
|
||||
// only has to carry `time` into the next install; otherwise the unstripped
|
||||
// meta is written and kept. Either way the abbreviated
|
||||
// mirror now holds the full document, so its ETag is recorded as `fullEtag`.
|
||||
function persistUpgradedMeta (
|
||||
ctx: { fullMetadata?: boolean, filterMetadata?: boolean },
|
||||
@@ -148,9 +148,11 @@ function persistUpgradedMeta (
|
||||
): PackageMeta {
|
||||
const metaForCache = condenseMetaForCache(ctx, upgradedFrom.meta)
|
||||
const fullEtag = upgradedFrom.etag
|
||||
const jsonForDisk = metaForCache === upgradedFrom.meta
|
||||
? prepareJsonForDisk(upgradedFrom.meta, undefined, { ...upgradedFrom, fullEtag })
|
||||
: prepareJsonForDisk(metaForCache, undefined, { uncacheable: upgradedFrom.uncacheable, fullEtag })
|
||||
saveMetaBestEffort(pkgMirror, jsonForDisk, upgradedFrom.uncacheable === true)
|
||||
const content = encodeMirror(ctx, upgradedFrom, {
|
||||
meta: metaForCache,
|
||||
etag: undefined,
|
||||
body: { uncacheable: upgradedFrom.uncacheable, fullEtag },
|
||||
})
|
||||
saveMetaBestEffort(pkgMirror, content, upgradedFrom.uncacheable === true)
|
||||
return metaForCache
|
||||
}
|
||||
@@ -194,8 +194,11 @@ function detectStrongestTrustEvidenceBeforeDate (
|
||||
const filter: TrustHistoryFilter = { beforeDate, excludePrerelease: options.excludePrerelease }
|
||||
let best: TrustEvidence | undefined
|
||||
|
||||
for (const versionEntry of Object.entries(meta.versions)) {
|
||||
const trustEvidence = readEarlierTrustEvidence(meta, versionEntry, filter)
|
||||
// Keys, not entries: a lazily-loaded packument parses a manifest only when
|
||||
// its value is read, and most versions fail the date filter first.
|
||||
for (const version in meta.versions) {
|
||||
if (!Object.hasOwn(meta.versions, version)) continue
|
||||
const trustEvidence = readEarlierTrustEvidence(meta, version, filter)
|
||||
if (!trustEvidence) continue
|
||||
if (trustEvidence === 'stagedPublish') return trustEvidence
|
||||
if (best === undefined || TRUST_RANK[trustEvidence] > TRUST_RANK[best]) {
|
||||
@@ -208,7 +211,7 @@ function detectStrongestTrustEvidenceBeforeDate (
|
||||
|
||||
function readEarlierTrustEvidence (
|
||||
meta: PackageMetaWithTime,
|
||||
[version, manifest]: [string, PackageInRegistry],
|
||||
version: string,
|
||||
filter: TrustHistoryFilter
|
||||
): TrustEvidence | undefined {
|
||||
if (filter.excludePrerelease && semver.prerelease(version, true)) return undefined
|
||||
@@ -218,7 +221,8 @@ function readEarlierTrustEvidence (
|
||||
const publishedAt = new Date(ts)
|
||||
if (!(publishedAt < filter.beforeDate)) return undefined
|
||||
|
||||
return getTrustEvidence(manifest)
|
||||
const manifest = meta.versions[version]
|
||||
return manifest == null ? undefined : getTrustEvidence(manifest)
|
||||
}
|
||||
|
||||
export function getTrustEvidence (manifest: PackageInRegistry): TrustEvidence | undefined {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchAbbreviatedMetadataCached,
|
||||
fetchFullMetadataCached,
|
||||
} from './fetchFullMetadataCached.js'
|
||||
import { isMalformedMirrorFragmentError } from './metaMirror.js'
|
||||
import type { PackageMetaCache } from './pickPackage.js'
|
||||
import { getPkgMetaCacheKey, getPkgMirrorPath, loadMeta } from './pickPackage.js'
|
||||
|
||||
@@ -179,7 +180,7 @@ export function fetchFullMetaForTrust (
|
||||
function projectTrustMeta (meta: PackageMeta): PackageMeta {
|
||||
const versions: Record<string, PackageInRegistry> = {}
|
||||
for (const [version, manifest] of Object.entries(meta.versions ?? {})) {
|
||||
versions[version] = projectTrustManifest(manifest)
|
||||
if (manifest != null) versions[version] = projectTrustManifest(manifest)
|
||||
}
|
||||
return {
|
||||
name: meta.name,
|
||||
@@ -324,8 +325,9 @@ export function fetchAbbreviatedMeta (
|
||||
// Fast path: the resolver's per-install LRU already holds this
|
||||
// packument from its own pickPackage pass — abbreviated or full.
|
||||
const shared = readSharedMeta(context.sharedMetaCache, registry, name)
|
||||
if (shared != null) {
|
||||
cachedPromise = Promise.resolve({ meta: projectAbbreviatedMeta(shared) })
|
||||
const sharedProjection = shared != null ? projectSharedMeta(shared) : undefined
|
||||
if (sharedProjection != null) {
|
||||
cachedPromise = Promise.resolve({ meta: sharedProjection })
|
||||
} else {
|
||||
cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
|
||||
registry,
|
||||
@@ -342,6 +344,19 @@ export function fetchAbbreviatedMeta (
|
||||
return cachedPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* `undefined` when the shared packument holds a corrupt mirror fragment, so
|
||||
* the lookup falls through to its own fetch, which repairs the mirror.
|
||||
*/
|
||||
function projectSharedMeta (meta: PackageMeta): ReturnType<typeof projectAbbreviatedMeta> | undefined {
|
||||
try {
|
||||
return projectAbbreviatedMeta(meta)
|
||||
} catch (err: unknown) {
|
||||
if (isMalformedMirrorFragmentError(err)) return undefined
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function readSharedMeta (
|
||||
cache: PackageMetaCache | undefined,
|
||||
registry: string,
|
||||
@@ -414,7 +429,7 @@ function projectVersionArtifacts (
|
||||
): Map<string, RegistryArtifactHistory> {
|
||||
const versionArtifacts = new Map<string, RegistryArtifactHistory>()
|
||||
for (const [version, manifest] of Object.entries(versions)) {
|
||||
versionArtifacts.set(version, projectArtifactHistory(manifest))
|
||||
if (manifest != null) versionArtifacts.set(version, projectArtifactHistory(manifest))
|
||||
}
|
||||
return versionArtifacts
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { expect, test } from '@jest/globals'
|
||||
import { ABBREVIATED_META_DIR, FULL_META_DIR } from '@pnpm/constants'
|
||||
import type { PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
import { temporaryDirectory } from 'tempy'
|
||||
|
||||
import type { FetchMetadataOptions } from '../src/fetch.js'
|
||||
import { fetchFullMetadataCached } from '../src/fetchFullMetadataCached.js'
|
||||
import { prepareIndexedForDisk } from '../src/mirrorLayout.js'
|
||||
import type { RegistryPackageSpec } from '../src/parseBareSpecifier.js'
|
||||
import { getPkgMirrorPath, type PackageMetaCache, pickPackage } from '../src/pickPackage.js'
|
||||
|
||||
const REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
function fooMeta (): PackageMeta {
|
||||
return {
|
||||
name: 'foo',
|
||||
'dist-tags': { latest: '2.0.0' },
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
dist: {
|
||||
shasum: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz',
|
||||
},
|
||||
},
|
||||
'2.0.0': {
|
||||
name: 'foo',
|
||||
version: '2.0.0',
|
||||
dist: {
|
||||
shasum: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
tarball: 'https://registry.npmjs.org/foo/-/foo-2.0.0.tgz',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PackageMeta
|
||||
}
|
||||
|
||||
function createMetaCache (): PackageMetaCache {
|
||||
const store = new Map<string, PackageMeta>()
|
||||
return {
|
||||
get: (key) => store.get(key),
|
||||
set: (key, meta) => {
|
||||
store.set(key, meta)
|
||||
},
|
||||
has: (key) => store.has(key),
|
||||
}
|
||||
}
|
||||
|
||||
// Writes an indexed mirror whose 2.0.0 fragment (the max satisfying version)
|
||||
// is corrupted in place: spans stay valid, the fragment bytes do not parse.
|
||||
function writeCorruptMirror (pkgMirror: string): void {
|
||||
const meta = fooMeta()
|
||||
const content = prepareIndexedForDisk(meta, '"etag-corrupt"')
|
||||
const fragmentStart = content.indexOf(Buffer.from(JSON.stringify(meta.versions['2.0.0'])))
|
||||
if (fragmentStart === -1) throw new Error('fragment not found in the serialized mirror')
|
||||
content.fill('x', fragmentStart, fragmentStart + 10)
|
||||
fs.mkdirSync(path.dirname(pkgMirror), { recursive: true })
|
||||
fs.writeFileSync(pkgMirror, content)
|
||||
}
|
||||
|
||||
test('offline resolution over a corrupt fragment fails with NO_OFFLINE_META', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
writeCorruptMirror(getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, REGISTRY, 'foo'))
|
||||
|
||||
const ctx = {
|
||||
fetch: async () => {
|
||||
throw new Error('offline resolution must not hit the network')
|
||||
},
|
||||
metaCache: createMetaCache(),
|
||||
cacheDir,
|
||||
offline: true,
|
||||
}
|
||||
const spec: RegistryPackageSpec = { type: 'range', name: 'foo', fetchSpec: '>=1.0.0' }
|
||||
|
||||
await expect(
|
||||
pickPackage(ctx, spec, { registry: REGISTRY, dryRun: false, preferredVersionSelectors: undefined })
|
||||
).rejects.toMatchObject({ code: 'ERR_PNPM_NO_OFFLINE_META' })
|
||||
})
|
||||
|
||||
test('offline fetchFullMetadataCached over a corrupt fragment fails with NO_OFFLINE_META', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
writeCorruptMirror(getPkgMirrorPath(cacheDir, FULL_META_DIR, REGISTRY, 'foo'))
|
||||
|
||||
await expect(fetchFullMetadataCached({
|
||||
fetch: async () => {
|
||||
throw new Error('offline resolution must not hit the network')
|
||||
},
|
||||
retry: { retries: 0 },
|
||||
timeout: 30_000,
|
||||
fetchWarnTimeoutMs: 30_000,
|
||||
}, 'foo', {
|
||||
cacheDir,
|
||||
registry: REGISTRY,
|
||||
offline: true,
|
||||
})).rejects.toMatchObject({ code: 'ERR_PNPM_NO_OFFLINE_META' })
|
||||
})
|
||||
|
||||
test('a corrupt fragment behind a 304 triggers a cache-bypassing refetch that heals the mirror', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
const pkgMirror = getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, REGISTRY, 'foo')
|
||||
writeCorruptMirror(pkgMirror)
|
||||
const corruptContent = fs.readFileSync(pkgMirror)
|
||||
|
||||
const meta = fooMeta()
|
||||
type CacheBypassFetchMetadataOptions = FetchMetadataOptions & { cacheBypass?: boolean }
|
||||
const fetchCalls: CacheBypassFetchMetadataOptions[] = []
|
||||
const ctx = {
|
||||
fetch: async (pkgName: string, fetchOpts: CacheBypassFetchMetadataOptions) => {
|
||||
fetchCalls.push(fetchOpts)
|
||||
if (!fetchOpts.cacheBypass) return { notModified: true as const }
|
||||
return { meta, jsonText: JSON.stringify(meta), etag: '"fresh"' }
|
||||
},
|
||||
metaCache: createMetaCache(),
|
||||
cacheDir,
|
||||
}
|
||||
const spec: RegistryPackageSpec = { type: 'range', name: 'foo', fetchSpec: '>=1.0.0' }
|
||||
|
||||
const res = await pickPackage(ctx, spec, { registry: REGISTRY, dryRun: false, preferredVersionSelectors: undefined })
|
||||
expect(res.pickedPackage?.version).toBe('2.0.0')
|
||||
expect(fetchCalls).toHaveLength(2)
|
||||
expect(fetchCalls[0].etag).toBe('"etag-corrupt"')
|
||||
expect(fetchCalls[0].cacheBypass).toBe(false)
|
||||
expect(fetchCalls[1].cacheBypass).toBe(true)
|
||||
|
||||
// The mirror rewrite is fire-and-forget; wait for the poisoned bytes to be
|
||||
// replaced by the refetched document.
|
||||
/* eslint-disable no-await-in-loop -- polls until the fire-and-forget write lands */
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (!fs.readFileSync(pkgMirror).equals(corruptContent)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
expect(fs.readFileSync(pkgMirror).equals(corruptContent)).toBe(false)
|
||||
})
|
||||
|
||||
test('an exact-version spec over a corrupt fragment refetches instead of failing', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
writeCorruptMirror(getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, REGISTRY, 'foo'))
|
||||
|
||||
const meta = fooMeta()
|
||||
const ctx = {
|
||||
fetch: async (_pkgName: string, fetchOpts: FetchMetadataOptions & { cacheBypass?: boolean }) => {
|
||||
if (!fetchOpts.cacheBypass) return { notModified: true as const }
|
||||
return { meta, jsonText: JSON.stringify(meta), etag: '"fresh"' }
|
||||
},
|
||||
metaCache: createMetaCache(),
|
||||
cacheDir,
|
||||
}
|
||||
const spec: RegistryPackageSpec = { type: 'version', name: 'foo', fetchSpec: '2.0.0' }
|
||||
|
||||
const res = await pickPackage(ctx, spec, { registry: REGISTRY, dryRun: false, preferredVersionSelectors: undefined })
|
||||
expect(res.pickedPackage?.version).toBe('2.0.0')
|
||||
})
|
||||
@@ -102,9 +102,9 @@ test('cached metadata reports NO_OFFLINE_META without registry access when offli
|
||||
getMockAgent().assertNoPendingInterceptors()
|
||||
})
|
||||
|
||||
test('cached metadata names the pre-rename mirror when offline metadata exists only under the old layout', async () => {
|
||||
test('cached metadata names the legacy mirror when offline metadata exists only under the old layout', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
const legacyMirrorDir = path.join(cacheDir, FULL_META_DIR, 'registry.npmjs.org')
|
||||
const legacyMirrorDir = path.join(cacheDir, 'v11/metadata-full', 'https%3A+registry.npmjs.org')
|
||||
fs.mkdirSync(legacyMirrorDir, { recursive: true })
|
||||
const legacyMirror = path.join(legacyMirrorDir, 'is-positive.jsonl')
|
||||
fs.writeFileSync(legacyMirror, '{}\n{}')
|
||||
|
||||
@@ -1299,7 +1299,7 @@ test('offline resolution fails when package meta not found in the store', async
|
||||
|
||||
test('offline resolution failure names the legacy mirror when metadata is cached under the old layout', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
const legacyMirrorDir = path.join(cacheDir, ABBREVIATED_META_DIR, 'registry.npmjs.org')
|
||||
const legacyMirrorDir = path.join(cacheDir, 'v11/metadata', 'registry.npmjs.org')
|
||||
fs.mkdirSync(legacyMirrorDir, { recursive: true })
|
||||
const legacyMirror = path.join(legacyMirrorDir, 'is-positive.jsonl')
|
||||
fs.writeFileSync(legacyMirror, '{}\n{}')
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
saveMeta,
|
||||
UNVALIDATED_MIRROR_MAX_AGE_MS,
|
||||
} from '../src/pickPackage.js'
|
||||
import { parseNdjsonMeta } from './utils/index.js'
|
||||
|
||||
const REGISTRY = 'https://registry.npmjs.org/'
|
||||
|
||||
@@ -529,16 +530,13 @@ test('a stable cached range uses the canonical packument package name', async ()
|
||||
expect(result.pickedPackage?.name).toBe('@scope/foo')
|
||||
})
|
||||
|
||||
test('the raw response body is written verbatim to the disk mirror', async () => {
|
||||
test('the response body is mirrored in the indexed layout', async () => {
|
||||
const meta = fooMeta()
|
||||
// A body distinct from the compact JSON.stringify(meta) so we can prove the
|
||||
// mirror is written from the raw response text, not re-serialized.
|
||||
const rawBody = JSON.stringify(meta, null, 2)
|
||||
const cacheDir = temporaryDirectory()
|
||||
const pkgMirror = getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, REGISTRY, 'foo')
|
||||
|
||||
const ctx = {
|
||||
fetch: async () => ({ meta, jsonText: rawBody, etag: undefined }),
|
||||
fetch: async () => ({ meta, jsonText: JSON.stringify(meta), etag: undefined }),
|
||||
metaCache: createMetaCache(),
|
||||
cacheDir,
|
||||
}
|
||||
@@ -551,11 +549,13 @@ test('the raw response body is written verbatim to the disk mirror', async () =>
|
||||
|
||||
// The mirror is written fire-and-forget, so retry until it appears.
|
||||
const mirror = await readMirrorWithRetry(pkgMirror, 100)
|
||||
// The body after the headers line is the raw response text, unchanged.
|
||||
expect(mirror?.slice(mirror.indexOf('\n') + 1)).toBe(rawBody)
|
||||
expect(mirror?.startsWith('pnpm-meta-v1 ')).toBe(true)
|
||||
const persisted = parseNdjsonMeta<PackageMeta>(mirror!)
|
||||
expect(persisted.versions['1.0.0']).toStrictEqual(meta.versions['1.0.0'])
|
||||
expect(persisted['dist-tags']).toStrictEqual(meta['dist-tags'])
|
||||
})
|
||||
|
||||
test('projects sharing one in-flight fetch mirror the fetched body instead of re-serializing it', async () => {
|
||||
test('projects sharing one in-flight fetch encode the mirror once instead of per project', async () => {
|
||||
const meta = fooMeta()
|
||||
const rawBody = JSON.stringify(meta)
|
||||
const cacheDir = temporaryDirectory()
|
||||
@@ -591,27 +591,28 @@ test('projects sharing one in-flight fetch mirror the fetched body instead of re
|
||||
))
|
||||
expect(picks.every((pick) => pick.pickedPackage?.version === '1.0.0')).toBe(true)
|
||||
expect(fetches).toBe(1)
|
||||
// Re-serializing per project is what exhausted the heap: the body reaches
|
||||
// tens of MB for a popular package, and every project holds its own copy
|
||||
// until the mirror write limiter drains.
|
||||
const serializations = stringifySpy.mock.calls.filter(([value]) => value === meta).length
|
||||
expect(serializations).toBe(0)
|
||||
// Encoding per project is what exhausted the heap: the body reaches tens
|
||||
// of MB for a popular package, and every project holds its own copy until
|
||||
// the mirror write limiter drains.
|
||||
const versionSerializations = stringifySpy.mock.calls
|
||||
.filter(([value]) => value === meta || value === meta.versions['1.0.0'])
|
||||
.length
|
||||
expect(versionSerializations).toBe(1)
|
||||
} finally {
|
||||
stringifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a full document fetched for an optional dependency is condensed in memory while the mirror keeps the raw body', async () => {
|
||||
test('a full document fetched for an optional dependency is condensed in memory while the mirror keeps full version manifests', async () => {
|
||||
const meta = fooMeta()
|
||||
meta.versions['1.0.0'].libc = ['glibc']
|
||||
meta.versions['1.0.0'].scripts = { postinstall: 'node scripts/build.js' }
|
||||
;(meta as PackageMeta & { readme: string }).readme = '# a readme the size of a novel'
|
||||
meta.time = { '1.0.0': '2020-01-01T00:00:00.000Z' }
|
||||
const rawBody = JSON.stringify(meta)
|
||||
const cacheDir = temporaryDirectory()
|
||||
|
||||
const ctx = {
|
||||
fetch: async () => ({ meta, jsonText: rawBody, etag: undefined }),
|
||||
fetch: async () => ({ meta, jsonText: JSON.stringify(meta), etag: undefined }),
|
||||
metaCache: createMetaCache(),
|
||||
cacheDir,
|
||||
}
|
||||
@@ -624,10 +625,12 @@ test('a full document fetched for an optional dependency is condensed in memory
|
||||
expect((res.meta as PackageMeta & { readme?: string }).readme).toBeUndefined()
|
||||
expect(ctx.metaCache.get(getPkgMetaCacheKey(REGISTRY, 'foo', true, false))).toBe(res.meta)
|
||||
|
||||
// The full-metadata mirror still receives the raw response body.
|
||||
// The full-metadata mirror keeps unstripped version manifests.
|
||||
const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, REGISTRY, 'foo')
|
||||
const mirror = await readMirrorWithRetry(pkgMirror, 100)
|
||||
expect(mirror?.slice(mirror.indexOf('\n') + 1)).toBe(rawBody)
|
||||
const persisted = parseNdjsonMeta<PackageMeta>(mirror!)
|
||||
expect(persisted.versions['1.0.0'].scripts).toStrictEqual({ postinstall: 'node scripts/build.js' })
|
||||
expect(persisted.time).toEqual({ '1.0.0': '2020-01-01T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
test('a mirror holding a full document is condensed when promoted into the in-memory cache', async () => {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { expect, test } from '@jest/globals'
|
||||
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
import { temporaryDirectory } from 'tempy'
|
||||
|
||||
import { clearMeta } from '../src/clearMeta.js'
|
||||
import {
|
||||
hasVersionManifest,
|
||||
isMalformedMirrorFragmentError,
|
||||
loadMeta,
|
||||
loadMetaHeaders,
|
||||
prepareIndexedForDisk,
|
||||
prepareJsonForDisk,
|
||||
saveMeta,
|
||||
} from '../src/mirrorLayout.js'
|
||||
|
||||
function fixtureMeta (): PackageMeta {
|
||||
return {
|
||||
name: 'foo',
|
||||
'dist-tags': { latest: '2.0.0' },
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
dist: { integrity: 'sha512-aaa', shasum: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', tarball: 'https://registry.example/foo/-/foo-1.0.0.tgz' },
|
||||
dependencies: { bar: '^1.0.0' },
|
||||
description: 'not part of the abbreviated field set',
|
||||
} as PackageInRegistry,
|
||||
'2.0.0': {
|
||||
name: 'foo',
|
||||
version: '2.0.0',
|
||||
dist: { integrity: 'sha512-bbb', shasum: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', tarball: 'https://registry.example/foo/-/foo-2.0.0.tgz' },
|
||||
} as PackageInRegistry,
|
||||
},
|
||||
time: {
|
||||
'1.0.0': '2020-01-01T00:00:00.000Z',
|
||||
'2.0.0': '2021-01-01T00:00:00.000Z',
|
||||
modified: '2021-01-01T00:00:00.000Z',
|
||||
},
|
||||
modified: '2021-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
test('indexed mirror round-trips through save and load', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(meta, '"etag-1"'))
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded!.name).toBe('foo')
|
||||
expect(loaded!['dist-tags']).toStrictEqual({ latest: '2.0.0' })
|
||||
expect(loaded!.etag).toBe('"etag-1"')
|
||||
expect(loaded!.modified).toBe('2021-01-01T00:00:00.000Z')
|
||||
expect(loaded!.time).toStrictEqual(meta.time)
|
||||
expect(Object.keys(loaded!.versions)).toStrictEqual(['1.0.0', '2.0.0'])
|
||||
expect(loaded!.versions['1.0.0']).toStrictEqual(meta.versions['1.0.0'])
|
||||
expect(loaded!.versions['2.0.0']).toStrictEqual(meta.versions['2.0.0'])
|
||||
})
|
||||
|
||||
test('hydrated manifests keep one identity and accept mutation', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(fixtureMeta(), undefined))
|
||||
|
||||
const loaded = (await loadMeta(pkgMirror))!
|
||||
const first = loaded.versions['1.0.0']
|
||||
expect(loaded.versions['1.0.0']).toBe(first)
|
||||
first.name = 'renamed'
|
||||
expect(loaded.versions['1.0.0'].name).toBe('renamed')
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(loaded.versions, '1.0.0')!
|
||||
const copy: PackageMeta['versions'] = {}
|
||||
Object.defineProperty(copy, '1.0.0', descriptor)
|
||||
expect(copy['1.0.0']).toBe(first)
|
||||
})
|
||||
|
||||
test('condensing load strips manifests to the abbreviated field set and marks the document', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(fixtureMeta(), undefined))
|
||||
|
||||
const loaded = (await loadMeta(pkgMirror, { condense: true }))!
|
||||
expect(clearMeta(loaded)).toBe(loaded)
|
||||
expect(loaded.versions['1.0.0'].description).toBeUndefined()
|
||||
expect(loaded.versions['1.0.0'].dependencies).toStrictEqual({ bar: '^1.0.0' })
|
||||
})
|
||||
|
||||
test('NDJSON mirror still loads', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
await saveMeta(pkgMirror, prepareJsonForDisk(meta, '"etag-2"'))
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded!.etag).toBe('"etag-2"')
|
||||
expect(loaded!.versions['1.0.0']).toStrictEqual(meta.versions['1.0.0'])
|
||||
})
|
||||
|
||||
test('a truncated indexed mirror reads as a cache miss', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const content = prepareIndexedForDisk(fixtureMeta(), undefined)
|
||||
fs.mkdirSync(path.dirname(pkgMirror), { recursive: true })
|
||||
fs.writeFileSync(pkgMirror, content.subarray(0, content.length - 10))
|
||||
|
||||
expect(await loadMeta(pkgMirror)).toBeNull()
|
||||
})
|
||||
|
||||
test('the index record carries the package-level homepage the Rust stack reads', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = { ...fixtureMeta(), homepage: 'https://example.com/foo' }
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(meta, undefined))
|
||||
|
||||
const written = fs.readFileSync(pkgMirror, 'utf8')
|
||||
expect(written).toContain('"homepage":"https://example.com/foo"')
|
||||
})
|
||||
|
||||
test('an indexed mirror declaring an absurd headers length reads as a cache miss', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
fs.mkdirSync(path.dirname(pkgMirror), { recursive: true })
|
||||
fs.writeFileSync(pkgMirror, `pnpm-meta-v1 ${Number.MAX_SAFE_INTEGER} 0\n{}`)
|
||||
|
||||
expect(await loadMetaHeaders(pkgMirror)).toBeNull()
|
||||
expect(await loadMeta(pkgMirror)).toBeNull()
|
||||
})
|
||||
|
||||
test('loadMetaHeaders reads both layouts', async () => {
|
||||
const dir = temporaryDirectory()
|
||||
const indexed = path.join(dir, 'indexed.jsonl')
|
||||
const ndjson = path.join(dir, 'ndjson.jsonl')
|
||||
await saveMeta(indexed, prepareIndexedForDisk(fixtureMeta(), '"etag-3"'))
|
||||
await saveMeta(ndjson, prepareJsonForDisk(fixtureMeta(), '"etag-4"'))
|
||||
|
||||
expect(await loadMetaHeaders(indexed)).toStrictEqual({ etag: '"etag-3"', modified: '2021-01-01T00:00:00.000Z' })
|
||||
expect(await loadMetaHeaders(ndjson)).toStrictEqual({ etag: '"etag-4"', modified: '2021-01-01T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
test('an eager load reads a corrupt fragment as a cache miss', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
const content = corruptFragmentOf(meta, '1.0.0')
|
||||
fs.mkdirSync(path.dirname(pkgMirror), { recursive: true })
|
||||
fs.writeFileSync(pkgMirror, content)
|
||||
|
||||
expect(await loadMeta(pkgMirror, { hydrateEagerly: true })).toBeNull()
|
||||
})
|
||||
|
||||
test('a corrupt fragment throws on access without breaking the rest of the document', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
fs.mkdirSync(path.dirname(pkgMirror), { recursive: true })
|
||||
fs.writeFileSync(pkgMirror, corruptFragmentOf(meta, '1.0.0'))
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(Object.keys(loaded!.versions)).toStrictEqual(['1.0.0', '2.0.0'])
|
||||
let thrown: unknown
|
||||
try {
|
||||
void loaded!.versions['1.0.0']
|
||||
} catch (err: unknown) {
|
||||
thrown = err
|
||||
}
|
||||
expect(isMalformedMirrorFragmentError(thrown)).toBe(true)
|
||||
expect(loaded!.versions['2.0.0']).toStrictEqual(meta.versions['2.0.0'])
|
||||
})
|
||||
|
||||
// Serializes `meta` and overwrites the leading bytes of one version's fragment
|
||||
// in place, so the file length and the index spans stay intact and only the
|
||||
// fragment's own bytes fail to parse.
|
||||
function corruptFragmentOf (meta: PackageMeta, version: string): Buffer {
|
||||
const content = prepareIndexedForDisk(meta, undefined)
|
||||
const fragmentStart = content.indexOf(Buffer.from(JSON.stringify(meta.versions[version])))
|
||||
if (fragmentStart === -1) throw new Error(`fragment of ${version} not found in the serialized mirror`)
|
||||
content.fill('x', fragmentStart, fragmentStart + 10)
|
||||
return content
|
||||
}
|
||||
|
||||
test('the indexed headers record carries fullEtag and uncacheable', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(fixtureMeta(), undefined, { fullEtag: '"full-1"', uncacheable: true }))
|
||||
|
||||
expect(await loadMetaHeaders(pkgMirror)).toStrictEqual({
|
||||
fullEtag: '"full-1"',
|
||||
modified: '2021-01-01T00:00:00.000Z',
|
||||
uncacheable: true,
|
||||
})
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded!.etag).toBeUndefined()
|
||||
expect(loaded!.fullEtag).toBe('"full-1"')
|
||||
expect(loaded!.uncacheable).toBe(true)
|
||||
})
|
||||
|
||||
test('an indexed mirror drops a time map that misses a version', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
delete meta.time!['1.0.0']
|
||||
await saveMeta(pkgMirror, prepareIndexedForDisk(meta, undefined))
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded!.time).toBeUndefined()
|
||||
})
|
||||
|
||||
test('a well-formed fragment of the wrong shape reads as no manifest', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const headers = '{}'
|
||||
const fragment = '[1,2]'
|
||||
const index = JSON.stringify({ name: 'foo', distTags: {}, versions: [['1.0.0', 0, fragment.length]] })
|
||||
fs.writeFileSync(pkgMirror, `pnpm-meta-v1 ${headers.length} ${index.length}\n${headers}${index}${fragment}`)
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(loaded!.versions['1.0.0']).toBeNull()
|
||||
})
|
||||
|
||||
test('a fragment with malformed UTF-8 is malformed', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const headers = '{}'
|
||||
const fragment = Buffer.concat([Buffer.from('{"name":"'), Buffer.from([0xff]), Buffer.from('"}')])
|
||||
const index = JSON.stringify({ name: 'foo', distTags: {}, versions: [['1.0.0', 0, fragment.length]] })
|
||||
fs.writeFileSync(pkgMirror, Buffer.concat([Buffer.from(`pnpm-meta-v1 ${headers.length} ${index.length}\n${headers}${index}`), fragment]))
|
||||
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
expect(() => loaded!.versions['1.0.0']).toThrow(expect.objectContaining({ code: 'ERR_PNPM_MALFORMED_META_FRAGMENT' }))
|
||||
})
|
||||
|
||||
test('a format line whose lengths are not whole numbers is a cache miss', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const headers = '{}'
|
||||
const index = JSON.stringify({ name: 'foo', distTags: {}, versions: [] })
|
||||
fs.writeFileSync(pkgMirror, `pnpm-meta-v1 ${headers.length}junk ${index.length}\n${headers}${index}`)
|
||||
|
||||
expect(await loadMeta(pkgMirror)).toBeNull()
|
||||
expect(await loadMetaHeaders(pkgMirror)).toBeNull()
|
||||
})
|
||||
|
||||
test('hasVersionManifest reads presence without parsing a lazy manifest', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
await saveMeta(pkgMirror, corruptFragmentOf(fixtureMeta(), '2.0.0'))
|
||||
const loaded = await loadMeta(pkgMirror)
|
||||
|
||||
expect(hasVersionManifest(loaded!.versions, '2.0.0')).toBe(true)
|
||||
expect(hasVersionManifest(loaded!.versions, '3.0.0')).toBe(false)
|
||||
expect(hasVersionManifest({ '1.0.0': null } as unknown as PackageMeta['versions'], '1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test('an index record over 64 MiB is a cache miss', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const headers = '{}'
|
||||
// Valid JSON once the padding is skipped, so only the size bound rejects it.
|
||||
const index = `${' '.repeat(64 * 1024 * 1024)}{"name":"foo","distTags":{},"versions":[]}`
|
||||
fs.writeFileSync(pkgMirror, `pnpm-meta-v1 ${headers.length} ${index.length}\n${headers}${index}`)
|
||||
|
||||
expect(await loadMeta(pkgMirror)).toBeNull()
|
||||
})
|
||||
|
||||
test('whole-packument readers skip a version without a manifest', async () => {
|
||||
const pkgMirror = path.join(temporaryDirectory(), 'foo.jsonl')
|
||||
const meta = fixtureMeta()
|
||||
const content = prepareIndexedForDisk(meta, undefined)
|
||||
// Same length as the 2.0.0 fragment, but a JSON array.
|
||||
const fragment = Buffer.from(JSON.stringify(meta.versions['2.0.0']))
|
||||
const fragmentStart = content.indexOf(fragment)
|
||||
content.write(`[${' '.repeat(fragment.length - 2)}]`, fragmentStart)
|
||||
await saveMeta(pkgMirror, content)
|
||||
|
||||
const loaded = await loadMeta(pkgMirror, { hydrateEagerly: true })
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded!.versions['2.0.0']).toBeNull()
|
||||
expect(Object.keys(loaded!.versions)).toStrictEqual(['1.0.0', '2.0.0'])
|
||||
})
|
||||
@@ -88,20 +88,21 @@ test('a full document served for an abbreviated request is normalized before cac
|
||||
expect(savedVersion.dist).toBeDefined()
|
||||
})
|
||||
|
||||
test('a document served with the abbreviated content type is cached verbatim (registry honored the Accept header)', async () => {
|
||||
test('a document served with the abbreviated content type is cached without stripping version fields (registry honored the Accept header)', async () => {
|
||||
const cacheDir = temporaryDirectory()
|
||||
|
||||
// The abbreviated document a spec-compliant registry (e.g. npm) returns. It
|
||||
// also carries a custom top-level field to prove the body is stored verbatim
|
||||
// (no re-serialization / stripping) on the happy path.
|
||||
// The abbreviated document a spec-compliant registry (e.g. npm) returns. A
|
||||
// version manifest carries a custom field to prove the mirror preserves
|
||||
// version data it doesn't know about (only top-level fields outside the
|
||||
// indexed layout's records are dropped).
|
||||
const abbreviatedDoc = {
|
||||
name: 'foo',
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
_cacheUntouchedMarker: 'kept-verbatim',
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
_cacheUntouchedMarker: 'kept-verbatim',
|
||||
dependencies: { bar: '^1.0.0' },
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz',
|
||||
@@ -126,8 +127,8 @@ test('a document served with the abbreviated content type is cached verbatim (re
|
||||
expect(res!.id).toBe('foo@1.0.0')
|
||||
|
||||
const cachePath = path.join(cacheDir, `${ABBREVIATED_META_DIR}/https%3A+registry.npmjs.org/foo.jsonl`)
|
||||
// The registry body is stored untouched: no field stripping and no
|
||||
// re-serialization on the honored-header happy path.
|
||||
const saved = await retryLoadJsonFile<{ _cacheUntouchedMarker?: string }>(cachePath)
|
||||
expect(saved._cacheUntouchedMarker).toBe('kept-verbatim')
|
||||
// Version manifests are mirrored without field stripping on the
|
||||
// honored-header happy path.
|
||||
const saved = await retryLoadJsonFile<{ versions: Record<string, { _cacheUntouchedMarker?: string }> }>(cachePath)
|
||||
expect(saved.versions['1.0.0']._cacheUntouchedMarker).toBe('kept-verbatim')
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from '@jest/globals'
|
||||
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
|
||||
|
||||
import { pickVersionByVersionRange } from '../src/pickPackageFromMeta.js'
|
||||
|
||||
// Builds a meta whose versions hydrate through getters, like a lazily-loaded
|
||||
// indexed mirror does, recording which versions were hydrated. A null manifest
|
||||
// input models a version key the document lists without a manifest.
|
||||
function lazyMeta (manifests: Record<string, PackageInRegistry | null>): { meta: PackageMeta, hydrated: string[] } {
|
||||
const hydrated: string[] = []
|
||||
const versions: PackageMeta['versions'] = Object.create(null)
|
||||
for (const [version, manifest] of Object.entries(manifests)) {
|
||||
Object.defineProperty(versions, version, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get (): PackageInRegistry | undefined {
|
||||
hydrated.push(version)
|
||||
return manifest ?? undefined
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
meta: { name: 'foo', 'dist-tags': {}, versions },
|
||||
hydrated,
|
||||
}
|
||||
}
|
||||
|
||||
function manifest (version: string, deprecated?: string): PackageInRegistry {
|
||||
return { name: 'foo', version, deprecated } as PackageInRegistry
|
||||
}
|
||||
|
||||
test('the deprecated fallback hydrates only versions that satisfy the range', () => {
|
||||
const { meta, hydrated } = lazyMeta({
|
||||
'1.0.0': manifest('1.0.0'),
|
||||
'2.0.0': manifest('2.0.0'),
|
||||
'2.0.1': manifest('2.0.1', 'use 3.x'),
|
||||
})
|
||||
|
||||
expect(pickVersionByVersionRange({ meta, versionRange: '^2.0.0' })).toBe('2.0.0')
|
||||
expect(hydrated).not.toContain('1.0.0')
|
||||
})
|
||||
|
||||
test('a missing manifest on the max satisfying version fails the pick instead of crashing', () => {
|
||||
const { meta } = lazyMeta({
|
||||
'1.0.0': manifest('1.0.0'),
|
||||
'2.0.0': null,
|
||||
})
|
||||
|
||||
// The version is still chosen (the picker's missing-manifest handling turns
|
||||
// it into a failed pick downstream); the deprecated check must not crash.
|
||||
expect(pickVersionByVersionRange({ meta, versionRange: '>=1.0.0' })).toBe('2.0.0')
|
||||
})
|
||||
|
||||
test('a missing manifest is not offered as the non-deprecated alternative', () => {
|
||||
const { meta } = lazyMeta({
|
||||
'2.0.0': null,
|
||||
'2.0.1': manifest('2.0.1', 'use 3.x'),
|
||||
})
|
||||
|
||||
expect(pickVersionByVersionRange({ meta, versionRange: '^2.0.0' })).toBe('2.0.1')
|
||||
})
|
||||
@@ -996,6 +996,7 @@ test('the release-age upgrade of a validated mirror records the full etag', asyn
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the test reads arbitrary fields of the cached document */
|
||||
const persistedMeta = await retryLoadJsonFile<any>(cachePath, (meta) => meta.time != null)
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
expect(persistedMeta.etag).toBeUndefined()
|
||||
expect(persistedMeta.fullEtag).toBe('"full-etag"')
|
||||
// `modified` is the packument's own `time.modified`, identical in both
|
||||
|
||||
@@ -31,14 +31,38 @@ export async function retryLoadJsonFile<Parsed> (filePath: string, isReady?: (da
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an NDJSON cache file: line 1 = headers, line 2 = metadata.
|
||||
* The headers (etag, modified) are merged into the metadata object.
|
||||
* Parses a mirror cache file in either layout: the indexed
|
||||
* `pnpm-meta-v1` form (headers record, index record, version fragments)
|
||||
* or two-line NDJSON (line 1 = headers, line 2 = metadata). The headers
|
||||
* are merged into the metadata object, and versions are materialized
|
||||
* eagerly so tests can assert on plain objects.
|
||||
*/
|
||||
export function parseNdjsonMeta<Parsed> (data: string): Parsed {
|
||||
const newlineIdx = data.indexOf('\n')
|
||||
const buf = Buffer.from(data, 'utf8')
|
||||
const newlineIdx = buf.indexOf(10)
|
||||
if (newlineIdx === -1) return JSON.parse(data) as Parsed
|
||||
const headers = JSON.parse(data.slice(0, newlineIdx))
|
||||
const meta = JSON.parse(data.slice(newlineIdx + 1))
|
||||
const firstLine = buf.toString('utf8', 0, newlineIdx)
|
||||
const formatMatch = /^pnpm-meta-v1 (\d+) (\d+)$/.exec(firstLine)
|
||||
if (formatMatch == null) {
|
||||
const headers = JSON.parse(firstLine)
|
||||
const meta = JSON.parse(buf.toString('utf8', newlineIdx + 1))
|
||||
return { ...meta, ...headers } as Parsed
|
||||
}
|
||||
const headersStart = newlineIdx + 1
|
||||
const indexStart = headersStart + Number.parseInt(formatMatch[1], 10)
|
||||
const fragmentBase = indexStart + Number.parseInt(formatMatch[2], 10)
|
||||
const headers = JSON.parse(buf.toString('utf8', headersStart, indexStart))
|
||||
const index = JSON.parse(buf.toString('utf8', indexStart, fragmentBase))
|
||||
const versions: Record<string, unknown> = {}
|
||||
for (const [version, offset, length] of index.versions as Array<[string, number, number]>) {
|
||||
versions[version] = JSON.parse(buf.toString('utf8', fragmentBase + offset, fragmentBase + offset + length))
|
||||
}
|
||||
const meta: Record<string, unknown> = {
|
||||
name: index.name,
|
||||
'dist-tags': index.distTags ?? {},
|
||||
versions,
|
||||
}
|
||||
if (index.time != null) meta.time = index.time
|
||||
return { ...meta, ...headers } as Parsed
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,11 @@ function filterKeptVersions (
|
||||
for (const version in versions) {
|
||||
if (!Object.hasOwn(versions, version)) continue
|
||||
if (keep(version)) {
|
||||
keptVersions[version] = versions[version]
|
||||
// Copy the property descriptor, not the value: a lazily-loaded packument
|
||||
// (see npm-resolver's mirrorLayout module) backs each version with a
|
||||
// getter that parses its manifest on first access, and reading the value
|
||||
// here would parse every kept version of every packument up front.
|
||||
Object.defineProperty(keptVersions, version, Object.getOwnPropertyDescriptor(versions, version)!)
|
||||
}
|
||||
}
|
||||
return keptVersions
|
||||
@@ -116,7 +120,9 @@ function resolveKeptDistTags (
|
||||
for (const tag in allDistTags) {
|
||||
if (!Object.hasOwn(allDistTags, tag)) continue
|
||||
const distTagVersion = allDistTags[tag]
|
||||
if (keptVersions[distTagVersion]) {
|
||||
// `in`, not a value read: presence is all that matters, and reading the
|
||||
// value would hydrate a lazily-loaded manifest for nothing.
|
||||
if (distTagVersion in keptVersions) {
|
||||
keptDistTags[tag] = distTagVersion
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function prune ({ cacheDir, storeDir, storeIndex }: PruneOptions, r
|
||||
}
|
||||
|
||||
async function removeCachedMetadata (cacheDir: string, storeDir: string): Promise<void> {
|
||||
// Metadata dirs may be at top level (legacy metadata-*) or under a version prefix (v11/metadata*)
|
||||
// Metadata dirs may be at top level (legacy metadata-*) or under a version prefix (vN/metadata*)
|
||||
const metadataDirs = await getSubdirsSafely(cacheDir)
|
||||
await Promise.all(metadataDirs.map(async (metadataDir) => {
|
||||
if (!metadataDir.startsWith('metadata') && !/^v\d+$/.test(metadataDir)) return
|
||||
|
||||
Reference in new issue
Block a user