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
10 changes: 6 additions & 4 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1714,7 +1714,14 @@ async fn apply_patches_inner(
// by ledger key, resolved base purl, or qualifier-stripped key so
// release-variant manifest keys (pypi `?artifact_id=`…) hit too;
// unreadable state degrades to "nothing vendored" (fail-open).
let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await;
// The ledger owns the PROJECT's copies only: a global apply restores
// and patches the global copy even when the cwd project vendors the
// same purl (see `project_state_in_scope`).
let vendored_purls = if crate::commands::project_state_in_scope(&args.common) {
socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await
} else {
Default::default()
};
let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p);
let (mut results, mut matched_manifest_purls, vendored_bases) =
synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls);
Expand Down
6 changes: 6 additions & 0 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2527,6 +2527,12 @@ pub async fn run(args: GetArgs) -> i32 {
} else {
super::scan::ScanMode::Hosted
});
// Global installs have no project lockfile: an explicit hosted or
// vendored mode would rewire the cwd project, not the global copy.
if let Some(conflict) = super::global_mode_conflict(&args.common, mode) {
report_error(args.common.json, conflict);
return 2;
}
if args.save_only && mode != super::scan::ScanMode::Agent {
report_error(
args.common.json,
Expand Down
38 changes: 38 additions & 0 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,44 @@ pub(crate) const HOSTED_MODE_LABEL: &str = "hosted";
/// `record`, so the ledger is the only place those records live.
pub(crate) const VENDORED_MODE_LABEL: &str = "vendored";

/// Whether the run's target includes the `--cwd` project's own
/// lockfile-backed state: its hosted pins and its vendor ledger. Global
/// scope (`--global` / `--global-prefix`) targets globally installed
/// packages, which have no project lockfile (CLI_CONTRACT.md, Mode
/// resolution). The project a global run happens to start in is not its
/// target, so that project's hosted and vendored state is never rewired,
/// unwound, or consulted for ownership of a global copy.
pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool {
!common.is_global()
}

/// The usage error for a mode that rewires the project (`hosted`,
/// `vendored`) under global scope, or `None` when `mode` is allowed.
/// Shared by `scan` and `get` so both refuse the same combinations with
/// the same wording.
pub(crate) fn global_mode_conflict(
common: &crate::args::GlobalArgs,
mode: scan::ScanMode,
) -> Option<String> {
if project_state_in_scope(common) {
return None;
}
let why = match mode {
scan::ScanMode::Agent => return None,
scan::ScanMode::Hosted => "redirect",
scan::ScanMode::Vendored => "wire vendored artifacts into",
};
Some(format!(
"{} cannot be used with --mode {}: global installs have no project lockfile to {why}",
if common.global {
"--global"
} else {
"--global-prefix"
},
mode.cli_name(),
))
}

/// Lockfile discovery of `root` (core `vex::discover`): the hosted and
/// vendored patch references its lockfiles and configs wire, with hosted
/// references counted on Socket's public patch server plus the operator's
Expand Down
25 changes: 20 additions & 5 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,24 @@ pub async fn run(args: RemoveArgs) -> i32 {
// must not see `.socket/` created and pruned again). The vendor ledger
// is loaded under the lock below; the hosted pins come from read-only
// lockfile discovery (the restore re-reads every file under the lock).
//
// Under global scope the project's hosted pins and vendor ledger are
// not this run's to unwind (see `project_state_in_scope`): a global
// remove restores the global copies and drops their manifest records
// only.
let project_state = crate::commands::project_state_in_scope(&args.common);
let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err();
let hosted_inventory = crate::commands::hosted_inventory(&args.common, cwd).await;
let hosted_inventory = if project_state {
crate::commands::hosted_inventory(&args.common, cwd).await
} else {
Default::default()
};
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
if manifest_missing {
let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))
.await
.is_ok();
let vendor_ledger_exists = project_state
&& tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))
.await
.is_ok();
if !vendor_ledger_exists && hosted_pins.is_empty() {
// Contested hosted wiring is still hosted state: name it
// instead of reporting a bare project.
Expand Down Expand Up @@ -440,7 +451,11 @@ pub async fn run(args: RemoveArgs) -> i32 {
// the vendored leg. An unreadable ledger degrades to "nothing vendored"
// for the rollback and fails closed at the vendored leg — exactly where
// the run is about to mutate vendored state.
let vendor_state_result = load_state(cwd).await;
let vendor_state_result = if project_state {
load_state(cwd).await
} else {
Ok(VendorState::default())
};

if matching.is_empty() {
// Ledger-only entries (vendored mode keeps no manifest record) —
Expand Down
57 changes: 48 additions & 9 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,10 @@ pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option
let path = common
.cwd
.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL);
if common.dry_run || tokio::fs::symlink_metadata(&path).await.is_err() {
if common.dry_run
|| !crate::commands::project_state_in_scope(common)
|| tokio::fs::symlink_metadata(&path).await.is_err()
{
return None;
}
let remaining = crate::commands::discover_wiring(common, &common.cwd).await;
Expand Down Expand Up @@ -1111,13 +1114,23 @@ pub async fn run(args: RollbackArgs) -> i32 {
// themselves are LOADED UNDER the apply lock below: this run persists
// mutated clones of the ledgers, so a pre-lock snapshot could clobber
// a concurrent run's writes with stale state.
//
// Under global scope the project's hosted pins and vendor ledger are
// not this run's to unwind (see `project_state_in_scope`): a global
// rollback restores the global copies and their manifest records only.
let project_state = crate::commands::project_state_in_scope(&args.common);
let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err();
let vendor_ledger_exists = tokio::fs::metadata(cwd.join(".socket/vendor/state.json"))
.await
.is_ok();
let vendor_ledger_exists = project_state
&& tokio::fs::metadata(cwd.join(".socket/vendor/state.json"))
.await
.is_ok();
// The hosted pins the lockfiles wire (read-only discovery; the restore
// re-reads every file under the lock before it writes).
let hosted_inventory = crate::commands::hosted_inventory(&args.common, &cwd).await;
let hosted_inventory = if project_state {
crate::commands::hosted_inventory(&args.common, &cwd).await
} else {
Default::default()
};
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();

if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() {
Expand All @@ -1131,7 +1144,7 @@ pub async fn run(args: RollbackArgs) -> i32 {
// so there is nothing to restore — retire the stale file (a wet run
// only) instead of failing on the missing manifest.
let legacy = cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL);
if tokio::fs::symlink_metadata(&legacy).await.is_ok() {
if project_state && tokio::fs::symlink_metadata(&legacy).await.is_ok() {
let warning = retire_legacy_redirect_ledger(&args.common).await;
if args.common.json {
println!(
Expand Down Expand Up @@ -1167,7 +1180,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
// with lockfiles still consuming `.socket/vendor/` artifacts: the
// ledger holds the pre-vendor originals, so it must come back from
// version control first.)
let wired = crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await;
let wired = if project_state {
crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await
} else {
Default::default()
};
if !wired.is_empty() {
emit_rollback_error(
args.common.json,
Expand Down Expand Up @@ -1213,7 +1230,22 @@ pub async fn run(args: RollbackArgs) -> i32 {
// Load the state stores UNDER the lock (see the discovery note above),
// each exactly once: the agent leg below receives the manifest and the
// vendor-ownership key set instead of re-reading them.
let vendor_state_result = socket_patch_core::vendor::load_state(&cwd).await;
//
// Under global scope the ledger is read only to keep the project's
// vendored manifest records (see the cleanup below): no vendored leg
// runs, and the ledger does not own the global copies, so the in-place
// leg restores them.
let loaded_vendor_state = socket_patch_core::vendor::load_state(&cwd).await;
let project_vendored_keys: HashSet<String> = loaded_vendor_state
.as_ref()
.map(VendorState::purl_keys)
.unwrap_or_default();
let ledger_unreadable = loaded_vendor_state.is_err();
let vendor_state_result = if project_state {
loaded_vendor_state
} else {
Ok(VendorState::default())
};
let vendor_corrupt = vendor_state_result.is_err();
// An unreadable ledger degrades to "nothing vendored" for the in-place
// leg (its own containment is the `vendor_state_unreadable` exit below).
Expand Down Expand Up @@ -1558,7 +1590,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
.filter(|r| !r.success)
.map(|r| r.package_key.clone())
.collect();
let cleanup_allowed = !args.preserve_state && !aborted && !vendor_corrupt;
// A global run keeps the project's vendored records too: their
// vendored state is not unwound, so dropping them would hand a
// later `vendor` reconcile a revert with no backing record. An
// unreadable ledger leaves that ownership unknowable either way.
let cleanup_allowed = !args.preserve_state && !aborted && !ledger_unreadable;
// A vendor-owned manifest purl is removable only when its
// ledger entry was cleanly reverted this run (drift-keeps and
// failures keep the record; the matching mirrors the
Expand Down Expand Up @@ -1588,6 +1624,9 @@ pub async fn run(args: RollbackArgs) -> i32 {
if failed_purls.contains(*purl) {
return false;
}
if !project_state && purl_keys_cover(&project_vendored_keys, purl) {
return false;
}
if vendored_excluded.contains(purl) {
return vendored_reverted_ok(purl);
}
Expand Down
34 changes: 18 additions & 16 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,20 +234,13 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> {
// stays report-only (neither has a project lockfile to rewire).
args.mode = Some(ScanMode::Hosted);
}
if args.mode == Some(ScanMode::Hosted)
&& args.common.is_global()
// Global installs have no project lockfile: hosted and vendored mode
// would rewire the cwd project instead of the global copy.
if let Some(conflict) = args
.mode
.and_then(|mode| crate::commands::global_mode_conflict(&args.common, mode))
{
// Global installs have no project lockfile to repoint: the hosted
// flow would "redirect 0 packages" and exit 0, a silent no-op.
return Err(format!(
"{} cannot be used with --mode hosted: global installs have no project \
lockfile to redirect",
if args.common.global {
"--global"
} else {
"--global-prefix"
},
));
return Err(conflict);
}
Ok(())
}
Expand Down Expand Up @@ -1653,6 +1646,15 @@ async fn run_scan(
.as_ref()
.map(VendorState::purl_keys)
.unwrap_or_default();
// The ledger owns and records the PROJECT's copies only: a global
// scan's agent leg patches the global copy even when the cwd project
// vendors the same purl (see `project_state_in_scope`).
let project_state = crate::commands::project_state_in_scope(&args.common);
let vendor_owned_purls: HashSet<String> = if project_state {
vendored_purls.clone()
} else {
HashSet::new()
};

// Read existing manifest once for update detection.
let existing_manifest = ctx.ledgers().await.manifest;
Expand All @@ -1676,7 +1678,7 @@ async fn run_scan(
.collect();
let update_manifest = merge_ledger_records_for_updates(
existing_manifest,
vendor_state.as_ref().ok(),
vendor_state.as_ref().ok().filter(|_| project_state),
&hosted_pins,
);
policy.set_recorded(update_manifest.as_deref());
Expand Down Expand Up @@ -2245,7 +2247,7 @@ async fn run_scan(
skip_records: vendored_records,
vendored_purls: vendored_skip_purls,
..
} = partition_agent_selection(writers_of(&rows), &vendored_purls, &lockfile_only);
} = partition_agent_selection(writers_of(&rows), &vendor_owned_purls, &lockfile_only);
let selected = plan_kept_rows(&mut stage, rows, kept);

if dry {
Expand Down Expand Up @@ -2665,7 +2667,7 @@ async fn run_scan(
let selected = if vendor {
selected
} else {
let split = partition_agent_selection(selected, &vendored_purls, &lockfile_only);
let split = partition_agent_selection(selected, &vendor_owned_purls, &lockfile_only);
if !silent {
for purl in &split.vendored_purls {
open_paragraph(&mut skip_paragraph);
Expand Down
Loading
Loading