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

Large diffs are not rendered by default.

115 changes: 85 additions & 30 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,18 @@ impl GlobalArgs {
"is a directory, not a manifest file",
));
}
let root = self.project_root();
// The directory that must exist: the project of a `.socket/`
// manifest (`.socket/` itself is created on demand), else the
// manifest file's own directory.
let in_socket_dir = manifest.parent().and_then(Path::file_name)
== Some(std::ffi::OsStr::new(
socket_patch_core::constants::SOCKET_DIR,
));
let root = if in_socket_dir {
self.project_root()
} else {
self.socket_dir()
};
if !creates_manifest && !root.is_dir() {
return Err(not_dir(
"--manifest-path",
Expand Down Expand Up @@ -491,35 +502,60 @@ impl GlobalArgs {
}
}

/// The project root whose `.socket/` state stores — manifest, vendor
/// ledger — belong together: the RESOLVED manifest's
/// directory, stepping out of a standard `.socket/` layout when the
/// manifest lives in one. For the default `<cwd>/.socket/manifest.json`
/// this is exactly `cwd`; for a `--manifest-path` into another project
/// it is that project's root (its `.socket` parent's parent); for a
/// bare file like `--manifest-path /tmp/x/abs.json` it is the file's
/// own directory. Every command that reads more than one store must
/// derive them from THIS root, so `--manifest-path` can never
/// interleave two projects' state (CLI_CONTRACT.md: both stores always
/// come from the SAME project).
/// The project root whose state stores — manifest, vendor ledger and
/// its `.socket/vendor/` artifacts — belong together. For the default
/// `<cwd>/.socket/manifest.json` this is exactly `cwd`; for a
/// `--manifest-path` into another project's `.socket/` it is that
/// project's root (the `.socket` parent's parent); a manifest file
/// outside any `.socket/` directory (`--manifest-path
/// state/patches.json`, `/etc/socket/manifest.json`) relocates only
/// the manifest — the project stays `cwd`. Every command derives every
/// store from THIS root, so `--manifest-path` can never interleave two
/// projects' state (CLI_CONTRACT.md `--manifest-path` row; #745).
pub(crate) fn project_root(&self) -> PathBuf {
let manifest_path = self.resolved_manifest_path();
match manifest_path.parent() {
Some(dir)
if dir.file_name()
== Some(std::ffi::OsStr::new(
socket_patch_core::constants::SOCKET_DIR,
)) =>
{
dir.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| self.cwd.clone())
}
Some(dir) => dir.to_path_buf(),
None => self.cwd.clone(),
project_root_of(&self.resolved_manifest_path(), &self.cwd)
}

/// Whether the resolved manifest belongs to a project other than
/// `--cwd` ([`Self::project_root`] names a different directory). Only
/// a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` can make
/// this true.
pub(crate) fn manifest_project_is_foreign(&self) -> bool {
let root = self.project_root();
if root == self.cwd {
return false;
}
match (
std::fs::canonicalize(&root),
std::fs::canonicalize(&self.cwd),
) {
(Ok(root), Ok(cwd)) => root != cwd,
_ => true,
}
}

/// `self` with `cwd` moved to [`Self::project_root`] (and the manifest
/// path re-expressed relative to it, so it resolves to the same file):
/// the view the vendored backend runs under, so a revert or repair
/// touches the ledger, `.socket/vendor/` artifacts and lockfile wiring
/// of ONE project — the manifest's (#745). Borrowed unchanged for the
/// default layout.
pub(crate) fn at_project_root(&self) -> std::borrow::Cow<'_, GlobalArgs> {
let root = self.project_root();
if root == self.cwd {
return std::borrow::Cow::Borrowed(self);
}
let manifest = self.resolved_manifest_path();
let mut rooted = self.clone();
rooted.manifest_path = manifest
.strip_prefix(&root)
.unwrap_or(&manifest)
.to_string_lossy()
.into_owned();
rooted.cwd = root;
std::borrow::Cow::Owned(rooted)
}

/// The directory the manifest lives in — where `apply.lock` and `blobs/`
/// sit, and the obsolete `diffs/` and `packages/` the cleanup sweeps
/// remove (`<cwd>/.socket` by default). The one
Expand Down Expand Up @@ -595,6 +631,23 @@ impl GlobalArgs {
}
}

