Skip to content
Merged
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
2 changes: 2 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1220,12 +1220,14 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. |
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
| `redirect_pnpm_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. |
| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |
| `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. |
| `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. |
| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. |
| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. |
| `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. |
| `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket/vendor`, `.socket/vendor/<eco>` or the patch's `<uuid>` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. |
| `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. |
| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. |
Expand Down
12 changes: 9 additions & 3 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,9 +749,15 @@ pub(crate) async fn run_redirect_selected(
}
// A workspace member whose lock lives in an ancestor directory (pnpm
// workspace / `lockfile-dir`, cargo workspace): the rewriters would
// read only the member, so refuse before any takeover or write.
if let Some(refusal) =
socket_patch_core::hosted::governing_root::refusal(&view, &candidates).await
// read only the member, so refuse before any takeover or write. The
// same goes for a pnpm member whose `trustLockfile` setting lives in
// the workspace root's pnpm-workspace.yaml.
if let Some(refusal) = socket_patch_core::hosted::governing_root::refusal(
&view,
&candidates,
!common.no_trust_lockfile_config,
)
.await
{
return refuse(common, scan_result.take(), &refusal);
}
Expand Down
128 changes: 128 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1329,3 +1329,131 @@ async fn hosted_scan_resolves_inherited_lockfile_dir_from_cwd() {
async fn hosted_scan_workspace_yaml_overrides_member_npmrc() {
assert_workspace_configured_lock_refused("root-yaml-precedence").await;
}

/// The workspace root as refusals and warnings name it: canonical (Windows
/// expands 8.3 names) without the verbatim `\\?\` prefix.
fn member_root(tmp: &tempfile::TempDir) -> std::path::PathBuf {
socket_patch_core::utils::pnpm_workspace::without_verbatim_prefix(
std::fs::canonicalize(tmp.path()).unwrap(),
)
}

/// Every redirect warning's text, unescaped: a JSON dump doubles each
/// Windows `\`, so a path would never match.
fn warning_texts(doc: &serde_json::Value) -> String {
let mut out = String::new();
for warning in doc["redirect"]["warnings"].as_array().into_iter().flatten() {
for value in warning.as_object().into_iter().flat_map(|o| o.values()) {
if let Some(text) = value.as_str() {
out.push_str(text);
out.push('\n');
}
}
if let Some(text) = warning.as_str() {
out.push_str(text);
out.push('\n');
}
}
out
}

/// #880: a workspace member with its own lock (`sharedWorkspaceLockfile:
/// false`) is pinned through its own lock, but pnpm reads `trustLockfile`
/// only from the workspace root's `pnpm-workspace.yaml`. Hosted mode used
/// to create a nested `packages: ['.']` + `trustLockfile: true` file in the
/// member, which pnpm ignores, so every root install failed on pnpm 11/12
/// while the scan reported success. It now refuses before any write and
/// names the root file; once that file trusts the lock, the member pins
/// with no nested file.
#[tokio::test]
#[serial]
async fn hosted_scan_from_pnpm_member_with_own_lock_never_nests_trust_config() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = member_root(&tmp);
std::fs::write(
root.join("package.json"),
r#"{ "name": "root", "private": true }"#,
)
.unwrap();
let root_ws = root.join("pnpm-workspace.yaml");
let ws_before = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n";
std::fs::write(&root_ws, ws_before).unwrap();
let member = root.join("packages/a");
std::fs::create_dir_all(&member).unwrap();
write_pnpm_project(&member);
let lock = member.join("pnpm-lock.yaml");
let lock_before = std::fs::read_to_string(&lock).unwrap();

