Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 58 additions & 25 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use serde::{Deserialize, Serialize};

use crate::api::types::PackageVendorResult;
use crate::constants::npm_family::{
RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, VLT_HIDDEN_LOCK_REL, VLT_LOCK,
};
use crate::constants::npm_family::{VLT_HIDDEN_LOCK_REL, VLT_LOCK};
use crate::formats::cargo::source_replacement::CratesIoSource;
use crate::formats::governing_locks::PYPI_REQUIREMENTS;
use crate::patch::redirect::npmrc::{
Expand All @@ -43,6 +41,7 @@ use crate::patch::redirect::{
use crate::utils::pnpm_workspace::governing_workspace_file;
use crate::utils::purl::purl_parts;
use crate::utils::redact::url_host;
use crate::vendor::lock_inventory::pnpm::{is_rush, rush_lock_rels};
use crate::vendor::lock_inventory::{bun_text_lock_drives, MemoryEntry, ProjectView};

use super::guidance::{
Expand Down Expand Up @@ -437,17 +436,6 @@ impl CandidateFiles {
}
}

/// Whether the project is a Rush monorepo (disk: `rush.json` is a file).
fn rush_repo(view: &ProjectView<'_>) -> bool {
match view {
ProjectView::Disk(_) | ProjectView::Snapshot(_) => {
let cwd = view.disk_root().expect("a disk view has a root");
cwd.join("rush.json").is_file()
}
ProjectView::Memory(project) => project.contains("rush.json"),
}
}

/// Whether `rel` is a [`PRESENCE_ONLY`](crate::formats::registry::PRESENCE_ONLY)
/// row the in-memory host lists without usable content (a symbolic link,
/// an oversize or presence-only entry). Its planners only ask whether it
Expand Down Expand Up @@ -900,17 +888,14 @@ pub async fn read_candidate_files(
// common/config/subspaces/<name>/. Added under their repo-relative
// keys — the pnpm rewriter is basename-generalized, so nested keys are
// rewritten in place, and the write-back is path-generic.
if rush_repo(view) {
if out.read(view, unreadable, RUSH_COMMON_LOCK_REL).await {
out.rush_lock_keys.push(RUSH_COMMON_LOCK_REL.to_string());
}
// Sorted by name: deterministic output.
if let Ok(entries) = view.list_dir(RUSH_SUBSPACES_DIR).await {
for entry in entries.into_iter().filter(|e| e.is_dir) {
let key = format!("{RUSH_SUBSPACES_DIR}/{}/pnpm-lock.yaml", entry.name);
if out.read(view, unreadable, &key).await {
out.rush_lock_keys.push(key);
}
// The gate and the lock list are the ones lockfile discovery and the
// lock inventory use: sorted by name, real directories only (a
// symlinked `common/...` dir could point the write at another
// repository), traversal-safe subspace names.
if is_rush(*view).await {
for key in rush_lock_rels(*view).await {
if out.read(view, unreadable, &key).await {
out.rush_lock_keys.push(key);
}
}
} else if candidates.iter().any(|c| c.dep.ecosystem == "npm") {
Expand Down Expand Up @@ -3670,6 +3655,7 @@ pub fn warning(code: &str, detail: impl Into<String>) -> RewriteWarning {
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::npm_family::RUSH_COMMON_LOCK_REL;
use crate::vendor::lock_inventory::MemoryProject;

/// In memory as on disk, a go.work go cannot parse reaches the
Expand Down Expand Up @@ -4652,6 +4638,53 @@ snapshots:
assert!(detail.contains(PNPM_TRUST_RUSH_MIXED_NOTE), "{detail}");
}

/// Hosted pins the Rush locks lockfile discovery reads
/// (`rush_lock_rels`): a subspace dir whose name is not a safe path
/// segment is skipped, as is every lock under a symlinked
/// `common/config/subspaces` (it could point the write at another
/// repository).
#[tokio::test]
async fn rush_locks_are_the_ones_discovery_reads() {
let mut rush = MemoryProject::new();
rush.insert_text("rush.json", r#"{ "rushVersion": "5.100.0" }"#);
rush.insert_text(RUSH_COMMON_LOCK_REL, LEFT_PAD_V9_LOCK);
rush.insert_text(
"common/config/subspaces/ok/pnpm-lock.yaml",
LEFT_PAD_V9_LOCK,
);
rush.insert_text(
"common/config/subspaces/a:b/pnpm-lock.yaml",
LEFT_PAD_V9_LOCK,
);
rush.insert_text(
"common/config/subspaces/a\\b/pnpm-lock.yaml",
LEFT_PAD_V9_LOCK,
);
let (read, _) = npm_rewrite(&ProjectView::Memory(&rush), &BTreeSet::new()).await;
assert_eq!(
read.rush_lock_keys,
[
RUSH_COMMON_LOCK_REL,
"common/config/subspaces/ok/pnpm-lock.yaml"
]
);

#[cfg(unix)]
{
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir_all(outside.path().join("x")).unwrap();
std::fs::write(outside.path().join("x/pnpm-lock.yaml"), LEFT_PAD_V9_LOCK).unwrap();
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("rush.json"), "{}").unwrap();
std::fs::create_dir_all(tmp.path().join("common/config/rush")).unwrap();
std::fs::write(tmp.path().join(RUSH_COMMON_LOCK_REL), LEFT_PAD_V9_LOCK).unwrap();
std::os::unix::fs::symlink(outside.path(), tmp.path().join("common/config/subspaces"))
.unwrap();
let (read, _) = npm_rewrite(&ProjectView::Disk(tmp.path()), &BTreeSet::new()).await;
assert_eq!(read.rush_lock_keys, [RUSH_COMMON_LOCK_REL]);
}
}

/// #713 HEAL-ON-RERUN: a Rush lock an earlier run already redirected
/// splices nothing on a re-run, yet its `rush install` still needs the
/// Rush remedy — the re-run re-issues it (and still writes nothing).
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/vendor/lock_inventory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ use self::{
gem::gem_remotes,
npm::inventory_package_lock,
npm_family::finalize_npm,
pnpm::{inventory_pnpm_lock, inventory_pnpm_lock_at},
pnpm::{inventory_pnpm_lock, inventory_pnpm_lock_rel_in},
pypi::{is_public_pypi_url, python_lock_inventory, socket_reference_coords},
recover::pure_wheel_from_uv_unit,
vlt::inventory_vlt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ pub(super) async fn inventory_npm_lock_raw_in(
}
// Rush monorepos have no root package.json/lock pair; their
// single pnpm source-of-truth lives under common/config/rush/.
// The flavor probe (root-relative) can't see it, so fall back
// explicitly when the root lock is absent but rush.json is
// present.
// The flavor probe (root-relative) can't see it, so every
// refusal that got this far falls back to Rush's locks, which
// read nothing outside a Rush monorepo (no rush.json).
let rush = inventory_rush_pnpm_locks_in(view).await;
return Ok((!rush.is_empty()).then(|| (NpmLockFlavor::Pnpm, guard_npm(rush))));
}
Expand Down
151 changes: 64 additions & 87 deletions crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,83 @@
//! Rush's pnpm locks: Rush's lock enumeration and the registry view over the
//! format's model ([`crate::formats::pnpm::PnpmLock`]).

use std::path::Path;

use crate::constants::npm_family::{PNPM_LOCK, RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR};
use crate::formats::pnpm::PnpmLock;
use crate::patch::path_safety::is_safe_single_segment;
use crate::utils::fs::read_regular_to_string;

use super::view::ProjectView;
use super::LockfileEntry;

// ── file selection ──

/// Whether the project is a Rush monorepo: a regular `rush.json` file at
/// the root, probed through the view (on disk a link to a file counts; a
/// dangling link, a directory or, in memory, an implied directory does
/// not, since Rush cannot load any of them). The one gate hosted mode,
/// the vendor flavor probe, lockfile discovery and every lock-inventory
/// reader share, so they agree on which locks a Rush project installs
/// from. Hosted mode's gate is the ground truth it was taken from.
pub(crate) async fn is_rush(view: ProjectView<'_>) -> bool {
view.is_file("rush.json")
}

/// A Rush monorepo's pnpm locks, root-relative, in lockfile discovery's
/// order: the single source-of-truth lock
/// ([`RUSH_COMMON_LOCK_REL`]), then every subspace's
/// `common/config/subspaces/<name>/pnpm-lock.yaml`, sorted by name — real
/// directories only (a symlinked subspace dir could point the read outside
/// the project) with traversal-safe UTF-8 names. Stat / list only; whether
/// the project IS a Rush monorepo (`rush.json`) is the caller's check.
/// order: the single source-of-truth lock ([`RUSH_COMMON_LOCK_REL`]), then
/// every subspace's `common/config/subspaces/<name>/pnpm-lock.yaml`, sorted
/// by name. Every directory on the way is a real one, never a symlink that
/// could point the read at another repository: `common`, `common/config`
/// and `common/config/rush` for the common lock (else it is left out);
/// `common`, `common/config`, `common/config/subspaces` and each subspace
/// dir for the subspace locks (else they are). Subspace names must be
/// traversal-safe UTF-8 segments. Stat / list only; whether the project IS
/// a Rush monorepo is the caller's [`is_rush`] check.
pub(crate) async fn rush_lock_rels(view: ProjectView<'_>) -> Vec<String> {
let mut names: Vec<String> = view
.list_dir(RUSH_SUBSPACES_DIR)
.await
.unwrap_or_default()
.into_iter()
.filter(|entry| entry.is_dir && is_safe_single_segment(&entry.name))
.map(|entry| entry.name)
.collect();
names.sort();
let mut rels = vec![RUSH_COMMON_LOCK_REL.to_string()];
let mut rels = Vec::new();
if !has_symlinked_dir(view, RUSH_COMMON_LOCK_DIR) {
rels.push(RUSH_COMMON_LOCK_REL.to_string());
}
if has_symlinked_dir(view, RUSH_SUBSPACES_DIR) {
return rels;
}
// The listing is sorted by name; `is_dir` does not follow links.
rels.extend(
names
view.list_dir(RUSH_SUBSPACES_DIR)
.await
.unwrap_or_default()
.into_iter()
.map(|name| format!("{RUSH_SUBSPACES_DIR}/{name}/{PNPM_LOCK}")),
.filter(|entry| entry.is_dir && is_safe_single_segment(&entry.name))
.map(|entry| format!("{RUSH_SUBSPACES_DIR}/{}/{PNPM_LOCK}", entry.name)),
);
rels
}

/// The directory holding [`RUSH_COMMON_LOCK_REL`].
const RUSH_COMMON_LOCK_DIR: &str = "common/config/rush";

/// Whether root-relative directory `dir` or any directory above it (below
/// the root) is a symlink.
fn has_symlinked_dir(view: ProjectView<'_>, dir: &str) -> bool {
dir.match_indices('/')
.map(|(at, _)| &dir[..at])
.chain([dir])
.any(|rel| view.is_symlink(rel))
}

// ── registry view ──

#[cfg(test)]
pub(super) async fn inventory_pnpm_lock(root: &Path) -> Option<Vec<LockfileEntry>> {
pub(super) async fn inventory_pnpm_lock(root: &std::path::Path) -> Option<Vec<LockfileEntry>> {
inventory_pnpm_lock_in(&ProjectView::Disk(root)).await
}

pub(super) async fn inventory_pnpm_lock_in(view: &ProjectView<'_>) -> Option<Vec<LockfileEntry>> {
inventory_pnpm_lock_rel_in(view, PNPM_LOCK).await
}

/// [`inventory_pnpm_lock_at`] for a project-relative lock path.
/// Inventory a project-relative `pnpm-lock.yaml` (the root lock, a
/// member's, or a Rush common / subspace lock) through the view:
/// [`PnpmLock::entries`], `None` when the lock is unreadable or has no
/// `packages:` section.
pub(super) async fn inventory_pnpm_lock_rel_in(
view: &ProjectView<'_>,
rel: &str,
Expand All @@ -60,22 +87,14 @@ pub(super) async fn inventory_pnpm_lock_rel_in(
pnpm_lock_text_inventory(&text)
}

/// Inventory a specific `pnpm-lock.yaml` (path given explicitly so the Rush
/// fallback can point it at `common/config/rush/…` and subspace locks):
/// [`PnpmLock::entries`], `None` when the lock has no `packages:` section.
pub(super) async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option<Vec<LockfileEntry>> {
let text = read_regular_to_string(lock_path).await.ok()?;
pnpm_lock_text_inventory(&text)
}

/// Inventory the workspace members' own locks pnpm installs from under
/// `sharedWorkspaceLockfile: false` (#492), the same ones hosted mode pins
/// and discovery reads ([`member_locks`]): empty for a shared lock, an
/// unresolved member list, or a Rush monorepo (whose locks are read
/// instead). A member lock that cannot be read adds nothing.
pub(super) async fn inventory_pnpm_member_locks_in(view: &ProjectView<'_>) -> Vec<LockfileEntry> {
use crate::utils::pnpm_workspace::{member_locks, MemberLocks};
if view.is_file("rush.json") {
if is_rush(*view).await {
return Vec::new();
}
let MemberLocks::PerMember(keys) = member_locks(view).await else {
Expand All @@ -102,68 +121,26 @@ fn pnpm_lock_text_inventory(text: &str) -> Option<Vec<LockfileEntry>> {
/// `common/config/subspaces/<name>/pnpm-lock.yaml`. `rush install` copies
/// the source lock into common/temp and runs pnpm there.
///
/// Only called (via [`inventory_npm_lock`]) when there is NO root lock but
/// `rush.json` is present, so it never shadows a plain pnpm project. The
/// subspace directory is read sorted for deterministic output. Missing
/// files/dirs are skipped fail-soft; the caller drops the whole result when
/// it comes back empty.
/// The last fallback of [`super::npm_family::inventory_npm_lock_raw_in`]:
/// reached whenever the npm-family flavor probe refuses the project (any
/// refusal but the PnP ones, which propagate) and no earlier fallback (the
/// out-of-family root pnpm lock, a live sibling lock, pnpm <= 2's
/// `shrinkwrap.yaml`, the members' own locks) returned entries — typically
/// no root lock at all, where the probe refuses with
/// `vendor_rush_unsupported` or `vendor_lockfile_missing`. Outside a Rush
/// monorepo ([`is_rush`]) it reads nothing. The locks are the ones lockfile
/// discovery reads ([`rush_lock_rels`]), each read through the view.
/// Missing files/dirs are skipped fail-soft; the caller drops the whole
/// result when it comes back empty.
pub(super) async fn inventory_rush_pnpm_locks_in(view: &ProjectView<'_>) -> Vec<LockfileEntry> {
let project = match view {
ProjectView::Disk(_) | ProjectView::Snapshot(_) => {
let project_root = view.disk_root().expect("a disk view has a root");
return inventory_rush_pnpm_locks(project_root).await;
}
ProjectView::Memory(project) => *project,
};
if !project.contains("rush.json") {
if !is_rush(*view).await {
return Vec::new();
}
let mut out = Vec::new();
if let Some(entries) = inventory_pnpm_lock_rel_in(view, RUSH_COMMON_LOCK_REL).await {
out.extend(entries);
}
for (name, is_dir) in project.children(RUSH_SUBSPACES_DIR) {
if !is_dir {
continue;
}
let rel = format!("{RUSH_SUBSPACES_DIR}/{name}/{PNPM_LOCK}");
for rel in rush_lock_rels(*view).await {
if let Some(entries) = inventory_pnpm_lock_rel_in(view, &rel).await {
out.extend(entries);
}
}
out
}

async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec<LockfileEntry> {
if tokio::fs::metadata(project_root.join("rush.json"))
.await
.is_err()
{
return Vec::new();
}
let mut out = Vec::new();

// The single source-of-truth lock.
let common_lock = project_root.join(RUSH_COMMON_LOCK_REL);
if let Some(entries) = inventory_pnpm_lock_at(&common_lock).await {
out.extend(entries);
}

// Per-subspace locks, sorted for determinism.
let subspaces_dir = project_root.join(RUSH_SUBSPACES_DIR);
if let Ok(mut read_dir) = tokio::fs::read_dir(&subspaces_dir).await {
let mut subspace_dirs: Vec<std::path::PathBuf> = Vec::new();
while let Ok(Some(entry)) = read_dir.next_entry().await {
if entry.file_type().await.is_ok_and(|t| t.is_dir()) {
subspace_dirs.push(entry.path());
}
}
subspace_dirs.sort();
for dir in subspace_dirs {
if let Some(entries) = inventory_pnpm_lock_at(&dir.join(PNPM_LOCK)).await {
out.extend(entries);
}
}
}
out
}
Loading
Loading