/// [`GlobalArgs::project_root`] for a caller holding a resolved manifest
/// path: the manifest's `.socket` parent's parent in the standard layout,
/// else `cwd` (a manifest file outside any `.socket/` directory relocates
/// only the manifest, not the project).
pub(crate) fn project_root_of(manifest_path: &Path, cwd: &Path) -> PathBuf {
manifest_path
.parent()
.filter(|dir| {
dir.file_name()
== Some(std::ffi::OsStr::new(
socket_patch_core::constants::SOCKET_DIR,
))
})
.and_then(Path::parent)
.map_or_else(|| cwd.to_path_buf(), Path::to_path_buf)
}

/// The `.socket/`-role directory for `manifest_path`: its parent, falling
/// back to `cwd` for a bare relative file name — never `"."`, which is
/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`]
Expand Down Expand Up @@ -1435,18 +1488,20 @@ mod tests {
assert_eq!(args.socket_dir(), other.join(".socket"));
}

/// A bare manifest file outside any `.socket/` layout: the file's own
/// directory plays both roles.
/// A bare manifest file outside any `.socket/` layout relocates only
/// the manifest: its directory holds the lock and artifacts, while the
/// project (vendor ledger, lockfiles) stays `cwd` (#745).
#[test]
fn project_root_of_a_bare_manifest_file_is_its_directory() {
fn project_root_of_a_bare_manifest_file_is_cwd() {
let args = GlobalArgs {
cwd: PathBuf::from("/work/project"),
manifest_path: "custom/mp.json".to_string(),
..GlobalArgs::default()
};
let custom = PathBuf::from("/work/project").join("custom");
assert_eq!(args.project_root(), custom);
assert_eq!(args.project_root(), PathBuf::from("/work/project"));
assert_eq!(args.socket_dir(), custom);
assert!(!args.manifest_project_is_foreign());
}

/// `socket_dir_of` on a raw relative file name falls back to `cwd`,
Expand Down
20 changes: 16 additions & 4 deletions crates/socket-patch-cli/src/commands/agent_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,12 @@ impl DownloadParams {
crate::args::socket_dir_of(&self.manifest_path, &self.cwd)
}

/// The manifest's project, where the vendor ledger lives
/// ([`crate::args::GlobalArgs::project_root`]; #745).
pub(crate) fn project_root(&self) -> PathBuf {
crate::args::project_root_of(&self.manifest_path, &self.cwd)
}

fn crawler_options(&self) -> CrawlerOptions {
CrawlerOptions {
cwd: self.cwd.clone(),
Expand Down Expand Up @@ -1198,7 +1204,7 @@ pub(crate) async fn download_patch_records_reusing(
// flattened into an empty ledger that then reports a Bun lock remedy.
// For the classification below it degrades to empty (no detached entry
// to reuse — the vendor step reports the corruption itself).
let vendor_state = load_state(&params.cwd).await;
let vendor_state = load_state(&params.project_root()).await;
// Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor
// engine, so it must refuse the same projects BEFORE fetching —
// otherwise the view is downloaded for nothing and a package
Expand Down Expand Up @@ -1294,12 +1300,12 @@ pub(crate) async fn download_patch_records_preflighted(
/// function sits on the in-process scan→download→apply chain, whose summed
/// poll frames must fit Windows' 1 MiB main-thread stack in debug builds.
pub(crate) async fn warn_on_vendored_uuid_drift(
cwd: &Path,
project_root: &Path,
quiet: bool,
downloaded_patches: &[serde_json::Value],
warnings: &mut Vec<String>,
) {
let Ok(vendor_state) = load_state(cwd).await else {
let Ok(vendor_state) = load_state(project_root).await else {
return;
};
if vendor_state.entries.is_empty() {
Expand Down Expand Up @@ -1649,7 +1655,13 @@ pub async fn download_and_apply_patches_with(
// later. (`scan` never hits this: it filters vendored purls before
// download.) The nested apply below skips the vendored purl either way.
let mut warnings = batch.warnings;
warn_on_vendored_uuid_drift(&params.cwd, quiet, &batch.patches_json, &mut warnings).await;
warn_on_vendored_uuid_drift(
&params.project_root(),
quiet,
&batch.patches_json,
&mut warnings,
)
.await;

if !quiet {
eprintln!();
Expand Down
20 changes: 18 additions & 2 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
.flatten()
.collect();
let vendored = if crate::commands::project_state_in_scope(&args.common) {
socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await
socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await
} else {
Default::default()
};
Expand Down Expand Up @@ -809,6 +809,22 @@ fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 {

pub async fn run(args: ApplyArgs) -> i32 {
apply_env_toggles(&args.common);
// `--vex` attests the manifest's project but this run patches `--cwd`'s
// installed copies: refuse a manifest in another project before
// anything is read (#745). `--check` never generates a document.
if !args.check {
if let Some(message) =
crate::commands::foreign_manifest_vex_conflict(&args.common, &args.vex, "apply")
{
return crate::json_envelope::usage_error(
Command::Apply,
args.common.json,
args.common.dry_run,
crate::commands::FOREIGN_MANIFEST_PROJECT,
&message,
);
}
}
let manifest_path = args.common.resolved_manifest_path();

// No manifest → nothing to apply: a clean exit-0 no-op (load-bearing
Expand Down Expand Up @@ -1933,7 +1949,7 @@ async fn apply_patches_inner(
// 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
socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await
} else {
Default::default()
};
Expand Down
10 changes: 3 additions & 7 deletions crates/socket-patch-cli/src/commands/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,12 @@ pub(crate) struct ProjectContext<'a> {
}

impl<'a> ProjectContext<'a> {
/// Every command's context: the ledgers always load from the manifest's
/// project, so `--manifest-path` never mixes two projects' state (#745).
pub(crate) fn new(common: &'a GlobalArgs) -> Self {
Self::rooted(common, common.project_root())
}

/// A context whose ledgers load from `root` (commands that read the
/// ledgers of `--cwd` rather than of the manifest's project).
pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self {
Self {
common,
root,
root: common.project_root(),
snapshot: DiskSnapshot::tracked(&common.cwd),
ledgers: OnceCell::new(),
locks: OnceCell::new(),
Expand Down
26 changes: 21 additions & 5 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ async fn filter_to_installed_purls(
let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await;
let mut present: HashSet<PurlKey> = found.keys().map(|k| PurlKey::new(k)).collect();

let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone());
let ctx = super::context::ProjectContext::new(common);
// Manifest membership counts as presence (read-only probe: a corrupt
// manifest degrades to "no extension" here — the download path's
// fail-closed read still guards every write).
Expand Down Expand Up @@ -1056,6 +1056,22 @@ pub async fn run(args: GetArgs) -> i32 {
&conflict,
);
}
// Hosted and vendored mode rewire `--cwd`'s lockfiles and vendor
// ledger: a manifest in another project would split the run (#745).
if let Some(conflict) = (mode != super::scan::ScanMode::Agent)
.then(|| {
super::foreign_manifest_conflict(&args.common, &format!("--mode {}", mode.cli_name()))
})
.flatten()
{
return usage_error(
JsonCommand::Get,
args.common.json,
args.common.dry_run,
super::FOREIGN_MANIFEST_PROJECT,
&conflict,
);
}
if args.save_only && mode != super::scan::ScanMode::Agent {
return usage_error(
JsonCommand::Get,
Expand Down Expand Up @@ -2055,7 +2071,7 @@ async fn save_and_apply_patch(
let mut warnings: Vec<String> = Vec::new();
if changed {
warn_on_vendored_uuid_drift(
&args.common.cwd,
&args.common.project_root(),
quiet,
&[serde_json::json!({
"purl": patch.purl,
Expand Down Expand Up @@ -2320,8 +2336,8 @@ async fn run_get_vendored(
//
// Human: `Error (<code>): <detail>` on stderr — an error, so it is
// exempt from `--silent` like every other `Error (…)` line here.
bun_refusal = bun_vendor_preflight(&args.common.cwd, selected).await;
let ledger = load_state(&args.common.cwd).await;
bun_refusal = bun_vendor_preflight(&args.common.project_root(), selected).await;
let ledger = load_state(&args.common.project_root()).await;
vlt_refusals = vlt_vendor_preflight_selected(
&args.common.cwd,
selected,
Expand Down Expand Up @@ -2380,7 +2396,7 @@ async fn run_get_vendored(
.unwrap_or_default();
let (dl_code, mut result, records) = if prefetched.is_some() {
// The preflight above already read the lock: hand its outcome down.
let vendor_state = load_state(&args.common.cwd).await;
let vendor_state = load_state(&args.common.project_root()).await;
Box::pin(download_patch_records_preflighted(
selected,
&params,
Expand Down
3 changes: 3 additions & 0 deletions crates/socket-patch-cli/src/commands/hosted_unwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H
use socket_patch_core::patch::redirect::upstream::{
restore_upstream, PinStatus, RestoreOptions,
};
// The pins are the manifest's project's (#745): restore them there.
let rooted = common.at_project_root();
let common: &GlobalArgs = &rooted;

let mut out = HostedLegOutcome::default();
if pins.is_empty() {
Expand Down
Loading
Loading