perf(git-fetcher): bundle the manifest into git-hosted store-index rows (#17369)
The git fetchers wrote store-index rows with no bundled manifest, so the warm prefetch re-derived `requiresBuild` from the row's files on every install, reading and parsing `package.json` once per git dependency, and the cached git prepare check could read it again. `queue_files_index` now reads the manifest once, derives `requiresBuild` from it and the file list, and stores the normalized manifest in the row, as pnpm v11 does. Rows with a bundled manifest keep their stored `requiresBuild`; only rows written by older releases are re-derived. `normalize_bundled_manifest` moves from `pnpm-tarball` to `pnpm-package-manifest` so the git fetcher can use it. The manifest-plus-files build check moves there too as `requires_build_from_files`, next to a new `read_package_json_from_cas_paths` that `requires_build_from_cas_paths` and the warm git prepare check now share. Closes pnpm/tasks#188
This commit is contained in:
1 parent
9861889a74
commit
39a96abcfc
15 files changed
+313
-303
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"pacquet": patch
|
||||
---
|
||||
|
||||
Warm installs no longer read the `package.json` of every git-hosted dependency. pnpm now stores the manifest of a git-hosted package in the store index when it fetches the package.
|
||||
@@ -10,13 +10,12 @@ use super::{
|
||||
use crate::{CasPathsByPkgId, InstallPackageBySnapshotError};
|
||||
use pnpm_git_fetcher::{GitFetcherError, resolve_package_build_permission};
|
||||
use pnpm_lockfile::{LockfileResolution, PackageKey, PackageMetadata, PkgName};
|
||||
use pnpm_package_manifest::parse_manifest;
|
||||
use pnpm_package_manifest::read_package_json_from_cas_paths;
|
||||
use pnpm_reporter::Reporter;
|
||||
use pnpm_tarball::PrefetchResult;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -102,9 +101,7 @@ pub(super) fn cached_git_manifest<'a>(
|
||||
if let Some(manifest) = prefetch.manifests.get(key) {
|
||||
return Some(Cow::Borrowed(manifest.as_ref()));
|
||||
}
|
||||
let package_json = cas_paths.get("package.json")?;
|
||||
let contents = fs::read_to_string(package_json).ok()?;
|
||||
parse_manifest(&contents).ok().map(Cow::Owned)
|
||||
read_package_json_from_cas_paths(cas_paths).map(Cow::Owned)
|
||||
}
|
||||
pub(super) fn is_git_hosted_resolution(resolution: &LockfileResolution) -> bool {
|
||||
match resolution {
|
||||
|
||||
@@ -33,8 +33,8 @@ use crate::{
|
||||
use pnpm_fs_packlist::packlist;
|
||||
use pnpm_network::{redact_and_sanitize, redact_and_sanitize_multiline};
|
||||
use pnpm_package_manifest::{
|
||||
requires_build_from_cas_paths, safe_read_package_json_from_dir,
|
||||
safe_read_project_manifest_from_dir,
|
||||
normalize_bundled_manifest, read_package_json_from_cas_paths, requires_build_from_files,
|
||||
safe_read_package_json_from_dir, safe_read_project_manifest_from_dir,
|
||||
};
|
||||
use pnpm_reporter::Reporter;
|
||||
use pnpm_store_dir::{CafsFileInfo, PackageFilesIndex, StoreIndexWriter};
|
||||
@@ -196,8 +196,9 @@ pub(crate) fn packlist_of(pkg_dir: &Path) -> Result<Vec<String>, GitFetcherError
|
||||
/// Queue a `PackageFilesIndex` row so a future install's warm prefetch
|
||||
/// finds the snapshot in `index.db` and skips the fetch and prepare re-run.
|
||||
///
|
||||
/// `requiresBuild` is read off the stored files, as for any other package,
|
||||
/// since every virtual-store slot they are imported into runs that build.
|
||||
/// The row bundles the package's normalized manifest, and `requiresBuild` is
|
||||
/// read off it and the stored files, as for any other package, since every
|
||||
/// virtual-store slot they are imported into runs that build.
|
||||
/// `requires_prepare` is whether the package needs `prepare`.
|
||||
pub(crate) fn queue_files_index(
|
||||
writer: Option<&Arc<StoreIndexWriter>>,
|
||||
@@ -207,11 +208,15 @@ pub(crate) fn queue_files_index(
|
||||
requires_prepare: bool,
|
||||
) {
|
||||
if let Some(writer) = writer {
|
||||
let manifest = read_package_json_from_cas_paths(cas_paths);
|
||||
writer.queue(
|
||||
files_index_file.to_string(),
|
||||
PackageFilesIndex {
|
||||
manifest: None,
|
||||
requires_build: Some(requires_build_from_cas_paths(cas_paths)),
|
||||
requires_build: Some(requires_build_from_files(
|
||||
manifest.as_ref(),
|
||||
cas_paths.keys(),
|
||||
)),
|
||||
manifest: manifest.as_ref().and_then(normalize_bundled_manifest),
|
||||
requires_prepare: Some(requires_prepare),
|
||||
algo: "sha512".to_string(),
|
||||
files,
|
||||
|
||||
@@ -415,6 +415,7 @@ async fn writes_index_row_when_writer_provided() {
|
||||
.expect("row must exist at the git-hosted key");
|
||||
assert_eq!(row.algo, "sha512");
|
||||
assert_eq!(row.requires_build, Some(false));
|
||||
assert_eq!(row.manifest, Some(serde_json::json!({ "name": "x", "version": "1.0.0" })));
|
||||
let keys: Vec<&str> = row.files
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
|
||||
@@ -166,12 +166,33 @@ where
|
||||
/// script in its `package.json`.
|
||||
#[must_use]
|
||||
pub fn requires_build_from_cas_paths(cas_paths: &HashMap<String, PathBuf>) -> bool {
|
||||
let mut triggers = files_build_triggers(cas_paths.keys());
|
||||
if let Some(package_json) = cas_paths.get("package.json")
|
||||
&& let Ok(contents) = fs::read_to_string(package_json)
|
||||
&& let Ok(manifest) = parse_manifest(&contents)
|
||||
{
|
||||
triggers.read_manifest(&manifest);
|
||||
requires_build_from_files(
|
||||
read_package_json_from_cas_paths(cas_paths).as_ref(),
|
||||
cas_paths.keys(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The package's `package.json`, read from the store file `cas_paths` maps it
|
||||
/// to. `None` when the package has none, or it cannot be read or parsed.
|
||||
#[must_use]
|
||||
pub fn read_package_json_from_cas_paths(cas_paths: &HashMap<String, PathBuf>) -> Option<Value> {
|
||||
let contents = fs::read_to_string(cas_paths.get("package.json")?).ok()?;
|
||||
parse_manifest(&contents).ok()
|
||||
}
|
||||
|
||||
/// Whether a package with this manifest and these files needs a build pass.
|
||||
#[must_use]
|
||||
pub fn requires_build_from_files<Filenames, Filename>(
|
||||
manifest: Option<&Value>,
|
||||
filenames: Filenames,
|
||||
) -> bool
|
||||
where
|
||||
Filenames: IntoIterator<Item = Filename>,
|
||||
Filename: AsRef<str>,
|
||||
{
|
||||
let mut triggers = files_build_triggers(filenames);
|
||||
if let Some(manifest) = manifest {
|
||||
triggers.read_manifest(manifest);
|
||||
}
|
||||
triggers.requires_build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/// Pick the `package.json` fields downstream code actually reads — bin
|
||||
/// linking, dependency resolution, build-script detection — and discard
|
||||
/// the rest, keeping only the lifecycle hooks pnpm executes out
|
||||
/// of `scripts`.
|
||||
///
|
||||
/// The subset exists to bound what lands in `index.db`: a full manifest
|
||||
/// runs to tens of KB, and msgpackr-records tops out at `0x7f` record
|
||||
/// slots.
|
||||
///
|
||||
/// `None` rather than an empty object when nothing survives, which
|
||||
/// would otherwise round-trip as a zero-field record def.
|
||||
#[must_use]
|
||||
pub fn normalize_bundled_manifest(value: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
/// Fields kept verbatim from the source manifest.
|
||||
///
|
||||
/// Order matters for the on-wire byte sequence — msgpackr emits
|
||||
/// fields in JS object insertion order, and pacquet's encoder
|
||||
/// follows the [`serde_json::Map`] iteration order — but it
|
||||
/// does *not* matter for property-access correctness on the
|
||||
/// pnpm side. The order below matches the field order pnpm
|
||||
/// emits so a side-by-side byte diff against a pnpm-written
|
||||
/// row is shallower.
|
||||
const BUNDLED_MANIFEST_FIELDS: &[&str] = &[
|
||||
"bin",
|
||||
"bundledDependencies",
|
||||
"bundleDependencies",
|
||||
"cpu",
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"directories",
|
||||
"engines",
|
||||
"gypfile",
|
||||
"libc",
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"os",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
];
|
||||
const LIFECYCLE_SCRIPTS: &[&str] = &["preinstall", "install", "postinstall"];
|
||||
|
||||
let serde_json::Value::Object(map) = value else { return None };
|
||||
let mut picked = serde_json::Map::new();
|
||||
|
||||
// pnpm emits `version` first regardless of whether it was first
|
||||
// in the source object. Version normalization
|
||||
// via `semver.clean(...)` (pnpm only loose-cleans for the bundled
|
||||
// row, not for resolution) is intentionally skipped: the inputs
|
||||
// from a real npm tarball are already semver-clean in practice,
|
||||
// and pulling `node-semver` into this crate purely for
|
||||
// this normalization would carry more risk than the deviation it
|
||||
// closes.
|
||||
pick_fields(&mut picked, map, &["version"]);
|
||||
pick_fields(&mut picked, map, BUNDLED_MANIFEST_FIELDS);
|
||||
|
||||
if let Some(serde_json::Value::Object(scripts)) = map.get("scripts") {
|
||||
let mut sub = serde_json::Map::new();
|
||||
pick_fields(&mut sub, scripts, LIFECYCLE_SCRIPTS);
|
||||
if !sub.is_empty() {
|
||||
picked.insert("scripts".to_string(), serde_json::Value::Object(sub));
|
||||
}
|
||||
}
|
||||
|
||||
if picked.is_empty() { None } else { Some(serde_json::Value::Object(picked)) }
|
||||
}
|
||||
|
||||
/// Copy the named fields that `source` actually carries, in the order given.
|
||||
/// A null is treated as absent: pnpm's own row omits it.
|
||||
fn pick_fields(
|
||||
picked: &mut serde_json::Map<String, serde_json::Value>,
|
||||
source: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) {
|
||||
for &key in keys {
|
||||
if let Some(value) = source.get(key)
|
||||
&& !value.is_null()
|
||||
{
|
||||
picked.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ pub use bins::{
|
||||
pub use build_triggers::{
|
||||
BINDING_GYP, BuildTriggers, files_build_triggers, files_include_install_scripts,
|
||||
manifest_opts_out_of_gyp_build, manifest_requires_build, pkg_build_triggers,
|
||||
pkg_requires_build, requires_build_from_cas_paths, stored_requires_build_needs_manifest_check,
|
||||
pkg_requires_build, read_package_json_from_cas_paths, requires_build_from_cas_paths,
|
||||
requires_build_from_files, stored_requires_build_needs_manifest_check,
|
||||
};
|
||||
pub use bundled_manifest::normalize_bundled_manifest;
|
||||
pub use error::PackageManifestError;
|
||||
pub use initialization::{InitAuthor, InitOptions};
|
||||
pub use metadata::{extract_author, extract_homepage, extract_license};
|
||||
@@ -46,6 +48,7 @@ use tempfile::NamedTempFile;
|
||||
mod bins;
|
||||
mod blank_lines;
|
||||
mod build_triggers;
|
||||
mod bundled_manifest;
|
||||
mod error;
|
||||
mod initialization;
|
||||
mod json5;
|
||||
|
||||
@@ -98,3 +98,5 @@ mod lockfile;
|
||||
mod yaml;
|
||||
|
||||
mod json5;
|
||||
|
||||
mod bundled_manifest;
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Pacquet's [`normalize_bundled_manifest`] picks the subset of
|
||||
//! `package.json` fields downstream install code reads (bin lookup,
|
||||
//! peer extraction, build-script detection) and narrows `scripts` to
|
||||
//! the lifecycle hooks. Two cases are intentionally NOT covered:
|
||||
//! `semver.clean` normalization (pacquet keeps version verbatim, per
|
||||
//! the function's doc comment) and the missing-version default of
|
||||
//! `0.0.0` (pacquet leaves the field absent rather than synthesizing
|
||||
//! one).
|
||||
|
||||
use crate::normalize_bundled_manifest;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_empty_manifest() {
|
||||
assert_eq!(normalize_bundled_manifest(&json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_non_object() {
|
||||
assert_eq!(normalize_bundled_manifest(&json!("not an object")), None);
|
||||
assert_eq!(normalize_bundled_manifest(&json!(null)), None);
|
||||
assert_eq!(normalize_bundled_manifest(&json!(42)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_manifest_has_only_excluded_fields() {
|
||||
assert_eq!(
|
||||
normalize_bundled_manifest(&json!({
|
||||
"description": "a package",
|
||||
"keywords": ["test"],
|
||||
"license": "MIT",
|
||||
"author": "test",
|
||||
"repository": "test/test",
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_included_fields_and_excludes_others() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"description": "should be excluded",
|
||||
"license": "MIT",
|
||||
"bin": { "foo": "./bin/foo.js" },
|
||||
"engines": { "node": ">=18" },
|
||||
"cpu": ["x64"],
|
||||
"os": ["linux"],
|
||||
"libc": ["glibc"],
|
||||
"dependencies": { "bar": "^1.0.0" },
|
||||
"devDependencies": { "qux": "^3.0.0" },
|
||||
"optionalDependencies": { "baz": "^2.0.0" },
|
||||
"peerDependencies": { "react": "^18" },
|
||||
"peerDependenciesMeta": { "react": { "optional": true } },
|
||||
"bundledDependencies": ["bar"],
|
||||
"directories": { "bin": "./bin" },
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
let map = result.as_object().expect("object");
|
||||
assert_eq!(map.get("name").and_then(|v| v.as_str()), Some("foo"));
|
||||
assert_eq!(map.get("version").and_then(|v| v.as_str()), Some("1.0.0"));
|
||||
assert_eq!(map.get("bin"), Some(&json!({ "foo": "./bin/foo.js" })));
|
||||
assert_eq!(map.get("engines"), Some(&json!({ "node": ">=18" })));
|
||||
assert_eq!(map.get("cpu"), Some(&json!(["x64"])));
|
||||
assert_eq!(map.get("os"), Some(&json!(["linux"])));
|
||||
assert_eq!(map.get("libc"), Some(&json!(["glibc"])));
|
||||
assert_eq!(map.get("dependencies"), Some(&json!({ "bar": "^1.0.0" })));
|
||||
assert_eq!(map.get("devDependencies"), Some(&json!({ "qux": "^3.0.0" })));
|
||||
assert_eq!(map.get("optionalDependencies"), Some(&json!({ "baz": "^2.0.0" })));
|
||||
assert_eq!(map.get("peerDependencies"), Some(&json!({ "react": "^18" })));
|
||||
assert_eq!(map.get("peerDependenciesMeta"), Some(&json!({ "react": { "optional": true } })));
|
||||
assert_eq!(map.get("bundledDependencies"), Some(&json!(["bar"])));
|
||||
assert_eq!(map.get("directories"), Some(&json!({ "bin": "./bin" })));
|
||||
// Excluded fields stay out.
|
||||
assert!(map.get("description").is_none());
|
||||
assert!(map.get("license").is_none());
|
||||
assert!(map.get("keywords").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_picks_lifecycle_scripts_not_all_scripts() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"preinstall": "echo pre",
|
||||
"install": "echo install",
|
||||
"postinstall": "echo post",
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"start": "node index.js",
|
||||
"prepare": "tsc",
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert_eq!(
|
||||
result.get("scripts").expect("scripts present"),
|
||||
&json!({
|
||||
"preinstall": "echo pre",
|
||||
"install": "echo install",
|
||||
"postinstall": "echo post",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_scripts_key_when_no_lifecycle_scripts_exist() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert!(
|
||||
result.get("scripts").is_none(),
|
||||
"scripts key must be absent when no lifecycle hook is present",
|
||||
);
|
||||
}
|
||||
|
||||
/// `null` and `undefined` fields are skipped. Rust's
|
||||
/// [`serde_json::Value`] has no `undefined`, but JSON `null`
|
||||
/// reaches the picker as [`serde_json::Value::Null`] and must be
|
||||
/// filtered out the same way.
|
||||
#[test]
|
||||
fn skips_null_fields() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"bin": null,
|
||||
"engines": null,
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert!(result.get("bin").is_none(), "null `bin` must be dropped");
|
||||
assert!(result.get("engines").is_none(), "null `engines` must be dropped");
|
||||
assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("foo"));
|
||||
assert_eq!(result.get("version").and_then(|v| v.as_str()), Some("1.0.0"));
|
||||
}
|
||||
|
||||
/// The bundled manifest is downstream-fed into
|
||||
/// `extract_peer_dependencies` and `extract_children`; dropping
|
||||
/// `peerDependenciesMeta` or `optionalDependencies` here would
|
||||
/// replicate the pnpm/pnpm#11934 resolver-side bug on the
|
||||
/// install-side. Pin the keys explicitly.
|
||||
#[test]
|
||||
fn preserves_optional_dependencies_and_peer_dependencies_meta_keys() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "consumer",
|
||||
"version": "1.0.0",
|
||||
"optionalDependencies": { "sharp": "^0.34.0" },
|
||||
"peerDependenciesMeta": {
|
||||
"@vercel/kv": { "optional": true },
|
||||
"ioredis": { "optional": true },
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert_eq!(result.get("optionalDependencies"), Some(&json!({ "sharp": "^0.34.0" })));
|
||||
assert_eq!(
|
||||
result.get("peerDependenciesMeta"),
|
||||
Some(&json!({
|
||||
"@vercel/kv": { "optional": true },
|
||||
"ioredis": { "optional": true },
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,7 @@ pub(crate) use limits::{
|
||||
clean_archive_entry_path, oversized_manifest_error,
|
||||
};
|
||||
pub use manifest::{TarInspection, inspect_tar, read_buffered_tar_entry, tar_requires_build};
|
||||
pub(crate) use manifest::{
|
||||
apply_append_manifest, apply_placeholder_manifest, normalize_bundled_manifest,
|
||||
};
|
||||
pub(crate) use manifest::{apply_append_manifest, apply_placeholder_manifest};
|
||||
pub(crate) use streaming::{
|
||||
BodyChunkReceiver, BodyChunkSender, ExtractedResource, STREAM_ENTRY_BUFFER_MAX,
|
||||
StreamedResource, body_chunk_channel, extract_resource_stream, stream_extract_channel,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use pnpm_package_manifest::{
|
||||
PackageBin, ScriptRuntime, manifest_declares_bins, parse_shebang_from_bytes,
|
||||
resolve_package_bins_with,
|
||||
PackageBin, ScriptRuntime, manifest_declares_bins, normalize_bundled_manifest,
|
||||
parse_shebang_from_bytes, resolve_package_bins_with,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -36,87 +36,6 @@ pub fn read_buffered_tar_entry(
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Pick the `package.json` fields downstream code actually reads — bin
|
||||
/// linking, dependency resolution, build-script detection — and discard
|
||||
/// the rest, keeping only the lifecycle hooks pnpm executes out
|
||||
/// of `scripts`.
|
||||
///
|
||||
/// The subset exists to bound what lands in `index.db`: a full manifest
|
||||
/// runs to tens of KB, and msgpackr-records tops out at `0x7f` record
|
||||
/// slots (see [`pnpm_store_dir::EncodeError::OutOfRecordSlots`]).
|
||||
///
|
||||
/// `None` rather than an empty object when nothing survives, which
|
||||
/// would otherwise round-trip as a zero-field record def.
|
||||
pub(crate) fn normalize_bundled_manifest(value: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
/// Fields kept verbatim from the source manifest.
|
||||
///
|
||||
/// Order matters for the on-wire byte sequence — msgpackr emits
|
||||
/// fields in JS object insertion order, and pacquet's encoder
|
||||
/// follows the [`serde_json::Map`] iteration order — but it
|
||||
/// does *not* matter for property-access correctness on the
|
||||
/// pnpm side. The order below matches the field order pnpm
|
||||
/// emits so a side-by-side byte diff against a pnpm-written
|
||||
/// row is shallower.
|
||||
const BUNDLED_MANIFEST_FIELDS: &[&str] = &[
|
||||
"bin",
|
||||
"bundledDependencies",
|
||||
"bundleDependencies",
|
||||
"cpu",
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"directories",
|
||||
"engines",
|
||||
"gypfile",
|
||||
"libc",
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"os",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
];
|
||||
const LIFECYCLE_SCRIPTS: &[&str] = &["preinstall", "install", "postinstall"];
|
||||
|
||||
let serde_json::Value::Object(map) = value else { return None };
|
||||
let mut picked = serde_json::Map::new();
|
||||
|
||||
// pnpm emits `version` first regardless of whether it was first
|
||||
// in the source object. Version normalization
|
||||
// via `semver.clean(...)` (pnpm only loose-cleans for the bundled
|
||||
// row, not for resolution) is intentionally skipped: the inputs
|
||||
// from a real npm tarball are already semver-clean in practice,
|
||||
// and pulling `node-semver` into `pnpm-tarball` purely for
|
||||
// this normalization would carry more risk than the deviation it
|
||||
// closes.
|
||||
pick_fields(&mut picked, map, &["version"]);
|
||||
pick_fields(&mut picked, map, BUNDLED_MANIFEST_FIELDS);
|
||||
|
||||
if let Some(serde_json::Value::Object(scripts)) = map.get("scripts") {
|
||||
let mut sub = serde_json::Map::new();
|
||||
pick_fields(&mut sub, scripts, LIFECYCLE_SCRIPTS);
|
||||
if !sub.is_empty() {
|
||||
picked.insert("scripts".to_string(), serde_json::Value::Object(sub));
|
||||
}
|
||||
}
|
||||
|
||||
if picked.is_empty() { None } else { Some(serde_json::Value::Object(picked)) }
|
||||
}
|
||||
|
||||
/// Copy the named fields that `source` actually carries, in the order given.
|
||||
/// A null is treated as absent: pnpm's own row omits it.
|
||||
fn pick_fields(
|
||||
picked: &mut serde_json::Map<String, serde_json::Value>,
|
||||
source: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) {
|
||||
for &key in keys {
|
||||
if let Some(value) = source.get(key)
|
||||
&& !value.is_null()
|
||||
{
|
||||
picked.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a synthesized `package.json` (pnpm's `appendManifest`) into a
|
||||
/// freshly extracted archive's CAFS output. Runtime archives (Node.js /
|
||||
/// Bun / Deno) carry no `package.json` of their own, so the caller
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(crate) use extract::{
|
||||
STREAM_EXTRACT_DURING_DOWNLOAD_THRESHOLD, allocate_tarball_buffer, apply_append_manifest,
|
||||
apply_placeholder_manifest, body_chunk_channel, clean_archive_entry_path, decompress_gzip,
|
||||
extract_archive_bytes, extract_resource_stream, is_eager_decode_limit_exceeded,
|
||||
normalize_bundled_manifest, oversized_manifest_error, tar_entry_payload,
|
||||
oversized_manifest_error, tar_entry_payload,
|
||||
};
|
||||
pub use fetch_for_resolution::{
|
||||
FetchTarballForResolution, NotModifiedResponse, ResolutionStore, ResolvedTarball,
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
use super::{
|
||||
Cursor, HashMap, MAX_UNTRUSTED_PREALLOC_BYTES, Path, PathBuf, Read, TarballError,
|
||||
allocate_tarball_buffer, clean_archive_entry_path, decompress_gzip, io,
|
||||
is_eager_decode_limit_exceeded, normalize_bundled_manifest, oversized_manifest_error,
|
||||
post_download_semaphore, tar_entry_payload, verify_tarball_integrity,
|
||||
is_eager_decode_limit_exceeded, oversized_manifest_error, post_download_semaphore,
|
||||
tar_entry_payload, verify_tarball_integrity,
|
||||
};
|
||||
use crate::extraction_task::spawn_extraction;
|
||||
use pnpm_package_manifest::parse_manifest_bytes;
|
||||
use pnpm_package_manifest::{normalize_bundled_manifest, parse_manifest_bytes};
|
||||
use ssri::{Integrity, IntegrityChecker};
|
||||
use tar::Archive;
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ use super::{
|
||||
ParallelIterator, PathBuf, TarballError,
|
||||
};
|
||||
use pnpm_package_manifest::{
|
||||
files_build_triggers, requires_build_from_cas_paths, stored_requires_build_needs_manifest_check,
|
||||
requires_build_from_cas_paths, requires_build_from_files,
|
||||
stored_requires_build_needs_manifest_check,
|
||||
};
|
||||
use pnpm_reporter::{GlobalLog, LogEvent, LogLevel};
|
||||
use pnpm_store_dir::{
|
||||
@@ -346,23 +347,6 @@ fn decode_matching_prefetch_entry(cache_key: &str, bytes: &[u8]) -> Option<Packa
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Derive `requiresBuild` for a store-index row that does not carry it, from
|
||||
/// the row's bundled manifest and its file list.
|
||||
fn row_requires_build<Filenames, Filename>(
|
||||
manifest: Option<&serde_json::Value>,
|
||||
filenames: Filenames,
|
||||
) -> bool
|
||||
where
|
||||
Filenames: IntoIterator<Item = Filename>,
|
||||
Filename: AsRef<str>,
|
||||
{
|
||||
let mut triggers = files_build_triggers(filenames);
|
||||
if let Some(manifest) = manifest {
|
||||
triggers.read_manifest(manifest);
|
||||
}
|
||||
triggers.requires_build()
|
||||
}
|
||||
|
||||
/// A git-hosted row (one that records `requiresPrepare`) with no bundled
|
||||
/// manifest derives `requiresBuild` from its files and ignores the stored
|
||||
/// value, which existing stores may hold wrong
|
||||
@@ -381,7 +365,7 @@ fn resolve_row_requires_build(
|
||||
requires_build_from_cas_paths(cas_paths)
|
||||
}
|
||||
Some(stored) => stored,
|
||||
None => row_requires_build(manifest, cas_paths.keys()),
|
||||
None => requires_build_from_files(manifest, cas_paths.keys()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ use super::{
|
||||
apply_append_manifest, apply_placeholder_manifest, decompress_gzip,
|
||||
extract_gzipped_tarball_with_tar, extract_tarball_entries_with_bins,
|
||||
gzip::{bounded_gzip_size_hint, gzip_isize_hint, should_stream_extract},
|
||||
is_eager_decode_limit_exceeded, normalize_bundled_manifest,
|
||||
stream_extract_gzipped_tarball_with_tar,
|
||||
is_eager_decode_limit_exceeded, stream_extract_gzipped_tarball_with_tar,
|
||||
},
|
||||
local_tarball::{
|
||||
allocate_local_tarball_buffer, local_file_tarball_path, open_local_tarball,
|
||||
@@ -381,180 +380,6 @@ impl Read for EndlessReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pacquet's [`normalize_bundled_manifest`] picks the subset of
|
||||
/// `package.json` fields downstream install code reads (bin lookup,
|
||||
/// peer extraction, build-script detection) and narrows `scripts` to
|
||||
/// the lifecycle hooks. Two cases are intentionally NOT covered:
|
||||
/// `semver.clean` normalization (pacquet keeps version verbatim, per
|
||||
/// the function's doc comment) and the missing-version default of
|
||||
/// `0.0.0` (pacquet leaves the field absent rather than synthesizing
|
||||
/// one).
|
||||
mod normalize_bundled_manifest_tests {
|
||||
use super::normalize_bundled_manifest;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_empty_manifest() {
|
||||
assert_eq!(normalize_bundled_manifest(&json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_non_object() {
|
||||
assert_eq!(normalize_bundled_manifest(&json!("not an object")), None);
|
||||
assert_eq!(normalize_bundled_manifest(&json!(null)), None);
|
||||
assert_eq!(normalize_bundled_manifest(&json!(42)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_manifest_has_only_excluded_fields() {
|
||||
assert_eq!(
|
||||
normalize_bundled_manifest(&json!({
|
||||
"description": "a package",
|
||||
"keywords": ["test"],
|
||||
"license": "MIT",
|
||||
"author": "test",
|
||||
"repository": "test/test",
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_included_fields_and_excludes_others() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"description": "should be excluded",
|
||||
"license": "MIT",
|
||||
"bin": { "foo": "./bin/foo.js" },
|
||||
"engines": { "node": ">=18" },
|
||||
"cpu": ["x64"],
|
||||
"os": ["linux"],
|
||||
"libc": ["glibc"],
|
||||
"dependencies": { "bar": "^1.0.0" },
|
||||
"devDependencies": { "qux": "^3.0.0" },
|
||||
"optionalDependencies": { "baz": "^2.0.0" },
|
||||
"peerDependencies": { "react": "^18" },
|
||||
"peerDependenciesMeta": { "react": { "optional": true } },
|
||||
"bundledDependencies": ["bar"],
|
||||
"directories": { "bin": "./bin" },
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
let map = result.as_object().expect("object");
|
||||
assert_eq!(map.get("name").and_then(|v| v.as_str()), Some("foo"));
|
||||
assert_eq!(map.get("version").and_then(|v| v.as_str()), Some("1.0.0"));
|
||||
assert_eq!(map.get("bin"), Some(&json!({ "foo": "./bin/foo.js" })));
|
||||
assert_eq!(map.get("engines"), Some(&json!({ "node": ">=18" })));
|
||||
assert_eq!(map.get("cpu"), Some(&json!(["x64"])));
|
||||
assert_eq!(map.get("os"), Some(&json!(["linux"])));
|
||||
assert_eq!(map.get("libc"), Some(&json!(["glibc"])));
|
||||
assert_eq!(map.get("dependencies"), Some(&json!({ "bar": "^1.0.0" })));
|
||||
assert_eq!(map.get("devDependencies"), Some(&json!({ "qux": "^3.0.0" })));
|
||||
assert_eq!(map.get("optionalDependencies"), Some(&json!({ "baz": "^2.0.0" })));
|
||||
assert_eq!(map.get("peerDependencies"), Some(&json!({ "react": "^18" })));
|
||||
assert_eq!(
|
||||
map.get("peerDependenciesMeta"),
|
||||
Some(&json!({ "react": { "optional": true } })),
|
||||
);
|
||||
assert_eq!(map.get("bundledDependencies"), Some(&json!(["bar"])));
|
||||
assert_eq!(map.get("directories"), Some(&json!({ "bin": "./bin" })));
|
||||
// Excluded fields stay out.
|
||||
assert!(map.get("description").is_none());
|
||||
assert!(map.get("license").is_none());
|
||||
assert!(map.get("keywords").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_picks_lifecycle_scripts_not_all_scripts() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"preinstall": "echo pre",
|
||||
"install": "echo install",
|
||||
"postinstall": "echo post",
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"start": "node index.js",
|
||||
"prepare": "tsc",
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert_eq!(
|
||||
result.get("scripts").expect("scripts present"),
|
||||
&json!({
|
||||
"preinstall": "echo pre",
|
||||
"install": "echo install",
|
||||
"postinstall": "echo post",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_scripts_key_when_no_lifecycle_scripts_exist() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert!(
|
||||
result.get("scripts").is_none(),
|
||||
"scripts key must be absent when no lifecycle hook is present",
|
||||
);
|
||||
}
|
||||
|
||||
/// `null` and `undefined` fields are skipped. Rust's
|
||||
/// [`serde_json::Value`] has no `undefined`, but JSON `null`
|
||||
/// reaches the picker as [`serde_json::Value::Null`] and must be
|
||||
/// filtered out the same way.
|
||||
#[test]
|
||||
fn skips_null_fields() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"bin": null,
|
||||
"engines": null,
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert!(result.get("bin").is_none(), "null `bin` must be dropped");
|
||||
assert!(result.get("engines").is_none(), "null `engines` must be dropped");
|
||||
assert_eq!(result.get("name").and_then(|v| v.as_str()), Some("foo"));
|
||||
assert_eq!(result.get("version").and_then(|v| v.as_str()), Some("1.0.0"));
|
||||
}
|
||||
|
||||
/// The bundled manifest is downstream-fed into
|
||||
/// `extract_peer_dependencies` and `extract_children`; dropping
|
||||
/// `peerDependenciesMeta` or `optionalDependencies` here would
|
||||
/// replicate the pnpm/pnpm#11934 resolver-side bug on the
|
||||
/// install-side. Pin the keys explicitly.
|
||||
#[test]
|
||||
fn preserves_optional_dependencies_and_peer_dependencies_meta_keys() {
|
||||
let result = normalize_bundled_manifest(&json!({
|
||||
"name": "consumer",
|
||||
"version": "1.0.0",
|
||||
"optionalDependencies": { "sharp": "^0.34.0" },
|
||||
"peerDependenciesMeta": {
|
||||
"@vercel/kv": { "optional": true },
|
||||
"ioredis": { "optional": true },
|
||||
},
|
||||
}))
|
||||
.expect("non-empty pick");
|
||||
assert_eq!(result.get("optionalDependencies"), Some(&json!({ "sharp": "^0.34.0" })));
|
||||
assert_eq!(
|
||||
result.get("peerDependenciesMeta"),
|
||||
Some(&json!({
|
||||
"@vercel/kv": { "optional": true },
|
||||
"ioredis": { "optional": true },
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a gzipped tar whose *compressed* body is at least `min_bytes`,
|
||||
/// so the response carries a `Content-Length` over the in-progress
|
||||
/// threshold. The payload is LCG noise because gzip would otherwise
|
||||
|
||||
Reference in new issue
Block a user