Skip to content

Commit ba9ada4

Browse files
Fix vendored-ledger root under --manifest-path (#745) (#1345)
* Root vendored-ledger access at the manifest project WIP for #745. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Root vendored-ledger access at the manifest project With --manifest-path into another project, rollback, remove, repair, apply --check, vex and agent-mode scan/get read (and rollback/remove saved) the vendor ledger of --cwd while list, apply and vendor --check read the manifest project's. One run could revert one project's agent patches and another project's vendored artifacts, and rollback wrote a ledger its apply lock did not cover. Every ledger read, revert and save now uses GlobalArgs::project_root; the vendored backend and the scan --prune vendored GC run re-rooted there. Hosted/vendored scan and get, and vendor other than --check, rewire --cwd's lockfiles, so they now refuse a manifest in another project (exit 2, manifest_path_foreign_project). An architecture test pins that no ledger access outside the vendoring engine takes a cwd. Fixes #745 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep a bare --manifest-path file in the cwd project A manifest file outside any .socket/ directory (--manifest-path state/patches.json, /etc/socket/manifest.json) relocates only the manifest; the project whose vendor ledger and lockfiles a run uses stays --cwd. Previously project_root() named the file's own directory, so list and apply looked for the ledger next to the relocated manifest, and the new foreign-manifest guard would have refused hosted scan for such a file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Unwind hosted pins in the manifest project too Bugbot on #1345: rollback, remove and repair read the vendor ledger from the manifest's project but still discovered and restored hosted pins (and the pre-v5 redirect ledger) in --cwd, so a foreign-manifest rollback could unwind two projects at once, and missed the pins list and vex report. The hosted inventory, the legacy-ledger probes and retirement, and the hosted restore leg now run at project_root too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Run standalone vex at the manifest's project vex loaded the ledgers and wiring discovery from the manifest's project but detected the product, crawled installed copies and ran the pnpm/PnP guards in --cwd. With --manifest-path into another project, that project's hosted pins could vouch for --cwd's lockfile-only packages, and the product came from the wrong package.json. vex now runs under GlobalArgs::at_project_root(), so the whole document describes one project (a no-op in the default layout). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Refuse embedded --vex with a manifest in another project `apply --vex` and agent-mode / report-only `scan --vex` patch --cwd's installed copies, but the VEX document's product, ledgers and lockfile wiring come from the manifest's project (#745), so one document would attest one project from another's state. Re-rooting would instead verify copies the run never patched. Refuse the combination up front with the existing usage-error code `manifest_path_foreign_project` (exit 2), using the same project-root comparison (`manifest_project_is_foreign`) as the hosted/vendored refusals. A manifest resolving into --cwd's own project is never refused; `apply --check` (no document) and standalone `vex` (runs wholly in the manifest's project) stay allowed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 03d9237 commit ba9ada4

20 files changed

Lines changed: 876 additions & 95 deletions

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

Lines changed: 4 additions & 3 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/args.rs‎

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,18 @@ impl GlobalArgs {
431431
"is a directory, not a manifest file",
432432
));
433433
}
434-
let root = self.project_root();
434+
// The directory that must exist: the project of a `.socket/`
435+
// manifest (`.socket/` itself is created on demand), else the
436+
// manifest file's own directory.
437+
let in_socket_dir = manifest.parent().and_then(Path::file_name)
438+
== Some(std::ffi::OsStr::new(
439+
socket_patch_core::constants::SOCKET_DIR,
440+
));
441+
let root = if in_socket_dir {
442+
self.project_root()
443+
} else {
444+
self.socket_dir()
445+
};
435446
if !creates_manifest && !root.is_dir() {
436447
return Err(not_dir(
437448
"--manifest-path",
@@ -491,35 +502,60 @@ impl GlobalArgs {
491502
}
492503
}
493504

494-
/// The project root whose `.socket/` state stores — manifest, vendor
495-
/// ledger — belong together: the RESOLVED manifest's
496-
/// directory, stepping out of a standard `.socket/` layout when the
497-
/// manifest lives in one. For the default `<cwd>/.socket/manifest.json`
498-
/// this is exactly `cwd`; for a `--manifest-path` into another project
499-
/// it is that project's root (its `.socket` parent's parent); for a
500-
/// bare file like `--manifest-path /tmp/x/abs.json` it is the file's
501-
/// own directory. Every command that reads more than one store must
502-
/// derive them from THIS root, so `--manifest-path` can never
503-
/// interleave two projects' state (CLI_CONTRACT.md: both stores always
504-
/// come from the SAME project).
505+
/// The project root whose state stores — manifest, vendor ledger and
506+
/// its `.socket/vendor/` artifacts — belong together. For the default
507+
/// `<cwd>/.socket/manifest.json` this is exactly `cwd`; for a
508+
/// `--manifest-path` into another project's `.socket/` it is that
509+
/// project's root (the `.socket` parent's parent); a manifest file
510+
/// outside any `.socket/` directory (`--manifest-path
511+
/// state/patches.json`, `/etc/socket/manifest.json`) relocates only
512+
/// the manifest — the project stays `cwd`. Every command derives every
513+
/// store from THIS root, so `--manifest-path` can never interleave two
514+
/// projects' state (CLI_CONTRACT.md `--manifest-path` row; #745).
505515
pub(crate) fn project_root(&self) -> PathBuf {
506-
let manifest_path = self.resolved_manifest_path();
507-
match manifest_path.parent() {
508-
Some(dir)
509-
if dir.file_name()
510-
== Some(std::ffi::OsStr::new(
511-
socket_patch_core::constants::SOCKET_DIR,
512-
)) =>
513-
{
514-
dir.parent()
515-
.map(Path::to_path_buf)
516-
.unwrap_or_else(|| self.cwd.clone())
517-
}
518-
Some(dir) => dir.to_path_buf(),
519-
None => self.cwd.clone(),
516+
project_root_of(&self.resolved_manifest_path(), &self.cwd)
517+
}
518+
519+
/// Whether the resolved manifest belongs to a project other than
520+
/// `--cwd` ([`Self::project_root`] names a different directory). Only
521+
/// a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` can make
522+
/// this true.
523+
pub(crate) fn manifest_project_is_foreign(&self) -> bool {
524+
let root = self.project_root();
525+
if root == self.cwd {
526+
return false;
527+
}
528+
match (
529+
std::fs::canonicalize(&root),
530+
std::fs::canonicalize(&self.cwd),
531+
) {
532+
(Ok(root), Ok(cwd)) => root != cwd,
533+
_ => true,
520534
}
521535
}
522536

537+
/// `self` with `cwd` moved to [`Self::project_root`] (and the manifest
538+
/// path re-expressed relative to it, so it resolves to the same file):
539+
/// the view the vendored backend runs under, so a revert or repair
540+
/// touches the ledger, `.socket/vendor/` artifacts and lockfile wiring
541+
/// of ONE project — the manifest's (#745). Borrowed unchanged for the
542+
/// default layout.
543+
pub(crate) fn at_project_root(&self) -> std::borrow::Cow<'_, GlobalArgs> {
544+
let root = self.project_root();
545+
if root == self.cwd {
546+
return std::borrow::Cow::Borrowed(self);
547+
}
548+
let manifest = self.resolved_manifest_path();
549+
let mut rooted = self.clone();
550+
rooted.manifest_path = manifest
551+
.strip_prefix(&root)
552+
.unwrap_or(&manifest)
553+
.to_string_lossy()
554+
.into_owned();
555+
rooted.cwd = root;
556+
std::borrow::Cow::Owned(rooted)
557+
}
558+
523559
/// The directory the manifest lives in — where `apply.lock` and `blobs/`
524560
/// sit, and the obsolete `diffs/` and `packages/` the cleanup sweeps
525561
/// remove (`<cwd>/.socket` by default). The one
@@ -595,6 +631,23 @@ impl GlobalArgs {
595631
}
596632
}
597633

634+
/// [`GlobalArgs::project_root`] for a caller holding a resolved manifest
635+
/// path: the manifest's `.socket` parent's parent in the standard layout,
636+
/// else `cwd` (a manifest file outside any `.socket/` directory relocates
637+
/// only the manifest, not the project).
638+
pub(crate) fn project_root_of(manifest_path: &Path, cwd: &Path) -> PathBuf {
639+
manifest_path
640+
.parent()
641+
.filter(|dir| {
642+
dir.file_name()
643+
== Some(std::ffi::OsStr::new(
644+
socket_patch_core::constants::SOCKET_DIR,
645+
))
646+
})
647+
.and_then(Path::parent)
648+
.map_or_else(|| cwd.to_path_buf(), Path::to_path_buf)
649+
}
650+
598651
/// The `.socket/`-role directory for `manifest_path`: its parent, falling
599652
/// back to `cwd` for a bare relative file name — never `"."`, which is
600653
/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`]
@@ -1435,18 +1488,20 @@ mod tests {
14351488
assert_eq!(args.socket_dir(), other.join(".socket"));
14361489
}
14371490

1438-
/// A bare manifest file outside any `.socket/` layout: the file's own
1439-
/// directory plays both roles.
1491+
/// A bare manifest file outside any `.socket/` layout relocates only
1492+
/// the manifest: its directory holds the lock and artifacts, while the
1493+
/// project (vendor ledger, lockfiles) stays `cwd` (#745).
14401494
#[test]
1441-
fn project_root_of_a_bare_manifest_file_is_its_directory() {
1495+
fn project_root_of_a_bare_manifest_file_is_cwd() {
14421496
let args = GlobalArgs {
14431497
cwd: PathBuf::from("/work/project"),
14441498
manifest_path: "custom/mp.json".to_string(),
14451499
..GlobalArgs::default()
14461500
};
14471501
let custom = PathBuf::from("/work/project").join("custom");
1448-
assert_eq!(args.project_root(), custom);
1502+
assert_eq!(args.project_root(), PathBuf::from("/work/project"));
14491503
assert_eq!(args.socket_dir(), custom);
1504+
assert!(!args.manifest_project_is_foreign());
14501505
}
14511506

14521507
/// `socket_dir_of` on a raw relative file name falls back to `cwd`,

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,12 @@ impl DownloadParams {
410410
crate::args::socket_dir_of(&self.manifest_path, &self.cwd)
411411
}
412412

413+
/// The manifest's project, where the vendor ledger lives
414+
/// ([`crate::args::GlobalArgs::project_root`]; #745).
415+
pub(crate) fn project_root(&self) -> PathBuf {
416+
crate::args::project_root_of(&self.manifest_path, &self.cwd)
417+
}
418+
413419
fn crawler_options(&self) -> CrawlerOptions {
414420
CrawlerOptions {
415421
cwd: self.cwd.clone(),
@@ -1198,7 +1204,7 @@ pub(crate) async fn download_patch_records_reusing(
11981204
// flattened into an empty ledger that then reports a Bun lock remedy.
11991205
// For the classification below it degrades to empty (no detached entry
12001206
// to reuse — the vendor step reports the corruption itself).
1201-
let vendor_state = load_state(&params.cwd).await;
1207+
let vendor_state = load_state(&params.project_root()).await;
12021208
// Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor
12031209
// engine, so it must refuse the same projects BEFORE fetching —
12041210
// otherwise the view is downloaded for nothing and a package
@@ -1294,12 +1300,12 @@ pub(crate) async fn download_patch_records_preflighted(
12941300
/// function sits on the in-process scan→download→apply chain, whose summed
12951301
/// poll frames must fit Windows' 1 MiB main-thread stack in debug builds.
12961302
pub(crate) async fn warn_on_vendored_uuid_drift(
1297-
cwd: &Path,
1303+
project_root: &Path,
12981304
quiet: bool,
12991305
downloaded_patches: &[serde_json::Value],
13001306
warnings: &mut Vec<String>,
13011307
) {
1302-
let Ok(vendor_state) = load_state(cwd).await else {
1308+
let Ok(vendor_state) = load_state(project_root).await else {
13031309
return;
13041310
};
13051311
if vendor_state.entries.is_empty() {
@@ -1649,7 +1655,13 @@ pub async fn download_and_apply_patches_with(
16491655
// later. (`scan` never hits this: it filters vendored purls before
16501656
// download.) The nested apply below skips the vendored purl either way.
16511657
let mut warnings = batch.warnings;
1652-
warn_on_vendored_uuid_drift(&params.cwd, quiet, &batch.patches_json, &mut warnings).await;
1658+
warn_on_vendored_uuid_drift(
1659+
&params.project_root(),
1660+
quiet,
1661+
&batch.patches_json,
1662+
&mut warnings,
1663+
)
1664+
.await;
16531665

16541666
if !quiet {
16551667
eprintln!();

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
324324
.flatten()
325325
.collect();
326326
let vendored = if crate::commands::project_state_in_scope(&args.common) {
327-
socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await
327+
socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await
328328
} else {
329329
Default::default()
330330
};
@@ -831,6 +831,22 @@ fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 {
831831

832832
pub async fn run(args: ApplyArgs) -> i32 {
833833
apply_env_toggles(&args.common);
834+
// `--vex` attests the manifest's project but this run patches `--cwd`'s
835+
// installed copies: refuse a manifest in another project before
836+
// anything is read (#745). `--check` never generates a document.
837+
if !args.check {
838+
if let Some(message) =
839+
crate::commands::foreign_manifest_vex_conflict(&args.common, &args.vex, "apply")
840+
{
841+
return crate::json_envelope::usage_error(
842+
Command::Apply,
843+
args.common.json,
844+
args.common.dry_run,
845+
crate::commands::FOREIGN_MANIFEST_PROJECT,
846+
&message,
847+
);
848+
}
849+
}
834850
let manifest_path = args.common.resolved_manifest_path();
835851

836852
// No manifest → nothing to apply: a clean exit-0 no-op (load-bearing
@@ -1981,7 +1997,7 @@ async fn apply_patches_inner(
19811997
// and patches the global copy even when the cwd project vendors the
19821998
// same purl (see `project_state_in_scope`).
19831999
let vendored_purls = if crate::commands::project_state_in_scope(&args.common) {
1984-
socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await
2000+
socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await
19852001
} else {
19862002
Default::default()
19872003
};

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,12 @@ pub(crate) struct ProjectContext<'a> {
4646
}
4747

4848
impl<'a> ProjectContext<'a> {
49+
/// Every command's context: the ledgers always load from the manifest's
50+
/// project, so `--manifest-path` never mixes two projects' state (#745).
4951
pub(crate) fn new(common: &'a GlobalArgs) -> Self {
50-
Self::rooted(common, common.project_root())
51-
}
52-
53-
/// A context whose ledgers load from `root` (commands that read the
54-
/// ledgers of `--cwd` rather than of the manifest's project).
55-
pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self {
5652
Self {
5753
common,
58-
root,
54+
root: common.project_root(),
5955
snapshot: DiskSnapshot::tracked(&common.cwd),
6056
ledgers: OnceCell::new(),
6157
locks: OnceCell::new(),

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ async fn filter_to_installed_purls(
821821
let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await;
822822
let mut present: HashSet<PurlKey> = found.keys().map(|k| PurlKey::new(k)).collect();
823823

824-
let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone());
824+
let ctx = super::context::ProjectContext::new(common);
825825
// Manifest membership counts as presence (read-only probe: a corrupt
826826
// manifest degrades to "no extension" here — the download path's
827827
// fail-closed read still guards every write).
@@ -1056,6 +1056,22 @@ pub async fn run(args: GetArgs) -> i32 {
10561056
&conflict,
10571057
);
10581058
}
1059+
// Hosted and vendored mode rewire `--cwd`'s lockfiles and vendor
1060+
// ledger: a manifest in another project would split the run (#745).
1061+
if let Some(conflict) = (mode != super::scan::ScanMode::Agent)
1062+
.then(|| {
1063+
super::foreign_manifest_conflict(&args.common, &format!("--mode {}", mode.cli_name()))
1064+
})
1065+
.flatten()
1066+
{
1067+
return usage_error(
1068+
JsonCommand::Get,
1069+
args.common.json,
1070+
args.common.dry_run,
1071+
super::FOREIGN_MANIFEST_PROJECT,
1072+
&conflict,
1073+
);
1074+
}
10591075
if args.save_only && mode != super::scan::ScanMode::Agent {
10601076
return usage_error(
10611077
JsonCommand::Get,
@@ -2055,7 +2071,7 @@ async fn save_and_apply_patch(
20552071
let mut warnings: Vec<String> = Vec::new();
20562072
if changed {
20572073
warn_on_vendored_uuid_drift(
2058-
&args.common.cwd,
2074+
&args.common.project_root(),
20592075
quiet,
20602076
&[serde_json::json!({
20612077
"purl": patch.purl,
@@ -2320,8 +2336,8 @@ async fn run_get_vendored(
23202336
//
23212337
// Human: `Error (<code>): <detail>` on stderr — an error, so it is
23222338
// exempt from `--silent` like every other `Error (…)` line here.
2323-
bun_refusal = bun_vendor_preflight(&args.common.cwd, selected).await;
2324-
let ledger = load_state(&args.common.cwd).await;
2339+
bun_refusal = bun_vendor_preflight(&args.common.project_root(), selected).await;
2340+
let ledger = load_state(&args.common.project_root()).await;
23252341
vlt_refusals = vlt_vendor_preflight_selected(
23262342
&args.common.cwd,
23272343
selected,
@@ -2380,7 +2396,7 @@ async fn run_get_vendored(
23802396
.unwrap_or_default();
23812397
let (dl_code, mut result, records) = if prefetched.is_some() {
23822398
// The preflight above already read the lock: hand its outcome down.
2383-
let vendor_state = load_state(&args.common.cwd).await;
2399+
let vendor_state = load_state(&args.common.project_root()).await;
23842400
Box::pin(download_patch_records_preflighted(
23852401
selected,
23862402
&params,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H
4242
use socket_patch_core::patch::redirect::upstream::{
4343
restore_upstream, PinStatus, RestoreOptions,
4444
};
45+
// The pins are the manifest's project's (#745): restore them there.
46+
let rooted = common.at_project_root();
47+
let common: &GlobalArgs = &rooted;
4548

4649
let mut out = HostedLegOutcome::default();
4750
if pins.is_empty() {

0 commit comments

Comments
 (0)