Skip to content

Commit 35f7b32

Browse files
committed
Find Hatch's out-of-tree project environments
Hatch installs a project into envs under its data directory, never ./.venv, so stale-install checks, VEX and agent mode never looked at the environment hatch run actually uses. Model Hatch's placement rules (data dir, dirs.env.virtual, flat layouts, explicit env paths, the project id hash) and add those envs to local venv discovery. Hosted scans now warn about a stale Hatch env with the remedy that works (hatch env remove / prune), and vendored Hatch gets the same check as pypi_hatch_stale_install. Refs #335 Assisted-by: Claude Code:claude-opus-5-5
1 parent 3e50795 commit 35f7b32

134 files changed

Lines changed: 3200 additions & 872 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract**
189189

190190
`repair` keeps its `gc` visible alias.
191191

192-
**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`.
192+
**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs, the project's Hatch environments (Hatch's data dir / `HATCH_DATA_DIR`, `[dirs.env] virtual`, explicit env `path`s) and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. A stale Hatch environment instead names `hatch env remove <env>` / `hatch env prune`: Hatch's pip installer (and uv before Hatch 1.16) keeps a same-version release, so only a recreated env picks up the patch. The same Hatch envs are what agent mode patches and `vex` judges for a Hatch project.
193193

194194
### socket.yml patch policy (v5.0)
195195

@@ -1234,6 +1234,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
12341234
| `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. |
12351235
| `pypi_poetry_changed` / `pypi_pdm_changed` / `pypi_pipenv_changed` / `pypi_uv_changed` | `failed` | vendor (pypi, v5.0): the lock / project file changed between the read that planned the edit and the first write — refused before any write (worded like `pypi_lock_changed`: "<file> changed during vendoring; re-run"). |
12361236
| `pypi_pipenv_stale_install` | `skipped` (warning) | vendor (pipenv): the vendored twin of `redirect_pypi_stale_install` — the project's venv still holds the upstream release Pipenv will not reinstall over; the detail names the `pipenv run pip uninstall -y <pkg> && pipenv sync` remedy. |
1237+
| `pypi_hatch_stale_install` | `skipped` (warning) | vendor (hatch): an existing Hatch environment of the project (found under Hatch's data dir, `dirs.env.virtual` or an explicit env `path`) still holds the upstream release; Hatch keeps it on the next `hatch run`, so the detail names the env and the `hatch env remove <env>` / `hatch env prune` remedy. |
12371238
| `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. |
12381239
| `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. |
12391240
| `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run `<tool> lock`. |

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ use clap::Args;
22
use socket_patch_core::api::blob_fetcher::get_missing_blobs;
33
use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
44
use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
5-
use socket_patch_core::crawlers::{
6-
detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler,
7-
};
5+
use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler};
86
use socket_patch_core::manifest::operations::read_manifest;
97
use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
108
use socket_patch_core::patch::apply::{

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,10 @@ pub async fn run(args: ListArgs) -> i32 {
431431
detail: detail.clone(),
432432
});
433433
} else if !args.common.silent {
434-
eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
434+
eprintln!(
435+
"Warning: {}",
436+
crate::commands::rollback::capitalize_first(detail)
437+
);
435438
}
436439
}
437440
let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@ mod tests {
773776
let listings = HostedListing::from_pins(
774777
&[
775778
pin("pkg:npm/minimist@1.2.2", &record.uuid),
776-
pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"),
779+
pin(
780+
"pkg:npm/other@1.0.0",
781+
"33333333-3333-4333-8333-333333333333",
782+
),
777783
],
778784
Some(&legacy),
779785
);
780786
assert_eq!(listings[0].record, record);
781-
assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
787+
assert_eq!(
788+
listings[1].record.uuid,
789+
"33333333-3333-4333-8333-333333333333"
790+
);
782791
assert!(listings[1].record.vulnerabilities.is_empty());
783792
assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
784793
}

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
pub mod apply;
22
pub(crate) mod bun_preflight;
3-
pub(crate) mod context;
43
pub(crate) mod composer_hints;
4+
pub(crate) mod context;
55
pub(crate) mod fetch_stage;
66
pub mod get;
77
pub mod hosted_bundle;
88
pub mod list;
99
pub(crate) mod lock_cli;
1010
pub mod remove;
1111
pub mod repair;
12-
pub(crate) mod vendored_backend;
1312
pub mod rollback;
1413
pub mod scan;
1514
pub mod update;
1615
pub mod vendor;
16+
pub(crate) mod vendored_backend;
1717
pub mod vex;
1818
pub(crate) mod vex_consumed;
1919
pub(crate) mod vex_sources;
@@ -141,9 +141,11 @@ pub(crate) async fn hosted_state_from_lockfiles(
141141
common: &crate::args::GlobalArgs,
142142
root: &Path,
143143
) -> socket_patch_core::patch::redirect::RedirectState {
144-
hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
145-
&discover_wiring(common, root).await,
146-
))
144+
hosted_state_from_pins(
145+
&socket_patch_core::patch::redirect::upstream::HostedPin::all(
146+
&discover_wiring(common, root).await,
147+
),
148+
)
147149
}
148150

149151
/// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -153,18 +155,17 @@ pub(crate) fn hosted_state_from_pins(
153155
) -> socket_patch_core::patch::redirect::RedirectState {
154156
let mut state = socket_patch_core::patch::redirect::RedirectState::new();
155157
for pin in pins {
156-
state
157-
.records
158-
.entry(pin.purl.clone())
159-
.or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
158+
state.records.entry(pin.purl.clone()).or_insert_with(|| {
159+
socket_patch_core::manifest::schema::PatchRecord {
160160
uuid: pin.uuid.clone(),
161161
exported_at: String::new(),
162162
files: Default::default(),
163163
vulnerabilities: Default::default(),
164164
description: String::new(),
165165
license: String::new(),
166166
tier: String::new(),
167-
});
167+
}
168+
});
168169
}
169170
state
170171
}
@@ -191,4 +192,3 @@ pub(crate) fn vendor_state_lenient(
191192
}
192193
}
193194
}
194-

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ use super::rollback::{
1717
pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure,
1818
sweep_unused_artifacts, HostedLegOutcome, InnerSelection,
1919
};
20-
use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
2120
use crate::args::{apply_env_toggles, GlobalArgs};
2221
use crate::commands::lock_cli::acquire_or_emit;
22+
use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
2323
use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status};
2424
use crate::ui::plural;
2525

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ use socket_patch_core::manifest::operations::{
1010
};
1111
use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
1212
use socket_patch_core::patch::apply::select_installed_variants;
13+
use socket_patch_core::patch::redirect::upstream::HostedPin;
1314
use socket_patch_core::patch::rollback::{
1415
cannot_rollback_error, rollback_package_patch, verify_file_rollback, RollbackResult,
1516
VerifyRollbackResult, VerifyRollbackStatus,
1617
};
1718
use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back};
1819
use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers};
19-
use socket_patch_core::patch::redirect::upstream::HostedPin;
2020
use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState};
2121
use std::collections::{HashMap, HashSet};
2222
use std::path::{Path, PathBuf};
@@ -1026,7 +1026,8 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H
10261026
.iter()
10271027
.map(|(code, detail)| (code.to_string(), detail.clone())),
10281028
);
1029-
out.edited_files.extend(outcome.reverted_files.iter().cloned());
1029+
out.edited_files
1030+
.extend(outcome.reverted_files.iter().cloned());
10301031
let unwound: Vec<_> = vlt_targets
10311032
.into_iter()
10321033
.filter(|t| out.reverted.iter().any(|p| p == &t.purl))
@@ -1170,7 +1171,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
11701171
} else if !args.common.silent {
11711172
println!(
11721173
"{} the pre-v5 hosted ledger {}: no lockfile pins a hosted patch.",
1173-
if args.common.dry_run { "Would remove" } else { "Removed" },
1174+
if args.common.dry_run {
1175+
"Would remove"
1176+
} else {
1177+
"Removed"
1178+
},
11741179
socket_patch_core::patch::redirect::REDIRECT_STATE_REL
11751180
);
11761181
}

‎crates/socket-patch-cli/src/commands/scan/discovery.rs‎

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -168,29 +168,32 @@ pub(crate) async fn vendored_ledger_supplement(
168168
}
169169
// `(ledger key, base purl, entry)`; the artifact fallback has no
170170
// entries to probe, so it never reports unwired keys.
171-
let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
172-
match state {
173-
Ok(state) => state
174-
.entries
175-
.iter()
176-
.map(|(key, entry)| {
177-
(
178-
key.clone(),
179-
strip_purl_qualifiers(&entry.base_purl).to_string(),
180-
Some(entry),
181-
)
182-
})
183-
.collect(),
184-
// Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
185-
// recover the vendored set from the committed artifacts, or
186-
// `scan --prune` (whose ledger exemption also degrades to empty)
187-
// would delete still-vendored packages' manifest entries and blobs.
188-
Err(_) => vendored_purls_from_artifacts(common)
189-
.await
190-
.into_iter()
191-
.map(|base| (base.clone(), base, None))
192-
.collect(),
193-
};
171+
let candidates: Vec<(
172+
String,
173+
String,
174+
Option<&socket_patch_core::vendor::VendorEntry>,
175+
)> = match state {
176+
Ok(state) => state
177+
.entries
178+
.iter()
179+
.map(|(key, entry)| {
180+
(
181+
key.clone(),
182+
strip_purl_qualifiers(&entry.base_purl).to_string(),
183+
Some(entry),
184+
)
185+
})
186+
.collect(),
187+
// Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
188+
// recover the vendored set from the committed artifacts, or
189+
// `scan --prune` (whose ledger exemption also degrades to empty)
190+
// would delete still-vendored packages' manifest entries and blobs.
191+
Err(_) => vendored_purls_from_artifacts(common)
192+
.await
193+
.into_iter()
194+
.map(|base| (base.clone(), base, None))
195+
.collect(),
196+
};
194197
// Composer by release identity: a ledger `@3.0.2.0` is the crawled
195198
// `@3.0.2`, not a second package to supplement.
196199
let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
@@ -1038,7 +1041,9 @@ mod tests {
10381041
..GlobalArgs::default()
10391042
};
10401043
let state = socket_patch_core::vendor::load_state(root).await;
1041-
vendored_ledger_supplement(&args, crawled, &state).await.packages
1044+
vendored_ledger_supplement(&args, crawled, &state)
1045+
.await
1046+
.packages
10421047
}
10431048

10441049
/// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1073,7 +1078,9 @@ mod tests {
10731078
out.iter().map(|p| &p.purl).collect::<Vec<_>>()
10741079
);
10751080

1076-
let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
1081+
let out = vendored_ledger_supplement(&args, &[], &Ok(state))
1082+
.await
1083+
.packages;
10771084
assert_eq!(
10781085
out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
10791086
vec!["pkg:composer/psr/log@3.0.2.0"]
@@ -1176,7 +1183,10 @@ mod tests {
11761183
let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
11771184
let out = vendored_ledger_supplement(&args, &[], &state).await;
11781185
assert_eq!(
1179-
out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
1186+
out.packages
1187+
.iter()
1188+
.map(|p| p.purl.as_str())
1189+
.collect::<Vec<_>>(),
11801190
vec!["pkg:npm/left-pad@1.3.0"],
11811191
"lock={lock:?}"
11821192
);

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -932,7 +932,8 @@ pub(crate) async fn run_redirect_selected(
932932
socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok()
933933
})
934934
};
935-
let rewrite_options = || RewriteOptions {
935+
let rewrite_options = || {
936+
RewriteOptions {
936937
dry_run: common.dry_run,
937938
targets_pipenv_lock,
938939
pipenv_major,
@@ -944,6 +945,7 @@ pub(crate) async fn run_redirect_selected(
944945
npm_allow_remote_config: !common.no_npm_allow_remote_config,
945946
npm_outer: &npm_outer,
946947
blocking: true,
948+
}
947949
};
948950
// The rollout gate plans again without its deferred rows: keep what
949951
// the second pass needs.
@@ -2304,13 +2306,19 @@ fn join_names(names: &[String], max: usize) -> String {
23042306
/// artifacts, then verify with `vex`. After a vendored→hosted takeover
23052307
/// (`vendored_removed`) the commit also has to carry the deleted vendored
23062308
/// ledger entries and artifacts.
2307-
fn format_next_steps(files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool) -> Vec<String> {
2309+
fn format_next_steps(
2310+
files: &[String],
2311+
edits: &[socket_patch_core::patch::redirect::FileEdit],
2312+
vendored_removed: bool,
2313+
) -> Vec<String> {
23082314
if files.is_empty() && !vendored_removed {
23092315
return Vec::new();
23102316
}
23112317
let mut commit: Vec<String> = Vec::new();
23122318
if vendored_removed {
2313-
commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string());
2319+
commit.push(
2320+
".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string(),
2321+
);
23142322
}
23152323
commit.extend(files.iter().cloned());
23162324
let npm = files
@@ -4391,19 +4399,43 @@ mod tests {
43914399
use super::npm_allow_remote_one_line;
43924400
let hosts = ["patch.socket.dev"];
43934401
let cases = [
4394-
(npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
4395-
(npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
4396-
(npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
4397-
(npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
4398-
(npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
4399-
(npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
4402+
(
4403+
npm_allow_remote_configured_detail(&hosts, true, false),
4404+
"Note: set",
4405+
),
4406+
(
4407+
npm_allow_remote_configured_detail(&hosts, false, false),
4408+
"Note: set",
4409+
),
4410+
(
4411+
npm_allow_remote_configured_detail(&hosts, true, true),
4412+
"Note: would set",
4413+
),
4414+
(
4415+
npm_allow_remote_already_detail(&hosts),
4416+
"Note: .npmrc already",
4417+
),
4418+
(
4419+
npm_allow_remote_user_set_detail(&hosts, "none"),
4420+
"Warning: npm >=12",
4421+
),
4422+
(
4423+
npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
4424+
"Warning: npm >=12",
4425+
),
44004426
(npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
4401-
(npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
4427+
(
4428+
npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
4429+
"Warning: npm >=12",
4430+
),
44024431
];
44034432
for (detail, start) in cases {
44044433
let line = npm_allow_remote_one_line(&detail);
44054434
assert!(line.starts_with(start), "{line}");
4406-
assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
4435+
assert!(
4436+
!line.contains('\n') && line.ends_with("(details: --verbose)."),
4437+
"{line}"
4438+
);
44074439
}
44084440
}
44094441
}

0 commit comments

Comments
 (0)