diff --git a/Cargo.lock b/Cargo.lock index b20e8e60ff..2902664bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 665c532964..2530a3b1fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/pacquet/crates/config/src/defaults.rs b/pacquet/crates/config/src/defaults.rs index 60a87a1b58..3b72b84621 100644 --- a/pacquet/crates/config/src/defaults.rs +++ b/pacquet/crates/config/src/defaults.rs @@ -217,6 +217,18 @@ pub fn default_modules_cache_max_age() -> u64 { 10080 } +/// Default `virtualStoreDirMaxLength` matching pnpm's fallback at +/// . +/// +/// 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 } diff --git a/pacquet/crates/config/src/env_overlay.rs b/pacquet/crates/config/src/env_overlay.rs index 318b46db1f..61f4f3d755 100644 --- a/pacquet/crates/config/src/env_overlay.rs +++ b/pacquet/crates/config/src/env_overlay.rs @@ -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"); diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index 198092906e..61e8dcd10e 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -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/`). 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/` + /// 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 + /// . + /// 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::(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::(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 { + 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 { + None + } + } + impl GetHomeDir for HostWithEnvOverride { + fn home_dir() -> Option { + None + } + } + + let config = Config::new().current::(tmp.path()).expect("loads"); + assert_eq!( + config.virtual_store_dir_max_length, 50, + "env var must win over pnpm-workspace.yaml", + ); + } } diff --git a/pacquet/crates/config/src/workspace_yaml.rs b/pacquet/crates/config/src/workspace_yaml.rs index bf3034e56a..f37339496b 100644 --- a/pacquet/crates/config/src/workspace_yaml.rs +++ b/pacquet/crates/config/src/workspace_yaml.rs @@ -114,6 +114,7 @@ pub struct WorkspaceSettings { pub global_virtual_store_dir: Option, pub package_import_method: Option, pub modules_cache_max_age: Option, + pub virtual_store_dir_max_length: Option, pub lockfile: Option, pub prefer_frozen_lockfile: Option, pub offline: Option, @@ -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, diff --git a/pacquet/crates/crypto-hash/Cargo.toml b/pacquet/crates/crypto-hash/Cargo.toml new file mode 100644 index 0000000000..fdd1c8f606 --- /dev/null +++ b/pacquet/crates/crypto-hash/Cargo.toml @@ -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 diff --git a/pacquet/crates/crypto-hash/src/lib.rs b/pacquet/crates/crypto-hash/src/lib.rs new file mode 100644 index 0000000000..09400f9b8e --- /dev/null +++ b/pacquet/crates/crypto-hash/src/lib.rs @@ -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); + } +} diff --git a/pacquet/crates/lockfile/Cargo.toml b/pacquet/crates/lockfile/Cargo.toml index 87d59a6c2b..27005d8d14 100644 --- a/pacquet/crates/lockfile/Cargo.toml +++ b/pacquet/crates/lockfile/Cargo.toml @@ -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 } diff --git a/pacquet/crates/lockfile/src/pkg_name_ver_peer.rs b/pacquet/crates/lockfile/src/pkg_name_ver_peer.rs index 6fdf056d02..d27794edb8 100644 --- a/pacquet/crates/lockfile/src/pkg_name_ver_peer.rs +++ b/pacquet/crates/lockfile/src/pkg_name_ver_peer.rs @@ -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; pub type ParsePkgNameVerPeerError = ParsePkgNameSuffixError; 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 `_<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. diff --git a/pacquet/crates/lockfile/src/pkg_name_ver_peer/tests.rs b/pacquet/crates/lockfile/src/pkg_name_ver_peer/tests.rs index 76eadf9743..afeabec89d 100644 --- a/pacquet/crates/lockfile/src/pkg_name_ver_peer/tests.rs +++ b/pacquet/crates/lockfile/src/pkg_name_ver_peer/tests.rs @@ -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())); +} diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index 7f85cd20db..23eab08844 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -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 } diff --git a/pacquet/crates/package-manager/src/build_modules/tests.rs b/pacquet/crates/package-manager/src/build_modules/tests.rs index a601047711..7f9cd576b1 100644 --- a/pacquet/crates/package-manager/src/build_modules/tests.rs +++ b/pacquet/crates/package-manager/src/build_modules/tests.rs @@ -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), diff --git a/pacquet/crates/package-manager/src/create_symlink_layout/tests.rs b/pacquet/crates/package-manager/src/create_symlink_layout/tests.rs index fb70ad292e..641ffb4c4b 100644 --- a/pacquet/crates/package-manager/src/create_symlink_layout/tests.rs +++ b/pacquet/crates/package-manager/src/create_symlink_layout/tests.rs @@ -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 = 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 = 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 = 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 = HashMap::new(); deps.insert(pkg_name("string-width-cjs"), dep_ref("string-width@4.2.3")); diff --git a/pacquet/crates/package-manager/src/create_virtual_dir_by_snapshot/tests.rs b/pacquet/crates/package-manager/src/create_virtual_dir_by_snapshot/tests.rs index 50b737b1f6..5b091a778b 100644 --- a/pacquet/crates/package-manager/src/create_virtual_dir_by_snapshot/tests.rs +++ b/pacquet/crates/package-manager/src/create_virtual_dir_by_snapshot/tests.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/hoist/tests.rs b/pacquet/crates/package-manager/src/hoist/tests.rs index c779d6f4ee..e0ed3c8262 100644 --- a/pacquet/crates/package-manager/src/hoist/tests.rs +++ b/pacquet/crates/package-manager/src/hoist/tests.rs @@ -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 = HashSet::new(); diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 43ecccafb3..12e93da206 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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() } } diff --git a/pacquet/crates/package-manager/src/install_package_from_registry.rs b/pacquet/crates/package-manager/src/install_package_from_registry.rs index aab4234cb2..40348f2ef2 100644 --- a/pacquet/crates/package-manager/src/install_package_from_registry.rs +++ b/pacquet/crates/package-manager/src/install_package_from_registry.rs @@ -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 diff --git a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs index dabd752c13..06f03f2bb2 100644 --- a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs +++ b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/install_without_lockfile.rs b/pacquet/crates/package-manager/src/install_without_lockfile.rs index cb83e5884a..079fbdcb04 100644 --- a/pacquet/crates/package-manager/src/install_without_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_without_lockfile.rs @@ -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 diff --git a/pacquet/crates/package-manager/src/link_bins/tests.rs b/pacquet/crates/package-manager/src/link_bins/tests.rs index f69bfccd2e..074cd7505e 100644 --- a/pacquet/crates/package-manager/src/link_bins/tests.rs +++ b/pacquet/crates/package-manager/src/link_bins/tests.rs @@ -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(), diff --git a/pacquet/crates/package-manager/src/symlink_direct_dependencies/tests.rs b/pacquet/crates/package-manager/src/symlink_direct_dependencies/tests.rs index 0c66d5738c..ece9649480 100644 --- a/pacquet/crates/package-manager/src/symlink_direct_dependencies/tests.rs +++ b/pacquet/crates/package-manager/src/symlink_direct_dependencies/tests.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/virtual_store_layout.rs b/pacquet/crates/package-manager/src/virtual_store_layout.rs index 870b0248b9..3722d56a23 100644 --- a/pacquet/crates/package-manager/src/virtual_store_layout.rs +++ b/pacquet/crates/package-manager/src/virtual_store_layout.rs @@ -71,6 +71,18 @@ pub struct VirtualStoreLayout { /// /// [`PkgNameVerPeer::to_virtual_store_name`]: pacquet_lockfile::PkgNameVerPeer::to_virtual_store_name gvs_suffixes: Option>, + + /// 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 + /// `@/` 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) -> Self { - VirtualStoreLayout { package_store_dir: root.into(), gvs_suffixes: None } + pub fn legacy(root: impl Into, 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) } diff --git a/pacquet/crates/registry/src/package_version.rs b/pacquet/crates/registry/src/package_version.rs index b54ab4a20b..3d199419ea 100644 --- a/pacquet/crates/registry/src/package_version.rs +++ b/pacquet/crates/registry/src/package_version.rs @@ -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() } diff --git a/pacquet/crates/store-dir/Cargo.toml b/pacquet/crates/store-dir/Cargo.toml index 5d9bbc7ad3..cc2d1f3f83 100644 --- a/pacquet/crates/store-dir/Cargo.toml +++ b/pacquet/crates/store-dir/Cargo.toml @@ -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 } diff --git a/pacquet/crates/store-dir/src/project_registry.rs b/pacquet/crates/store-dir/src/project_registry.rs index c5a9fe3969..fc71d9a192 100644 --- a/pacquet/crates/store-dir/src/project_registry.rs +++ b/pacquet/crates/store-dir/src/project_registry.rs @@ -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 {