let (code, doc) = run_hosted_json(&member, &server.uri());
assert_eq!(code, Some(1), "{doc}");
assert_eq!(doc["status"], "error", "{doc}");
assert_eq!(
doc["errorCode"], "redirect_pnpm_settings_elsewhere",
"{doc}"
);
let message = doc["error"].as_str().unwrap_or_default();
assert!(
message.contains(&root_ws.display().to_string())
&& message.contains("trustLockfile: true")
&& message.contains("nothing was written"),
"the error names the root file and the key to add: {message}"
);
assert_eq!(std::fs::read_to_string(&lock).unwrap(), lock_before);
assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws_before);
assert!(!member.join("pnpm-workspace.yaml").exists());

// With the key in the root file (what pnpm reads), the member pins and
// no nested settings file is created.
let ws_trusted = format!("{ws_before}trustLockfile: true\n");
std::fs::write(&root_ws, &ws_trusted).unwrap();
let (code, doc) = run_hosted_json(&member, &server.uri());
assert_eq!(code, Some(0), "{doc}");
assert_eq!(doc["redirect"]["redirected"], 1, "{doc}");
assert!(std::fs::read_to_string(&lock).unwrap().contains(HOSTED_URL));
assert!(
!member.join("pnpm-workspace.yaml").exists(),
"pnpm ignores a member's settings file; none is created"
);
assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws_trusted);
let warnings = warning_texts(&doc);
assert!(
warnings.contains(&root_ws.display().to_string()) && warnings.contains("already carries"),
"the trust warning names the root file: {warnings}"
);
}

/// #880: an explicit `trustLockfile: false` in the root file is the user's
/// call, respected as in a single project: the member pins, nothing is
/// nested, and the warning names the root file.
#[tokio::test]
#[serial]
async fn hosted_scan_from_pnpm_member_respects_root_trust_opt_out() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;
mock_view(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = member_root(&tmp);
let root_ws = root.join("pnpm-workspace.yaml");
let ws = "packages:\n - 'packages/*'\ntrustLockfile: false\n";
std::fs::write(&root_ws, ws).unwrap();
let member = root.join("packages/a");
std::fs::create_dir_all(&member).unwrap();
write_pnpm_project(&member);

let (code, doc) = run_hosted_json(&member, &server.uri());
assert_eq!(code, Some(0), "{doc}");
assert_eq!(doc["redirect"]["redirected"], 1, "{doc}");
assert!(!member.join("pnpm-workspace.yaml").exists());
assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws);
let warnings = warning_texts(&doc);
assert!(
warnings.contains(&root_ws.display().to_string())
&& warnings.contains("trustLockfile: false"),
"{warnings}"
);
}
9 changes: 4 additions & 5 deletions crates/socket-patch-core/src/crawlers/gradle_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool {
/// Whether `bytes` are the pristine download Gradle stored in the hash
/// directory `dir_name` (their sha1 names it).
pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool {
use sha1::{Digest, Sha1};
hash_eq(dir_name, &hex::encode(Sha1::digest(bytes)))
hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes))
}

