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:
Zoltan Kochan
2026-05-20 15:39:57 +02:00
committed by GitHub
parent e5e7b7241d
commit c068720dec
26 changed files with 515 additions and 78 deletions

10
Cargo.lock generated
View File

@@ -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",

View File

@@ -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" }

View File

@@ -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
}

View File

@@ -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");

View File

@@ -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",
);
}
}

View File

@@ -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,

View 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

View 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);
}
}

View File

@@ -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 }

View File

@@ -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.

View File

@@ -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()));
}

View File

@@ -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 }

View File

@@ -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),

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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();

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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 }

View File

@@ -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 {