Skip to content

Commit 14a5931

Browse files
mikolalysenkoclaude
andcommitted
Merge origin/main into agent/fix-vendor-check-wiring-liveness
Brings in the vex_consumed alias test fix (#849) that main's red test/test-release/coverage jobs were waiting on. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents e391912 + 99f61d2 commit 14a5931

37 files changed

Lines changed: 2874 additions & 402 deletions

‎AGENTS.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Agent instructions
2+
3+
## CHANGELOG.md is written only at release time
4+
5+
Don't add, edit or delete entries in `CHANGELOG.md` in a feature, fix,
6+
refactor, CI or docs PR, even if a doc, template or reviewer asks for one.
7+
The `[Unreleased]` section is written when a release is cut, by the release
8+
agent, from the PRs merged since the last tag and the code itself. Put the
9+
details of a change in its commit messages and PR description instead.
10+
11+
If a PR you work on already changes `CHANGELOG.md`, restore that file to its
12+
merge-base version. The only exceptions are release PRs: a `release/v*`
13+
branch, or the release train's `release-sync` PR.

‎CHANGELOG.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ Pre-v3.0 entries are concise summaries derived from each tag's commit
99
history. For full per-release detail, see the
1010
[GitHub releases page](https://github.com/SocketDev/socket-patch/releases).
1111

12-
Add entries under `[Unreleased]`; its `###` headings set the next version's
13-
bump (Breaking/Removed → major, Added/Changed/Deprecated → minor, anything
14-
else → patch). Releases are cut by the release train
12+
PRs never edit this file. Only the release agent writes `[Unreleased]`, at
13+
release time, from the PRs merged since the last tag and the code they
14+
changed. Its `###` headings set the next version's bump (Breaking/Removed →
15+
major, Added/Changed/Deprecated → minor, anything else → patch). Releases
16+
are cut by the release train
1517
([docs/release-train/DESIGN.md](docs/release-train/DESIGN.md)) with
1618
`scripts/release.py`: a release candidate's `[Unreleased]` entries become a
1719
`## [X.Y.Z-rc.N]` section, the rolling `release-sync` PR brings each cut

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ Recognition rules that hold for every ecosystem:
387387
|---|---|---|
388388
| Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` |
389389
| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. | `(redirected)` |
390-
| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`) must hash to the patched bytes, as `apply` patches every copy. One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none |
390+
| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`; pnpm, vlt, Bun and Deno stores add peer-variant copies and copies bundled inside other packages) must hash to the patched bytes, as `apply` patches every copy. One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none |
391391

392392
**Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates:
393393

‎crates/socket-patch-cli/src/commands/vex_consumed.rs‎

Lines changed: 253 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
use std::collections::{BTreeMap, HashMap};
4141
use std::path::{Path, PathBuf};
4242

43-
use socket_patch_core::crawlers::npm_crawler::find_store_peer_variant_copies;
43+
#[cfg(not(test))]
44+
use socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies;
4445
use socket_patch_core::crawlers::{
4546
CargoCrawler, CrawlerOptions, Ecosystem, GoCrawler, MavenCrawler, NpmCrawler,
4647
};
@@ -50,6 +51,8 @@ use socket_patch_core::vendor::go_mod_edit::{
5051
};
5152
use socket_patch_core::vendor::lock_inventory::LockIntegrity;
5253
use socket_patch_core::vex::HostedCopies;
54+
#[cfg(test)]
55+
use tests::recording_store_variants as with_store_peer_variant_copies;
5356

5457
use crate::args::GlobalArgs;
5558
use crate::commands::vex_sources::HostedWiring;
@@ -61,8 +64,9 @@ use crate::ecosystem_dispatch::{
6164
/// module docs), under the same crawler options and `--ecosystems` scope as
6265
/// the installed-tree lookup. `installed` is that lookup's every-copy
6366
/// result ([`crate::ecosystem_dispatch::find_manifest_package_copies_reusing`] over
64-
/// the record view, which holds every hosted purl): the shared-location
65-
/// ecosystems read it instead of crawling the tree a second time. `prior`
67+
/// the record view, which holds every hosted purl), including npm store
68+
/// variants. The shared-location ecosystems read it instead of crawling
69+
/// the tree a second time. `prior`
6670
/// (embedded hosted `scan --vex` only) is scan's npm crawl of the same
6771
/// tree: the alias walk takes its `node_modules` roots and the identity
6872
/// fallback its packages instead of walking the tree again.
@@ -109,13 +113,38 @@ pub(crate) async fn hosted_consumed_copies(
109113
let mut paths = all.remove(purl).unwrap_or_default();
110114
// The installed-tree lookup already resolves importer-tree
111115
// aliases, so most of the walk's finds are in `paths` already.
112-
for alias in aliases.remove(purl).unwrap_or_default() {
113-
if !paths.contains(&alias) {
114-
paths.push(alias);
116+
let extra: Vec<PathBuf> = aliases
117+
.remove(purl)
118+
.unwrap_or_default()
119+
.into_iter()
120+
.filter(|alias| !paths.contains(alias))
121+
.collect();
122+
if npm.contains(&purl) && installed.get(purl).is_some_and(|p| !p.is_empty()) {
123+
// The installed lookup already expanded these copies.
124+
// Expanding its N variants again scans the store N times.
125+
// Only aliases are new; expand them before merging so a
126+
// different alias/store can still contribute more copies.
127+
if !extra.is_empty() {
128+
let added = with_store_peer_variant_copies(extra).await;
129+
let mut seen = std::collections::HashSet::new();
130+
for path in &paths {
131+
seen.insert(tokio::fs::canonicalize(path).await.unwrap_or(path.clone()));
132+
}
133+
for path in added {
134+
let canonical =
135+
tokio::fs::canonicalize(&path).await.unwrap_or(path.clone());
136+
if seen.insert(canonical) {
137+
paths.push(path);
138+
}
139+
}
115140
}
116-
}
117-
if npm.contains(&purl) {
118-
paths = with_store_variants(paths).await;
141+
} else if npm.contains(&purl) {
142+
// No installed copies: the identity fallback and aliases
143+
// have not had their store variants enumerated yet.
144+
paths.extend(extra);
145+
paths = with_store_peer_variant_copies(paths).await;
146+
} else {
147+
paths.extend(extra);
119148
}
120149
out.insert(
121150
purl.clone(),
@@ -321,28 +350,6 @@ async fn npm_identity_fallback_reusing(
321350
}
322351
}
323352

324-
/// `paths` plus every store variant of each (a pnpm peer suffix, a vlt peer
325-
/// or modifier extra, a vlt registry-alias instance of the same
326-
/// `name@version`): the crawler resolves a store copy only for a package
327-
/// with no importer copy, leaving the variants to apply's fan-out, but each
328-
/// variant is what some dependent loads.
329-
async fn with_store_variants(paths: Vec<PathBuf>) -> Vec<PathBuf> {
330-
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
331-
for path in &paths {
332-
seen.insert(tokio::fs::canonicalize(path).await.unwrap_or(path.clone()));
333-
}
334-
let mut out = paths.clone();
335-
for path in &paths {
336-
for copy in find_store_peer_variant_copies(path).await {
337-
let canonical = tokio::fs::canonicalize(&copy).await.unwrap_or(copy.clone());
338-
if seen.insert(canonical) {
339-
out.push(copy);
340-
}
341-
}
342-
}
343-
out
344-
}
345-
346353
// ── golang ───────────────────────────────────────────────────────────────
347354

348355
/// Under `replace M v => patch.socket.dev/gopatch/<uuid> <sver>` the build
@@ -604,6 +611,219 @@ async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWirin
604611
mod tests {
605612
use super::*;
606613

614+
tokio::task_local! {
615+
// Observe real expansion work only in the regression's own task;
616+
// concurrent tests keep calling the production helper normally.
617+
static VARIANT_INPUTS: std::cell::RefCell<Vec<Vec<PathBuf>>>;
618+
}
619+
620+
pub(super) async fn recording_store_variants(paths: Vec<PathBuf>) -> Vec<PathBuf> {
621+
let _ = VARIANT_INPUTS.try_with(|calls| calls.borrow_mut().push(paths.clone()));
622+
socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies(paths).await
623+
}
624+
625+
#[cfg(unix)]
626+
async fn tracked_npm_hosted(
627+
common: &GlobalArgs,
628+
installed: &HashMap<String, Vec<PathBuf>>,
629+
) -> (Vec<PathBuf>, Vec<Vec<PathBuf>>) {
630+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
631+
let hosted = BTreeMap::from([(
632+
purl.clone(),
633+
HostedWiring {
634+
uuid: "11111111-1111-4111-8111-111111111111".to_string(),
635+
refs: Vec::new(),
636+
},
637+
)]);
638+
VARIANT_INPUTS
639+
.scope(std::cell::RefCell::new(Vec::new()), async {
640+
let mut found = hosted_consumed_copies(common, &hosted, installed, None).await;
641+
let paths = found.remove(&purl).unwrap().paths;
642+
let calls = VARIANT_INPUTS.with(|inputs| inputs.borrow().clone());
643+
(paths, calls)
644+
})
645+
.await
646+
}
647+
648+
#[cfg(unix)]
649+
fn peer_copies(store: &Path, count: usize) -> Vec<PathBuf> {
650+
(0..count)
651+
.map(|i| {
652+
let path = store.join(format!(
653+
"left-pad@1.3.0(peer@1.0.{i})/node_modules/left-pad"
654+
));
655+
pkg(&path, "left-pad", "1.3.0");
656+
path
657+
})
658+
.collect()
659+
}
660+
661+
#[cfg(unix)]
662+
#[tokio::test]
663+
async fn hosted_reuses_expanded_npm_copies_and_merges_alias_variants() {
664+
let tmp = tempfile::tempdir().unwrap();
665+
let nm = tmp.path().canonicalize().unwrap().join("node_modules");
666+
let peers = peer_copies(&nm.join(".pnpm"), 8);
667+
std::os::unix::fs::symlink(&peers[0], nm.join("left-pad")).unwrap();
668+
let common = GlobalArgs {
669+
cwd: tmp.path().canonicalize().unwrap(),
670+
ecosystems: Some(vec!["npm".to_string()]),
671+
..GlobalArgs::default()
672+
};
673+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
674+
let installed = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
675+
std::slice::from_ref(&purl),
676+
&common,
677+
true,
678+
None,
679+
)
680+
.await;
681+
assert_eq!(installed[&purl].len(), peers.len());
682+
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
683+
assert_eq!(paths, installed[&purl]);
684+
assert!(
685+
calls.is_empty(),
686+
"already-expanded copies were rescanned: {calls:?}"
687+
);
688+
689+
// A real alias is absent from the name-keyed installed set. Its
690+
// store variants overlap that set canonically, including the
691+
// importer link's physical copy; keep the alias once and preserve
692+
// the original importer-first path choices.
693+
let alias = nm.join("lp");
694+
pkg(&alias, "left-pad", "1.3.0");
695+
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
696+
assert_eq!(calls, vec![vec![alias.clone()]]);
697+
let mut expected = installed[&purl].clone();
698+
expected.push(alias);
699+
assert_eq!(paths, expected);
700+
701+
// An alias beneath a real nested host can reach another store.
702+
// The installed root copy makes the name-keyed resolver skip
703+
// those peers, so alias expansion must still add them even when
704+
// installed copies are already present.
705+
let host = nm.join("host");
706+
pkg(&host, "host", "1.0.0");
707+
let host_nm = host.join("node_modules");
708+
let nested_peers = peer_copies(&host_nm.join(".pnpm"), 2);
709+
let nested_alias = host_nm.join("lp");
710+
pkg(&nested_alias, "left-pad", "1.3.0");
711+
let installed_again = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
712+
std::slice::from_ref(&purl),
713+
&common,
714+
true,
715+
None,
716+
)
717+
.await;
718+
// Since #605 the name-keyed resolver probes bundled trees itself, so
719+
// it already returns the aliases and the nested store's peers. Feed
720+
// the earlier, alias-free set to keep exercising alias expansion;
721+
// the resolver's own set is checked against the same result below.
722+
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
723+
assert_eq!(calls.len(), 1);
724+
let mut inputs = calls[0].clone();
725+
inputs.sort();
726+
let mut aliases = vec![nm.join("lp"), nested_alias.clone()];
727+
aliases.sort();
728+
assert_eq!(inputs, aliases);
729+
assert_eq!(&paths[..installed[&purl].len()], installed[&purl]);
730+
expected.push(nested_alias);
731+
expected.extend(nested_peers);
732+
let mut actual = paths.clone();
733+
actual.sort();
734+
expected.sort();
735+
assert_eq!(actual, expected);
736+
assert_eq!(
737+
paths
738+
.iter()
739+
.map(|path| path.canonicalize().unwrap())
740+
.collect::<std::collections::HashSet<_>>()
741+
.len(),
742+
paths.len()
743+
);
744+
let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await;
745+
resolved.sort();
746+
assert_eq!(resolved, expected, "the resolver's own copy set");
747+
}
748+
749+
#[cfg(unix)]
750+
#[tokio::test]
751+
async fn hosted_expands_alias_only_copies() {
752+
let tmp = tempfile::tempdir().unwrap();
753+
let store = tmp
754+
.path()
755+
.canonicalize()
756+
.unwrap()
757+
.join("node_modules/.pnpm");
758+
let peers = peer_copies(&store, 2);
759+
// Run within a store package whose nested dependency is an alias.
760+
// The sibling peer copies are outside its project-root search.
761+
let root = store.join("host@1.0.0/node_modules/host");
762+
let alias = root.join("node_modules/lp");
763+
pkg(&alias, "left-pad", "1.3.0");
764+
let common = GlobalArgs {
765+
cwd: root,
766+
ecosystems: Some(vec!["npm".to_string()]),
767+
..GlobalArgs::default()
768+
};
769+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
770+
let installed = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
771+
std::slice::from_ref(&purl),
772+
&common,
773+
true,
774+
None,
775+
)
776+
.await;
777+
// Since #605 the name-keyed resolver reaches the alias and its
778+
// sibling peers on its own. An alias-only set (what an alias-blind
779+
// resolver returns) must still expand to the same copies.
780+
let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await;
781+
assert_eq!(calls, vec![vec![alias.clone()]]);
782+
let mut expected = peers;
783+
expected.push(alias);
784+
paths.sort();
785+
expected.sort();
786+
assert_eq!(paths, expected);
787+
let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await;
788+
resolved.sort();
789+
assert_eq!(resolved, expected, "the resolver's own copy set");
790+
}
791+
792+
#[cfg(unix)]
793+
#[tokio::test]
794+
async fn hosted_expands_identity_fallback_with_empty_installed_entry() {
795+
let tmp = tempfile::tempdir().unwrap();
796+
let peers = peer_copies(
797+
&tmp.path()
798+
.canonicalize()
799+
.unwrap()
800+
.join("external/node_modules/.pnpm"),
801+
2,
802+
);
803+
let root = tmp.path().canonicalize().unwrap().join("project");
804+
let alias = root.join("node_modules/lp");
805+
std::fs::create_dir_all(alias.parent().unwrap()).unwrap();
806+
std::os::unix::fs::symlink(&peers[0], &alias).unwrap();
807+
let common = GlobalArgs {
808+
cwd: root,
809+
ecosystems: Some(vec!["npm".to_string()]),
810+
..GlobalArgs::default()
811+
};
812+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
813+
assert!(
814+
npm_alias_copies(&common.crawler_options(), std::slice::from_ref(&purl))
815+
.await
816+
.is_empty()
817+
);
818+
let installed = HashMap::from([(purl, Vec::new())]);
819+
let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await;
820+
assert_eq!(calls, vec![vec![alias.clone()]]);
821+
let mut expected = vec![alias, peers[1].clone()];
822+
paths.sort();
823+
expected.sort();
824+
assert_eq!(paths, expected);
825+
}
826+
607827
fn pkg(dir: &Path, name: &str, version: &str) {
608828
std::fs::create_dir_all(dir).unwrap();
609829
std::fs::write(
@@ -857,7 +1077,7 @@ mod tests {
8571077
nm.join("left-pad"),
8581078
)
8591079
.unwrap();
860-
let mut got = with_store_variants(vec![nm.join("left-pad")]).await;
1080+
let mut got = with_store_peer_variant_copies(vec![nm.join("left-pad")]).await;
8611081
got.sort();
8621082
let mut want = vec![
8631083
nm.join("left-pad"),
@@ -866,7 +1086,7 @@ mod tests {
8661086
];
8671087
want.sort();
8681088
assert_eq!(got, want);
869-
assert!(with_store_variants(Vec::new()).await.is_empty());
1089+
assert!(with_store_peer_variant_copies(Vec::new()).await.is_empty());
8701090
}
8711091

8721092
/// vlt twin of the `.pnpm` case: every importer entry is a link into

0 commit comments

Comments
 (0)