mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 18:05:29 -04:00
fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG (#11768)
* fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG Peer-heavy snapshot keys (e.g. vitest with a dozen browser / coverage / DOM peers) produced flat-name directories that overflowed macOS's 255- byte filename limit, so `install` aborted with errno 63 before unpacking any tarballs. Port the trailing length / case-shortening branch of upstream's `depPathToFilename` (deps/path/src/index.ts:169) so the name becomes `<prefix>_<32-hex-sha256>` capped at `virtualStoreDirMaxLength` bytes (default 120). Extract `create_short_hash` and `shorten_virtual_store_name` into a new `pacquet-crypto-hash` crate mirroring upstream `@pnpm/crypto.hash`; `pacquet-lockfile`, `pacquet-registry`, and `pacquet-store-dir` all consume it instead of duplicating the sha2 + truncate logic. Reported via pnpm/pacquet issue triage (vitest@4.1.6 peer suffix). * fix(pacquet): taplo format and remove broken intra-doc link Format `pacquet/crates/crypto-hash/Cargo.toml` per the workspace `.taplo.toml` (aligns the `[package]` keys) and downgrade the `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH` reference in `PkgNameVerPeer::to_virtual_store_name` to plain text, since `pacquet-lockfile` deliberately does not depend on `pacquet-modules-yaml` and `RUSTDOCFLAGS=-D warnings` rejected the unresolved intra-doc link. * feat(pacquet/config): expose virtualStoreDirMaxLength Add `virtual_store_dir_max_length: u64` to `Config` with default 120 (matching `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`). Wire it through `WorkspaceSettings.virtualStoreDirMaxLength` and the `PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH` env-overlay so users can override the threshold via `pnpm-workspace.yaml`, global `config.yaml`, or environment variables — mirroring upstream `Config.virtualStoreDirMaxLength`. The three flat-name call sites (`install_without_lockfile.rs`, `install_package_from_registry.rs`, `virtual_store_layout.rs`) and the `.modules.yaml` writer now read the configured value instead of the hardcoded constant. `VirtualStoreLayout::legacy` takes the value as an explicit second arg so test fixtures don't silently inherit a default.
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -2044,6 +2044,13 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-crypto-hash"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-diagnostics"
|
||||
version = "0.0.1"
|
||||
@@ -2152,6 +2159,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"node-semver",
|
||||
"pacquet-crypto-hash",
|
||||
"pacquet-diagnostics",
|
||||
"pacquet-package-manifest",
|
||||
"pipe-trait",
|
||||
@@ -2270,6 +2278,7 @@ dependencies = [
|
||||
"node-semver",
|
||||
"pacquet-cmd-shim",
|
||||
"pacquet-config",
|
||||
"pacquet-crypto-hash",
|
||||
"pacquet-directory-fetcher",
|
||||
"pacquet-executor",
|
||||
"pacquet-fs",
|
||||
@@ -2482,6 +2491,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"dunce",
|
||||
"miette 7.6.0",
|
||||
"pacquet-crypto-hash",
|
||||
"pacquet-fs",
|
||||
"pipe-trait",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -15,6 +15,7 @@ repository = "https://github.com/pnpm/pacquet"
|
||||
# Crates
|
||||
pacquet-cli = { path = "pacquet/crates/cli" }
|
||||
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
|
||||
pacquet-crypto-hash = { path = "pacquet/crates/crypto-hash" }
|
||||
pacquet-fs = { path = "pacquet/crates/fs" }
|
||||
pacquet-registry = { path = "pacquet/crates/registry" }
|
||||
pacquet-tarball = { path = "pacquet/crates/tarball" }
|
||||
|
||||
@@ -217,6 +217,18 @@ pub fn default_modules_cache_max_age() -> u64 {
|
||||
10080
|
||||
}
|
||||
|
||||
/// Default `virtualStoreDirMaxLength` matching pnpm's fallback at
|
||||
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L101-L103>.
|
||||
///
|
||||
/// Kept as a free function (not a re-export of
|
||||
/// `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`) so
|
||||
/// `pacquet-config` doesn't pull in the modules-yaml crate just for one
|
||||
/// integer. Both copies must agree; the modules-yaml side carries the
|
||||
/// same upstream link.
|
||||
pub fn default_virtual_store_dir_max_length() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
pub fn default_fetch_retries() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ impl WorkspaceSettings {
|
||||
string_field!(global_virtual_store_dir, "GLOBAL_VIRTUAL_STORE_DIR");
|
||||
enum_field!(package_import_method, "PACKAGE_IMPORT_METHOD", PackageImportMethod);
|
||||
json_field!(modules_cache_max_age, "MODULES_CACHE_MAX_AGE");
|
||||
json_field!(virtual_store_dir_max_length, "VIRTUAL_STORE_DIR_MAX_LENGTH");
|
||||
json_field!(lockfile, "LOCKFILE");
|
||||
json_field!(prefer_frozen_lockfile, "PREFER_FROZEN_LOCKFILE");
|
||||
json_field!(offline, "OFFLINE");
|
||||
|
||||
@@ -22,8 +22,8 @@ use std::{
|
||||
};
|
||||
|
||||
pub use crate::defaults::{
|
||||
available_parallelism, default_git_shallow_hosts, default_unsafe_perm, is_unsafe_perm_posix,
|
||||
resolve_child_concurrency,
|
||||
available_parallelism, default_git_shallow_hosts, default_unsafe_perm,
|
||||
default_virtual_store_dir_max_length, is_unsafe_perm_posix, resolve_child_concurrency,
|
||||
};
|
||||
use crate::defaults::{
|
||||
default_cache_dir, default_child_concurrency, default_config_dir,
|
||||
@@ -292,6 +292,26 @@ pub struct Config {
|
||||
#[default(_code = "default_modules_cache_max_age()")]
|
||||
pub modules_cache_max_age: u64,
|
||||
|
||||
/// Maximum filename length for the per-snapshot subdirectory of the
|
||||
/// virtual store (`node_modules/.pnpm/<name>`). When the escaped
|
||||
/// flat name would exceed this many bytes, the tail is replaced
|
||||
/// with a 32-char sha256 hash so the path stays within filesystem
|
||||
/// limits (macOS / ext4 cap component names at 255 bytes; pnpm
|
||||
/// defaults to 120 to leave headroom for `node_modules/<name>`
|
||||
/// suffixes appended below).
|
||||
///
|
||||
/// Configurable via `virtualStoreDirMaxLength` in
|
||||
/// `pnpm-workspace.yaml`, global `config.yaml`, or
|
||||
/// `PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH`. Mirrors upstream
|
||||
/// `Config.virtualStoreDirMaxLength` at
|
||||
/// <https://github.com/pnpm/pnpm/blob/1819226b51/config/reader/src/Config.ts>.
|
||||
/// The same value is persisted into `node_modules/.modules.yaml`
|
||||
/// so subsequent installs see the user's pick.
|
||||
///
|
||||
/// Default value is 120.
|
||||
#[default(_code = "default_virtual_store_dir_max_length()")]
|
||||
pub virtual_store_dir_max_length: u64,
|
||||
|
||||
/// When set to false, pnpm won't read or generate a pnpm-lock.yaml file.
|
||||
pub lockfile: bool,
|
||||
|
||||
@@ -2137,4 +2157,65 @@ mod tests {
|
||||
"hoist: false must clear hoist_pattern, even when set via env var",
|
||||
);
|
||||
}
|
||||
|
||||
/// `virtualStoreDirMaxLength` defaults to 120 — same value pnpm
|
||||
/// writes when nothing is configured. The constant lives in
|
||||
/// `pacquet-modules-yaml`; this asserts the config side carries
|
||||
/// the matching default so a fresh install produces the same
|
||||
/// virtual-store dirnames as pnpm.
|
||||
#[test]
|
||||
pub fn virtual_store_dir_max_length_defaults_to_120() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let config = Config::new().current::<HostNoHome>(tmp.path()).expect("loads");
|
||||
assert_eq!(config.virtual_store_dir_max_length, 120);
|
||||
}
|
||||
|
||||
/// `virtualStoreDirMaxLength` in `pnpm-workspace.yaml` overrides
|
||||
/// the default. Mirrors pnpm's
|
||||
/// [`virtualStoreDirMaxLength`](https://github.com/pnpm/pnpm/blob/1819226b51/config/reader/src/Config.ts)
|
||||
/// config-reader entry.
|
||||
#[test]
|
||||
pub fn virtual_store_dir_max_length_from_workspace_yaml() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fs::write(tmp.path().join("pnpm-workspace.yaml"), "virtualStoreDirMaxLength: 90\n")
|
||||
.expect("write to pnpm-workspace.yaml");
|
||||
let config = Config::new().current::<HostNoHome>(tmp.path()).expect("yaml is valid");
|
||||
assert_eq!(config.virtual_store_dir_max_length, 90);
|
||||
}
|
||||
|
||||
/// `PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH` overrides the yaml
|
||||
/// value, matching the reader cascade priority (env > yaml >
|
||||
/// default).
|
||||
#[test]
|
||||
pub fn virtual_store_dir_max_length_env_var_overrides_yaml() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fs::write(tmp.path().join("pnpm-workspace.yaml"), "virtualStoreDirMaxLength: 90\n")
|
||||
.expect("write to pnpm-workspace.yaml");
|
||||
|
||||
struct HostWithEnvOverride;
|
||||
impl EnvVar for HostWithEnvOverride {
|
||||
fn var(name: &str) -> Option<String> {
|
||||
if name == "PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH" {
|
||||
return Some("50".to_owned());
|
||||
}
|
||||
safe_host_var(name)
|
||||
}
|
||||
}
|
||||
impl EnvVarOs for HostWithEnvOverride {
|
||||
fn var_os(_: &str) -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
}
|
||||
impl GetHomeDir for HostWithEnvOverride {
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let config = Config::new().current::<HostWithEnvOverride>(tmp.path()).expect("loads");
|
||||
assert_eq!(
|
||||
config.virtual_store_dir_max_length, 50,
|
||||
"env var must win over pnpm-workspace.yaml",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct WorkspaceSettings {
|
||||
pub global_virtual_store_dir: Option<String>,
|
||||
pub package_import_method: Option<PackageImportMethod>,
|
||||
pub modules_cache_max_age: Option<u64>,
|
||||
pub virtual_store_dir_max_length: Option<u64>,
|
||||
pub lockfile: Option<bool>,
|
||||
pub prefer_frozen_lockfile: Option<bool>,
|
||||
pub offline: Option<bool>,
|
||||
@@ -406,6 +407,7 @@ impl WorkspaceSettings {
|
||||
apply! {
|
||||
hoist, shamefully_hoist,
|
||||
node_linker, symlink, package_import_method, modules_cache_max_age,
|
||||
virtual_store_dir_max_length,
|
||||
lockfile, prefer_frozen_lockfile, offline, prefer_offline,
|
||||
lockfile_include_tarball_url,
|
||||
auto_install_peers, hoist_workspace_packages,
|
||||
|
||||
22
pacquet/crates/crypto-hash/Cargo.toml
Normal file
22
pacquet/crates/crypto-hash/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "pacquet-crypto-hash"
|
||||
description = "Hash helpers shared across pacquet crates"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
keywords.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# Match pacquet-store-dir's MSVC-aware feature gate so this crate
|
||||
# inherits the `asm` backend on every target where it compiles.
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
sha2 = { workspace = true, features = ["asm"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
118
pacquet/crates/crypto-hash/src/lib.rs
Normal file
118
pacquet/crates/crypto-hash/src/lib.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! Hash helpers shared across pacquet crates.
|
||||
//!
|
||||
//! Mirrors upstream pnpm's
|
||||
//! [`@pnpm/crypto.hash`](https://github.com/pnpm/pnpm/blob/1819226b51/crypto/hash/src/index.ts)
|
||||
//! package. Also hosts [`shorten_virtual_store_name`], the trailing
|
||||
//! length/case-shortening branch of upstream's
|
||||
//! [`depPathToFilename`](https://github.com/pnpm/pnpm/blob/1819226b51/deps/path/src/index.ts#L169-L180):
|
||||
//! pacquet doesn't have a `deps.path`-equivalent crate yet, and the
|
||||
//! lockfile and registry helpers both need to apply the same shortening
|
||||
//! after their own pre-escape step. Keeping the helpers in one place
|
||||
//! avoids duplicating the sha2 dependency in every consumer (lockfile,
|
||||
//! registry, store-dir).
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Compute the sha256 hex digest of `input` and truncate to the first
|
||||
/// 32 hex characters (16 bytes of entropy).
|
||||
///
|
||||
/// Matches upstream
|
||||
/// [`createShortHash`](https://github.com/pnpm/pnpm/blob/1819226b51/crypto/hash/src/index.ts#L7-L9):
|
||||
/// `crypto.hash('sha256', input, 'hex').substring(0, 32)`. The truncation
|
||||
/// is part of the on-disk contract — anything written into a path with
|
||||
/// this hash (project-registry slugs, virtual-store dirnames that
|
||||
/// overflowed `virtualStoreDirMaxLength`, etc.) must use the same 32-char
|
||||
/// length so pacquet and pnpm produce the same directory layout.
|
||||
pub fn create_short_hash(input: &str) -> String {
|
||||
let digest = Sha256::digest(input.as_bytes());
|
||||
let mut hex = format!("{digest:x}");
|
||||
hex.truncate(32);
|
||||
hex
|
||||
}
|
||||
|
||||
/// Hash-shorten `filename` when it exceeds `max_length` bytes or carries
|
||||
/// uppercase characters that would collide on case-insensitive
|
||||
/// filesystems. Returns `filename` unchanged otherwise.
|
||||
///
|
||||
/// Mirrors the trailing branch of upstream's
|
||||
/// [`depPathToFilename`](https://github.com/pnpm/pnpm/blob/1819226b51/deps/path/src/index.ts#L169-L180):
|
||||
///
|
||||
/// ```text
|
||||
/// if (filename.length > maxLengthWithoutHash ||
|
||||
/// (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
|
||||
/// return `${filename.substring(0, maxLengthWithoutHash - 33)}_${createShortHash(filename)}`
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// `max_length` is `Modules.virtual_store_dir_max_length` (default
|
||||
/// 120; see `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`).
|
||||
/// The `file+` early exit keeps file-protocol deps from hashing just
|
||||
/// because their on-disk path component carries capitals.
|
||||
///
|
||||
/// The caller is responsible for pre-escaping the source string (parens
|
||||
/// → underscores, scoped-name slashes → `+`, etc) — this helper only
|
||||
/// applies the final length/case decision so the escape rules can stay
|
||||
/// where the structured input lives.
|
||||
pub fn shorten_virtual_store_name(filename: String, max_length: usize) -> String {
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
let needs_shortening =
|
||||
filename.len() > max_length || (filename != lower && !filename.starts_with("file+"));
|
||||
if !needs_shortening {
|
||||
return filename;
|
||||
}
|
||||
let cap = max_length.saturating_sub(33);
|
||||
let mut boundary = cap.min(filename.len());
|
||||
while !filename.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
let hash = create_short_hash(&filename);
|
||||
format!("{}_{}", &filename[..boundary], hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_short_hash, shorten_virtual_store_name};
|
||||
|
||||
/// Pinned vector against the shell oracle:
|
||||
///
|
||||
/// ```sh
|
||||
/// printf pacquet | shasum -a 256 | head -c 32
|
||||
/// # => 6784def0191a0dd68103a05ab700b31c
|
||||
/// ```
|
||||
#[test]
|
||||
fn short_hash_is_first_32_hex_chars_of_sha256() {
|
||||
let got = create_short_hash("pacquet");
|
||||
assert_eq!(got, "6784def0191a0dd68103a05ab700b31c");
|
||||
assert_eq!(got.len(), 32);
|
||||
assert_ne!(got, create_short_hash("pacquet "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_below_threshold_is_identity() {
|
||||
let name = "ts-node@10.9.1_@types+node@18.7.19_typescript@5.1.6".to_string();
|
||||
assert!(name.len() < 120);
|
||||
assert_eq!(shorten_virtual_store_name(name.clone(), 120), name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_above_threshold_hashes_to_max_length() {
|
||||
let input = "a".repeat(200);
|
||||
let shortened = shorten_virtual_store_name(input, 120);
|
||||
assert_eq!(shortened.len(), 120);
|
||||
let (prefix, hash) = shortened.rsplit_once('_').expect("hash suffix");
|
||||
assert_eq!(prefix.len(), 120 - 33);
|
||||
assert_eq!(hash.len(), 32);
|
||||
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_triggered_by_uppercase_unless_file_protocol() {
|
||||
let with_caps = "MyPkg@1.0.0".to_string();
|
||||
let shortened = shorten_virtual_store_name(with_caps.clone(), 120);
|
||||
assert_ne!(shortened, with_caps);
|
||||
assert!(shortened.len() <= 120);
|
||||
|
||||
let file_proto = "file+path+with+Caps".to_string();
|
||||
assert_eq!(shorten_virtual_store_name(file_proto.clone(), 120), file_proto);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-crypto-hash = { workspace = true }
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{ParsePkgNameSuffixError, ParsePkgVerPeerError, PkgNameSuffix, PkgVerPeer};
|
||||
use pacquet_crypto_hash::shorten_virtual_store_name;
|
||||
|
||||
/// Syntax: `{name}@{version}({peers})`
|
||||
///
|
||||
@@ -11,11 +12,34 @@ pub type PkgNameVerPeer = PkgNameSuffix<PkgVerPeer>;
|
||||
pub type ParsePkgNameVerPeerError = ParsePkgNameSuffixError<ParsePkgVerPeerError>;
|
||||
|
||||
impl PkgNameVerPeer {
|
||||
/// Construct the name of the corresponding subdirectory in the virtual store directory.
|
||||
pub fn to_virtual_store_name(&self) -> String {
|
||||
// the code below is far from optimal,
|
||||
// optimization requires parser combinator
|
||||
self.to_string().replace('/', "+").replace(")(", "_").replace('(', "_").replace(')', "")
|
||||
/// Construct the name of the corresponding subdirectory in the
|
||||
/// virtual store directory. When the resulting name would exceed
|
||||
/// `max_length` bytes, fall back to a hash-shortened form so the
|
||||
/// path stays within filesystem limits.
|
||||
///
|
||||
/// Mirrors upstream's
|
||||
/// [`depPathToFilename`](https://github.com/pnpm/pnpm/blob/1819226b51/deps/path/src/index.ts#L169-L180):
|
||||
/// the lossy escape (parens → underscores, `/` → `+`) runs first,
|
||||
/// then if the filename is longer than `max_length` *or* contains
|
||||
/// uppercase characters (the case-insensitive-filesystem guard),
|
||||
/// the result becomes `<filename truncated to max_length - 33>_<32-hex-sha256>`.
|
||||
/// The `file+` prefix skips the case guard so file-protocol deps
|
||||
/// don't all hash-shorten just because their on-disk paths happen
|
||||
/// to contain capitals.
|
||||
///
|
||||
/// `max_length` is `Modules.virtual_store_dir_max_length` (default
|
||||
/// 120; see `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`
|
||||
/// — referenced by name rather than as an intra-doc link because
|
||||
/// `pacquet-lockfile` deliberately does not depend on
|
||||
/// `pacquet-modules-yaml`).
|
||||
pub fn to_virtual_store_name(&self, max_length: usize) -> String {
|
||||
let filename = self
|
||||
.to_string()
|
||||
.replace('/', "+")
|
||||
.replace(")(", "_")
|
||||
.replace('(', "_")
|
||||
.replace(')', "");
|
||||
shorten_virtual_store_name(filename, max_length)
|
||||
}
|
||||
|
||||
/// Return a new [`PkgNameVerPeer`] with the peer-dependency suffix stripped.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::PkgNameVerPeer;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const DEFAULT_MAX_LENGTH: usize = 120;
|
||||
|
||||
fn name_peer_ver(name: &str, peer_ver: &str) -> PkgNameVerPeer {
|
||||
let peer_ver = peer_ver.to_string().parse().unwrap();
|
||||
PkgNameVerPeer::new(name.parse().unwrap(), peer_ver)
|
||||
@@ -38,7 +40,7 @@ fn to_virtual_store_name() {
|
||||
eprintln!("CASE: {input:?}");
|
||||
let name_ver_peer: PkgNameVerPeer = input.parse().unwrap();
|
||||
dbg!(&name_ver_peer);
|
||||
let received = name_ver_peer.to_virtual_store_name();
|
||||
let received = name_ver_peer.to_virtual_store_name(DEFAULT_MAX_LENGTH);
|
||||
assert_eq!(received, expected);
|
||||
}
|
||||
|
||||
@@ -56,3 +58,28 @@ fn to_virtual_store_name() {
|
||||
"@babel+plugin-proposal-object-rest-spread@7.12.1_@babel+core@7.12.9",
|
||||
);
|
||||
}
|
||||
|
||||
/// The user-reported macOS errno-63 case: a vitest snapshot key whose
|
||||
/// escaped filename blows past 120 bytes. The shortening must produce a
|
||||
/// name that fits inside `max_length` so `fs::create_dir_all` doesn't
|
||||
/// hit `ENAMETOOLONG`.
|
||||
#[test]
|
||||
fn to_virtual_store_name_shortens_user_reported_vitest_case() {
|
||||
let input: PkgNameVerPeer = "vitest@4.1.6\
|
||||
(@opentelemetry/api@1.9.1)\
|
||||
(@types/node@24.12.4)\
|
||||
(@vitest/browser-playwright@4.1.6)\
|
||||
(@vitest/coverage-v8@4.1.6)\
|
||||
(happy-dom@20.9.0)\
|
||||
(jsdom@26.1.0)\
|
||||
(canvas@3.2.1)\
|
||||
(msw@2.12.14)\
|
||||
(yaml@2.8.4)"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let received = input.to_virtual_store_name(DEFAULT_MAX_LENGTH);
|
||||
assert_eq!(received.len(), DEFAULT_MAX_LENGTH);
|
||||
let (_, hash) = received.rsplit_once('_').expect("hash suffix");
|
||||
assert_eq!(hash.len(), 32);
|
||||
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-cmd-shim = { workspace = true }
|
||||
pacquet-crypto-hash = { workspace = true }
|
||||
pacquet-directory-fetcher = { workspace = true }
|
||||
pacquet-executor = { workspace = true }
|
||||
pacquet-fs = { workspace = true }
|
||||
|
||||
@@ -290,7 +290,10 @@ fn build_modules_collects_ignored_builds() {
|
||||
create_buildable_pkg(virtual_store_dir.path(), &key("aaa", "2.0.0"));
|
||||
|
||||
let ignored = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -359,7 +362,10 @@ fn build_modules_collects_ignored_builds_under_concurrency() {
|
||||
create_buildable_pkg(virtual_store_dir.path(), &key("aaa", "2.0.0"));
|
||||
|
||||
let ignored = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -416,7 +422,10 @@ fn build_modules_excludes_explicit_deny_from_ignored() {
|
||||
create_buildable_pkg(virtual_store_dir.path(), &key("ignored", "1.0.0"));
|
||||
|
||||
let ignored = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -496,7 +505,10 @@ fn do_not_fail_on_optional_dep_with_failing_postinstall() {
|
||||
create_failing_postinstall_fixture(virtual_store_dir.path(), &pkg_key);
|
||||
|
||||
let ignored = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -628,7 +640,10 @@ fn using_side_effects_cache_skips_rebuild() {
|
||||
side_effects_maps.insert(pkg_key.clone(), std::sync::Arc::new(overlay));
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -692,7 +707,10 @@ fn side_effects_cache_disabled_bypasses_the_gate() {
|
||||
side_effects_maps.insert(pkg_key.clone(), std::sync::Arc::new(overlay));
|
||||
|
||||
let err = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -749,7 +767,10 @@ fn fail_when_failing_postinstall_is_required() {
|
||||
create_failing_postinstall_fixture(virtual_store_dir.path(), &pkg_key);
|
||||
|
||||
let err = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -984,7 +1005,10 @@ async fn write_path_populates_side_effects_row() {
|
||||
);
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -1093,7 +1117,10 @@ async fn write_path_disabled_skips_upload() {
|
||||
let (writer, writer_task) = StoreIndexWriter::spawn(&store_dir);
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -1211,7 +1238,10 @@ async fn upload_error_does_not_interrupt_install() {
|
||||
let (writer, writer_task) = StoreIndexWriter::spawn(&store_dir);
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -1440,7 +1470,10 @@ new file mode 100644
|
||||
);
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -1546,7 +1579,10 @@ new file mode 100644
|
||||
let (writer, writer_task) = StoreIndexWriter::spawn(&store_dir);
|
||||
|
||||
BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
@@ -1623,7 +1659,10 @@ async fn missing_patch_file_path_errors_with_diagnostic() {
|
||||
let (writer, writer_task) = StoreIndexWriter::spawn(&store_dir);
|
||||
|
||||
let err = BuildModules {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_store_dir.path()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.path(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
modules_dir: modules_dir.path(),
|
||||
lockfile_dir: lockfile_dir.path(),
|
||||
snapshots: Some(&snapshots),
|
||||
|
||||
@@ -46,7 +46,10 @@ fn assert_symlink_shape(
|
||||
fn links_matching_optional_sibling_alongside_regular_deps() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let virtual_store_dir = tmp.path().to_path_buf();
|
||||
let layout = VirtualStoreLayout::legacy(virtual_store_dir.clone());
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
|
||||
let mut deps: HashMap<PkgName, SnapshotDepRef> = HashMap::new();
|
||||
deps.insert(pkg_name("plain-dep"), dep_ref("1.0.0"));
|
||||
@@ -92,7 +95,10 @@ fn links_matching_optional_sibling_alongside_regular_deps() {
|
||||
fn skips_optional_siblings_that_are_in_skipped() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let virtual_store_dir = tmp.path().to_path_buf();
|
||||
let layout = VirtualStoreLayout::legacy(virtual_store_dir);
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
virtual_store_dir,
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
|
||||
let mut optional: HashMap<PkgName, SnapshotDepRef> = HashMap::new();
|
||||
optional.insert(pkg_name("matching-optional"), dep_ref("2.0.0"));
|
||||
@@ -141,7 +147,10 @@ fn skips_optional_siblings_that_are_in_skipped() {
|
||||
fn skips_dep_entries_whose_alias_matches_self_name() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let virtual_store_dir = tmp.path().to_path_buf();
|
||||
let layout = VirtualStoreLayout::legacy(virtual_store_dir);
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
virtual_store_dir,
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
|
||||
let mut deps: HashMap<PkgName, SnapshotDepRef> = HashMap::new();
|
||||
deps.insert(pkg_name("self"), dep_ref("1.0.0"));
|
||||
@@ -177,7 +186,10 @@ fn skips_dep_entries_whose_alias_matches_self_name() {
|
||||
fn both_dep_maps_absent_is_a_noop() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let virtual_store_dir = tmp.path().to_path_buf();
|
||||
let layout = VirtualStoreLayout::legacy(virtual_store_dir);
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
virtual_store_dir,
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
let skipped = SkippedSnapshots::default();
|
||||
let virtual_node_modules_dir = tmp.path().join("self/node_modules");
|
||||
fs::create_dir_all(&virtual_node_modules_dir).unwrap();
|
||||
@@ -205,7 +217,10 @@ fn both_dep_maps_absent_is_a_noop() {
|
||||
fn alias_dep_links_under_alias_but_resolves_via_target() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let virtual_store_dir = tmp.path().to_path_buf();
|
||||
let layout = VirtualStoreLayout::legacy(virtual_store_dir);
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
virtual_store_dir,
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
|
||||
let mut deps: HashMap<PkgName, SnapshotDepRef> = HashMap::new();
|
||||
deps.insert(pkg_name("string-width-cjs"), dep_ref("string-width@4.2.3"));
|
||||
|
||||
@@ -60,7 +60,10 @@ async fn run_emits_imported_event_after_import_indexed_dir() {
|
||||
// but `#[tokio::test]` defaults to single-thread, so we run
|
||||
// `.run()` directly here. The function itself is sync — only
|
||||
// the caller's runtime flavor matters.
|
||||
let layout = crate::VirtualStoreLayout::legacy(virtual_store_dir.clone());
|
||||
let layout = crate::VirtualStoreLayout::legacy(
|
||||
virtual_store_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
let skipped = crate::SkippedSnapshots::default();
|
||||
CreateVirtualDirBySnapshot {
|
||||
layout: &layout,
|
||||
|
||||
@@ -398,7 +398,10 @@ fn symlink_skips_dropped_nodes() {
|
||||
// valid target. The dropped snapshot's slot is intentionally
|
||||
// absent — without the skip filter, the symlink pass would try
|
||||
// to create a link pointing at it.
|
||||
let layout = VirtualStoreLayout::legacy(&virtual_store_dir);
|
||||
let layout = VirtualStoreLayout::legacy(
|
||||
&virtual_store_dir,
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
);
|
||||
std::fs::create_dir_all(layout.slot_dir(&kept_key).join("node_modules/kept")).unwrap();
|
||||
|
||||
let mut skipped: HashSet<PackageKey> = HashSet::new();
|
||||
|
||||
@@ -15,8 +15,8 @@ use pacquet_lockfile_verification::{
|
||||
VerifyError, VerifyLockfileResolutionsOptions, verify_lockfile_resolutions,
|
||||
};
|
||||
use pacquet_modules_yaml::{
|
||||
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, IncludedDependencies, LayoutVersion, Modules,
|
||||
NodeLinker as ModulesNodeLinker, WriteModulesError, write_modules_manifest,
|
||||
Host, IncludedDependencies, LayoutVersion, Modules, NodeLinker as ModulesNodeLinker,
|
||||
WriteModulesError, write_modules_manifest,
|
||||
};
|
||||
use pacquet_network::ThrottledClient;
|
||||
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
|
||||
@@ -700,7 +700,7 @@ fn build_modules_manifest(
|
||||
skipped: skipped.iter_installability().map(ToString::to_string).collect(),
|
||||
store_dir: config.store_dir.display().to_string(),
|
||||
virtual_store_dir: config.virtual_store_dir.to_string_lossy().into_owned(),
|
||||
virtual_store_dir_max_length: DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
|
||||
virtual_store_dir_max_length: config.virtual_store_dir_max_length,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_config::Config;
|
||||
use pacquet_crypto_hash::shorten_virtual_store_name;
|
||||
use pacquet_lockfile::LockfileResolution;
|
||||
use pacquet_network::ThrottledClient;
|
||||
use pacquet_reporter::{LogEvent, LogLevel, ProgressLog, ProgressMessage, Reporter};
|
||||
@@ -105,7 +106,10 @@ impl<'a> InstallPackageFromRegistry<'a> {
|
||||
|
||||
let real_name = resolution.id.name.to_string();
|
||||
let version = resolution.id.suffix.to_string();
|
||||
let virtual_store_name = format!("{}@{}", real_name.replace('/', "+"), version);
|
||||
let virtual_store_name = shorten_virtual_store_name(
|
||||
format!("{}@{}", real_name.replace('/', "+"), version),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
);
|
||||
let package_id = format!("{real_name}@{version}");
|
||||
|
||||
// The virtual store always uses the registry-returned name
|
||||
|
||||
@@ -30,6 +30,7 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
|
||||
global_virtual_store_dir: virtual_store_dir.to_path_buf(),
|
||||
package_import_method: Default::default(),
|
||||
modules_cache_max_age: 0,
|
||||
virtual_store_dir_max_length: pacquet_config::default_virtual_store_dir_max_length(),
|
||||
lockfile: false,
|
||||
prefer_frozen_lockfile: false,
|
||||
skip_runtimes: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ use futures_util::future;
|
||||
use miette::Diagnostic;
|
||||
use pacquet_cmd_shim::{Host, LinkBinsError, link_bins};
|
||||
use pacquet_config::Config;
|
||||
use pacquet_crypto_hash::shorten_virtual_store_name;
|
||||
use pacquet_network::ThrottledClient;
|
||||
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pacquet_reporter::{LogEvent, LogLevel, Reporter, Stage, StageLog};
|
||||
@@ -307,7 +308,10 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
|
||||
// enumerates `config.virtual_store_dir` exactly as before. GVS
|
||||
// is scoped to frozen-lockfile installs (pnpm/pacquet#432); the
|
||||
// without-lockfile fallback stays project-local.
|
||||
let layout = crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone());
|
||||
let layout = crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
);
|
||||
let empty_manifests = std::collections::HashMap::new();
|
||||
let empty_skipped = crate::SkippedSnapshots::new();
|
||||
LinkVirtualStoreBins {
|
||||
@@ -372,10 +376,13 @@ where
|
||||
.get(&dep.id)
|
||||
.expect("resolve_dependency_tree must populate every referenced id");
|
||||
|
||||
let virtual_store_name = format!(
|
||||
"{}@{}",
|
||||
package.result.id.name.to_string().replace('/', "+"),
|
||||
package.result.id.suffix,
|
||||
let virtual_store_name = shorten_virtual_store_name(
|
||||
format!(
|
||||
"{}@{}",
|
||||
package.result.id.name.to_string().replace('/', "+"),
|
||||
package.result.id.suffix,
|
||||
),
|
||||
ctx.config.virtual_store_dir_max_length as usize,
|
||||
);
|
||||
|
||||
// Claim the `(name, version)` slot. `first_visit` is true iff this
|
||||
|
||||
@@ -49,7 +49,10 @@ fn writes_child_bins_into_slot_own_package_node_modules() {
|
||||
write_file(child_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
|
||||
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -117,7 +120,10 @@ fn skips_slot_own_package_when_walking_children() {
|
||||
write_file(other_dir.join("other.js"), "#!/usr/bin/env node\n").unwrap();
|
||||
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -142,7 +148,10 @@ fn link_virtual_store_bins_no_op_when_dir_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let nonexistent = tmp.path().join("does-not-exist");
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(nonexistent.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
nonexistent.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -180,7 +189,10 @@ fn link_virtual_store_bins_handles_scoped_slot_name() {
|
||||
write_file(child_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
|
||||
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -231,7 +243,10 @@ fn link_virtual_store_bins_handles_peer_resolved_slot_name() {
|
||||
write_file(child_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
|
||||
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -281,7 +296,10 @@ fn link_virtual_store_bins_handles_unscoped_name_with_plus() {
|
||||
write_file(child_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
|
||||
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -306,7 +324,10 @@ fn link_virtual_store_bins_skips_slot_without_node_modules() {
|
||||
let virtual_dir = tmp.path().join(".pacquet");
|
||||
create_dir_all(virtual_dir.join("incomplete@1.0.0")).unwrap();
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -333,7 +354,10 @@ fn link_virtual_store_bins_skips_slot_without_own_package_dir() {
|
||||
// is missing, so `find_slot_own_package_dir` returns `None`.
|
||||
create_dir_all(virtual_dir.join("foo@1.0.0/node_modules")).unwrap();
|
||||
LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(virtual_dir.clone()),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
virtual_dir.clone(),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
@@ -481,7 +505,10 @@ fn link_virtual_store_bins_propagates_read_error_via_di() {
|
||||
}
|
||||
|
||||
let err = LinkVirtualStoreBins {
|
||||
layout: &VirtualStoreLayout::legacy(PathBuf::from("/anything")),
|
||||
layout: &VirtualStoreLayout::legacy(
|
||||
PathBuf::from("/anything"),
|
||||
pacquet_config::default_virtual_store_dir_max_length() as usize,
|
||||
),
|
||||
snapshots: None,
|
||||
packages: None,
|
||||
package_manifests: &Default::default(),
|
||||
|
||||
@@ -82,7 +82,10 @@ fn emits_pnpm_root_added_per_direct_dependency() {
|
||||
|
||||
SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev],
|
||||
workspace_root: &project_root,
|
||||
@@ -205,7 +208,10 @@ fn duplicate_dep_across_groups_collapses_to_one_entry() {
|
||||
|
||||
SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
// Prod first → first-wins gives `dependencyType: prod`.
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
@@ -286,7 +292,10 @@ fn cross_importer_link_dep_symlinks_to_sibling_rootdir() {
|
||||
|
||||
SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
workspace_root: &workspace_root,
|
||||
@@ -345,7 +354,10 @@ fn empty_importers_is_a_no_op() {
|
||||
let config = config.leak();
|
||||
|
||||
let importers = HashMap::new();
|
||||
let layout = crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone());
|
||||
let layout = crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
);
|
||||
let result = SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &layout,
|
||||
@@ -424,7 +436,10 @@ fn per_importer_prefix_in_pnpm_root_events() {
|
||||
|
||||
SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
workspace_root: &workspace_root,
|
||||
@@ -489,7 +504,10 @@ fn unsafe_importer_keys_error_before_filesystem_writes() {
|
||||
|
||||
let result = SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
workspace_root: &workspace_root,
|
||||
@@ -564,7 +582,10 @@ fn custom_modules_dir_propagates_to_each_importer() {
|
||||
|
||||
SymlinkDirectDependencies {
|
||||
config,
|
||||
layout: &crate::VirtualStoreLayout::legacy(config.virtual_store_dir.clone()),
|
||||
layout: &crate::VirtualStoreLayout::legacy(
|
||||
config.virtual_store_dir.clone(),
|
||||
config.virtual_store_dir_max_length as usize,
|
||||
),
|
||||
importers: &importers,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
workspace_root: &workspace_root,
|
||||
|
||||
@@ -71,6 +71,18 @@ pub struct VirtualStoreLayout {
|
||||
///
|
||||
/// [`PkgNameVerPeer::to_virtual_store_name`]: pacquet_lockfile::PkgNameVerPeer::to_virtual_store_name
|
||||
gvs_suffixes: Option<HashMap<PackageKey, String>>,
|
||||
|
||||
/// Threshold passed into
|
||||
/// [`PkgNameVerPeer::to_virtual_store_name`] for the legacy flat-
|
||||
/// name fallback. Mirrors pnpm's `virtualStoreDirMaxLength`: when
|
||||
/// the escaped filename exceeds this many bytes, the tail is
|
||||
/// replaced with a 32-char sha256 hash so the directory name fits
|
||||
/// within filesystem limits (macOS / ext4 cap component names at
|
||||
/// 255 bytes, but pnpm defaults to 120 to leave headroom for the
|
||||
/// `<name>@<version>/` suffix appended below).
|
||||
///
|
||||
/// [`PkgNameVerPeer::to_virtual_store_name`]: pacquet_lockfile::PkgNameVerPeer::to_virtual_store_name
|
||||
virtual_store_dir_max_length: usize,
|
||||
}
|
||||
|
||||
impl VirtualStoreLayout {
|
||||
@@ -81,8 +93,12 @@ impl VirtualStoreLayout {
|
||||
/// frozen-lockfile installs (pnpm/pacquet#432), so without-lockfile
|
||||
/// callers stay on the project-local flat layout even when
|
||||
/// `enable_global_virtual_store: true` is configured.
|
||||
pub fn legacy(root: impl Into<PathBuf>) -> Self {
|
||||
VirtualStoreLayout { package_store_dir: root.into(), gvs_suffixes: None }
|
||||
pub fn legacy(root: impl Into<PathBuf>, virtual_store_dir_max_length: usize) -> Self {
|
||||
VirtualStoreLayout {
|
||||
package_store_dir: root.into(),
|
||||
gvs_suffixes: None,
|
||||
virtual_store_dir_max_length,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the layout for one install. Reads
|
||||
@@ -156,11 +172,20 @@ impl VirtualStoreLayout {
|
||||
} else {
|
||||
config.virtual_store_dir.clone()
|
||||
};
|
||||
let virtual_store_dir_max_length = config.virtual_store_dir_max_length as usize;
|
||||
if !config.enable_global_virtual_store {
|
||||
return VirtualStoreLayout { package_store_dir, gvs_suffixes: None };
|
||||
return VirtualStoreLayout {
|
||||
package_store_dir,
|
||||
gvs_suffixes: None,
|
||||
virtual_store_dir_max_length,
|
||||
};
|
||||
}
|
||||
let Some(snapshots) = snapshots else {
|
||||
return VirtualStoreLayout { package_store_dir, gvs_suffixes: Some(HashMap::new()) };
|
||||
return VirtualStoreLayout {
|
||||
package_store_dir,
|
||||
gvs_suffixes: Some(HashMap::new()),
|
||||
virtual_store_dir_max_length,
|
||||
};
|
||||
};
|
||||
let graph = lockfile_to_dep_graph(snapshots, packages);
|
||||
// Build the engine-agnostic gating set once per install,
|
||||
@@ -217,7 +242,11 @@ impl VirtualStoreLayout {
|
||||
let suffix = format_global_virtual_store_path(&name, &version, &hex_digest);
|
||||
gvs_suffixes.insert(snapshot_key.clone(), suffix);
|
||||
}
|
||||
VirtualStoreLayout { package_store_dir, gvs_suffixes: Some(gvs_suffixes) }
|
||||
VirtualStoreLayout {
|
||||
package_store_dir,
|
||||
gvs_suffixes: Some(gvs_suffixes),
|
||||
virtual_store_dir_max_length,
|
||||
}
|
||||
}
|
||||
|
||||
/// Root of the layout — the directory that contains every per-
|
||||
@@ -248,8 +277,11 @@ impl VirtualStoreLayout {
|
||||
/// to fire).
|
||||
pub fn slot_dir(&self, key: &PackageKey) -> PathBuf {
|
||||
let suffix = match &self.gvs_suffixes {
|
||||
Some(map) => map.get(key).cloned().unwrap_or_else(|| key.to_virtual_store_name()),
|
||||
None => key.to_virtual_store_name(),
|
||||
Some(map) => map
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| key.to_virtual_store_name(self.virtual_store_dir_max_length)),
|
||||
None => key.to_virtual_store_name(self.virtual_store_dir_max_length),
|
||||
};
|
||||
self.package_store_dir.join(suffix)
|
||||
}
|
||||
|
||||
@@ -169,10 +169,6 @@ impl PackageVersion {
|
||||
.pipe(Ok)
|
||||
}
|
||||
|
||||
pub fn to_virtual_store_name(&self) -> String {
|
||||
format!("{0}@{1}", self.name.replace('/', "+"), self.version)
|
||||
}
|
||||
|
||||
pub fn as_tarball_url(&self) -> &str {
|
||||
self.dist.tarball.as_str()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-fs = { workspace = true }
|
||||
pacquet-crypto-hash = { workspace = true }
|
||||
pacquet-fs = { workspace = true }
|
||||
|
||||
dashmap = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
|
||||
@@ -15,26 +15,14 @@
|
||||
use crate::StoreDir;
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_crypto_hash::create_short_hash;
|
||||
use pacquet_fs::{read_symlink_dir, remove_symlink_dir, symlink_dir};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Compute the project-registry slug for `input`. Mirrors upstream's
|
||||
/// [`createShortHash`](https://github.com/pnpm/pnpm/blob/94240bc046/crypto/hash/src/index.ts):
|
||||
/// the sha256 hex digest, truncated to the first 32 characters (16 bytes
|
||||
/// of entropy — enough to make collisions across one user's projects
|
||||
/// vanishingly unlikely).
|
||||
pub fn create_short_hash(input: &str) -> String {
|
||||
let digest = Sha256::digest(input.as_bytes());
|
||||
let mut hex = format!("{digest:x}");
|
||||
hex.truncate(32);
|
||||
hex
|
||||
}
|
||||
|
||||
/// Error type for [`register_project`].
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
pub enum RegisterProjectError {
|
||||
|
||||
Reference in New Issue
Block a user