/// Whether `path` is a version directory of a `files-2.1` tree
Expand Down Expand Up @@ -432,8 +431,6 @@ impl DerivedIndex {
/// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes
/// hash to `pristine_sha1`.
pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies {
use sha1::{Digest, Sha1};

let instrumented = format!("instrumented-{jar_leaf}");
let mut out = DerivedCopies {
incomplete: self.incomplete,
Expand All @@ -460,7 +457,9 @@ impl DerivedIndex {
out.stale.push(path.clone());
} else if name == jar_leaf || name == instrumented {
match crate::utils::fs::read_regular_to_bytes_sync(path) {
Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => {
Ok(bytes)
if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) =>
{
out.stale.push(path.clone())
}
Ok(_) => out.unknown.push(path.clone()),
Expand Down
75 changes: 61 additions & 14 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use crate::patch::redirect::{
artifact_url_spellings, rewrite_registry_redirect_withholding_vlt, DepOverride, FileEdit,
RewriteResult, RewriteWarning,
};
use crate::utils::pnpm_workspace::governing_workspace_file;
use crate::utils::purl::purl_parts;
use crate::vendor::lock_inventory::{MemoryEntry, ProjectView};

Expand Down Expand Up @@ -1314,6 +1315,26 @@ fn pnpm_trust(
pnpm_trust_legacy_detail(&server)
} else if !root_lock_v9 || !options.trust_lockfile_config {
pnpm_trust_manual_guidance(&server)
} else if let Some(root_file) = governing_workspace(view) {
// A workspace member with its own lock: pnpm reads `trustLockfile`
// only from the workspace root's file, and a nested one would be
// ignored (#880). The governing-root pre-check refused every root
// file but these two, so nothing is written here.
let root_file = root_file.display().to_string();
match read_workspace_for_trust(std::path::Path::new(&root_file))
.ok()
.flatten()
.map(|text| plan_workspace_trust(Some(&text)))
{
Some(TrustPlan::AlreadyTrue) => {
pnpm_rerun_only = spliced_pnpm_locks == 0;
pnpm_trust_already_true_detail(&server, &root_file)
}
Some(TrustPlan::UserSet(value)) => {
pnpm_trust_user_set_detail(&server, &root_file, &value)
}
_ => pnpm_trust_manual_guidance(&server),
}
} else {
let (workspace, symlinked) = read_workspace(view);
workspace_symlinked = symlinked;
Expand Down Expand Up @@ -1341,21 +1362,11 @@ fn pnpm_trust(
}
TrustPlan::AlreadyTrue => {
pnpm_rerun_only = spliced_pnpm_locks == 0;
format!(
"{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \
true` — keep it committed alongside the lock; installs need \
no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}",
pnpm_trust_policy_preamble(&server),
)
pnpm_trust_already_true_detail(&server, PNPM_WORKSPACE_REL)
}
TrustPlan::UserSet(value) => {
pnpm_trust_user_set_detail(&server, PNPM_WORKSPACE_REL, &value)
}
TrustPlan::UserSet(value) => format!(
"{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \
{value}`, which was respected and left untouched — install \
with `pnpm install --trust-lockfile`, or set `trustLockfile: \
true` yourself so every install accepts the patched \
artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}",
pnpm_trust_policy_preamble(&server),
),
TrustPlan::Unsupported(why) => {
pnpm_trust_workspace_unsupported_detail(&server, &why)
}
Expand Down Expand Up @@ -1393,6 +1404,42 @@ fn pnpm_trust(
)
}

/// The trust detail for a settings file (`file`) that already carries
/// `trustLockfile: true`.
fn pnpm_trust_already_true_detail(server: &str, file: &str) -> String {
format!(
"{}, and {file} already carries `trustLockfile: true` — keep it committed \
alongside the lock; installs need no extra flags. \
{PNPM_TRUST_TRADEOFF_AND_CAUTION}",
pnpm_trust_policy_preamble(server),
)
}

/// The trust detail for a settings file (`file`) whose explicit
/// `trustLockfile: <value>` was respected.
fn pnpm_trust_user_set_detail(server: &str, file: &str, value: &str) -> String {
format!(
"{}. {file} explicitly sets `trustLockfile: {value}`, which was respected \
and left untouched — install with `pnpm install --trust-lockfile`, or set \
`trustLockfile: true` yourself so every install accepts the patched \
artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}",
pnpm_trust_policy_preamble(server),
)
}

/// The ancestor `pnpm-workspace.yaml` governing a disk project's pnpm
/// settings (see [`governing_workspace_file`]); an in-memory project has
/// no ancestors.
fn governing_workspace(view: &ProjectView<'_>) -> Option<std::path::PathBuf> {
match view {
ProjectView::Disk(cwd)
| ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot {
root: cwd, ..
}) => governing_workspace_file(cwd),
ProjectView::Memory(_) => None,
}
}

/// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every
/// tarball whose `resolved` origin is not the configured registry — which
/// is exactly what a hosted redirect writes. npm <= 11 installs it
Expand Down
Loading
Loading