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: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc
**Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode.

* **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== <dir> ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run.
* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: a pnpm workspace member's link into the root `.pnpm` store and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**.
* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: any package link in a `node_modules` root, such as a pnpm workspace member's link into the root `.pnpm` store (or npm's linked `.store`), alias links included (`packages/a/node_modules/lp` for `"lp": "npm:left-pad@1.3.0"`, matched by its target's own `package.json` name@version), and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**.

`scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): an npm-family package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure (#954); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): <purls>`) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads `<purl> (replacing <short uuid>)`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation") — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] <purl> (<code>): <detail>` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one).

Expand Down
3 changes: 2 additions & 1 deletion crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1591,10 +1591,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
global: args.common.global,
global_prefix: args.common.global_prefix.clone(),
};
let discovered = find_all_packages_for_rollback(
let discovered = crate::ecosystem_dispatch::find_package_paths_for_path_scope(
&partitioned,
&crawler_options,
args.common.silent || args.common.json,
None,
)
.await;
// One single-pattern scope per raw pattern, compiled once (not per
Expand Down
5 changes: 2 additions & 3 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ use std::path::{Path, PathBuf};
use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
use crate::ecosystem_dispatch::{
crawl_ecosystems, crawl_ecosystems_with_npm, find_all_packages_for_rollback_reusing,
partition_purls,
crawl_ecosystems, crawl_ecosystems_with_npm, find_package_paths_for_path_scope, partition_purls,
};
use crate::json_envelope::{
usage_error, Command as JsonCommand, Envelope, EnvelopeError, PatchAction, PatchEvent,
Expand Down Expand Up @@ -2181,7 +2180,7 @@ async fn run_scan(
unresolved.dedup();
if !unresolved.is_empty() {
let partitioned = partition_purls(&unresolved, args.common.ecosystems.as_deref());
let copies = find_all_packages_for_rollback_reusing(
let copies = find_package_paths_for_path_scope(
&partitioned,
&crawler_options,
true,
Expand Down
133 changes: 128 additions & 5 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ use socket_patch_core::crawlers::{
use socket_patch_core::utils::purl::strip_purl_qualifiers;
use socket_patch_core::utils::purl_key::PurlKey;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use crate::args::GlobalArgs;

use socket_patch_core::crawlers::npm_crawler::{
retain_live_store_copies, with_store_peer_variant_copies,
build_npm_purl, parse_package_name, retain_live_store_copies, with_store_peer_variant_copies,
};
use socket_patch_core::crawlers::walk_pool;
use socket_patch_core::crawlers::CargoCrawler;
Expand Down Expand Up @@ -634,9 +634,7 @@ pub async fn find_all_packages_for_rollback(
}

/// [`find_all_packages_for_rollback`], taking the npm `node_modules` roots
/// from `prior` as [`find_packages_for_rollback_reusing`] does. Used by
/// `scan`'s path scoping, which must see the same copies `rollback`'s path
/// targets do.
/// from `prior` as [`find_packages_for_rollback_reusing`] does.
pub async fn find_all_packages_for_rollback_reusing(
partitioned: &HashMap<Ecosystem, Vec<String>>,
options: &CrawlerOptions,
Expand All @@ -649,6 +647,131 @@ pub async fn find_all_packages_for_rollback_reusing(
dispatch_find(partitioned, options, silent, merge_qualified, npm_roots).await
}

/// The install paths that reach each purl, for `scan`'s and `rollback`'s
/// path targets: [`find_all_packages_for_rollback_reusing`]'s physical
/// copies, plus each npm package link directly in a `node_modules` root
/// (scoped ones one level down) whose target's `package.json` names one
/// of the npm purls asked for.
///
/// The crawler records a plain-name link at its own path
/// (`packages/a/node_modules/ms`), but never an alias link
/// (`packages/a/node_modules/lp -> …/.pnpm/left-pad@1.3.0/node_modules/left-pad`):
/// links are not physical copies, and apply and VEX must see each copy
/// once (#856). Path scoping asks which paths install the package
/// instead, and pnpm's isolated linker or npm's `install-strategy=linked`
/// reach a workspace member's ALIAS dependency only through such a link
/// (#1266). A link is matched by its target's own identity, not against
/// the crawled copies, so a link to a store peer variant the crawl
/// skipped still counts, and the cost is one pass over the root links
/// however many purls are asked for. The result is for path matching
/// only: it can name one physical copy more than once.
pub async fn find_package_paths_for_path_scope(
partitioned: &HashMap<Ecosystem, Vec<String>>,
options: &CrawlerOptions,
silent: bool,
prior: Option<&NpmCrawlSnapshot>,
) -> HashMap<String, Vec<PathBuf>> {
let mut wanted: HashMap<PurlKey, Vec<String>> = HashMap::new();
for purl in partitioned.get(&Ecosystem::Npm).into_iter().flatten() {
wanted
.entry(PurlKey::new(purl))
.or_default()
.push(purl.clone());
}
let npm_roots = match prior.and_then(|p| p.roots_for(options)) {
_ if wanted.is_empty() => None,
Some(roots) => Some(roots.to_vec()),
None => NpmCrawler.get_node_modules_paths(options).await.ok(),
};
let mut found = dispatch_find(
partitioned,
options,
silent,
merge_qualified,
npm_roots.as_deref(),
)
.await;
let Some(roots) = npm_roots else {
return found;
};
// A failed enumeration only loses the extra links; the copies stand.
let links = tokio::task::spawn_blocking(move || npm_links_to(&wanted, &roots))
.await
.unwrap_or_default();
for (purl, link) in links {
let paths = found.entry(purl).or_default();
if !paths.contains(&link) {
paths.push(link);
}
}
found
}

/// Every package link directly in one of `roots` (scoped ones one level
/// down) whose target directory's `package.json` `name@version` is a key
/// of `wanted`, paired with each purl spelling that key stands for.
fn npm_links_to(
wanted: &HashMap<PurlKey, Vec<String>>,
roots: &[PathBuf],
) -> Vec<(String, PathBuf)> {
let entries = |dir: &Path| -> Vec<(String, PathBuf, std::fs::FileType)> {
let Ok(read) = std::fs::read_dir(dir) else {
return Vec::new();
};
read.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let file_type = e.file_type().ok()?;
(!name.starts_with('.')).then(|| (name, e.path(), file_type))
})
.collect()
};
let identity = |link: &Path| -> Option<PurlKey> {
let target = std::fs::canonicalize(link).ok()?;
let text =
socket_patch_core::utils::fs::read_regular_to_string_sync(&target.join("package.json"))
.ok()?;
let pkg: serde_json::Value =
serde_json::from_str(socket_patch_core::formats::text::strip_bom(&text)).ok()?;
let name = pkg.get("name")?.as_str()?;
let version = pkg.get("version")?.as_str()?;
if name.is_empty() || version.is_empty() {
return None;
}
let (namespace, leaf) = parse_package_name(name);
Some(PurlKey::new(&build_npm_purl(
namespace.as_deref(),
&leaf,
version,
)))
};
let mut out = Vec::new();
for root in roots {
let mut links = Vec::new();
for (name, path, file_type) in entries(root) {
if name.starts_with('@') && file_type.is_dir() {
links.extend(
entries(&path)
.into_iter()
.filter(|(_, _, ft)| ft.is_symlink())
.map(|(_, p, _)| p),
);
} else if file_type.is_symlink() {
links.push(path);
}
}
for link in links {
let Some(key) = identity(&link) else {
continue;
};
for purl in wanted.get(&key).into_iter().flatten() {
out.push((purl.clone(), link.clone()));
}
}
}
out
}

/// Qualified-aware PURL resolution for rollback, vendor, repair and
/// narrow-release lookups: remaps qualified PURLs (PyPI `?artifact_id=`,
/// RubyGems `?platform=`, Maven `?classifier=&ext=`) to the base PURL the
Expand Down
Loading
Loading