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
3 changes: 2 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ Contract details:
* **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`.
* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`.
* **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly.
* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove <env>`, since Hatch keeps an installed release); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source).
* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove <env>`, since Hatch keeps an installed release; for a pypi package of a project whose governing pdm.lock was written by PDM < 2.11 it names `pdm sync --reinstall`, or removing the installed copy first on PDM 1.x); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`<purl>: <why> (<errorCode>)` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: <purl> (<errorCode>)` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source).

### VEX provenance markers (contract)

Expand Down Expand Up @@ -1638,6 +1638,7 @@ command's integration tests check its output.
| `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"). |
| `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, with the same lock-category `sync` arguments as the hosted warning. |
| `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. |
| `pypi_pdm_stale_install` | `skipped` (warning) | vendor (pdm): the project's PDM env (its venv, or PEP 582 `__pypackages__`) still holds the upstream release, and pdm.lock was written by PDM < 2.11 (lock_version `2` / `4.3` / `4.4`), which keeps a same-version install on `pdm sync` / `pdm install`; the detail names `pdm sync --reinstall`, or for PDM 1.x (lock_version `2`, no `--reinstall`) removing the installed copy before `pdm sync`. Emitted on a fresh vendor and on an in-sync re-run while the env stays stale. |
| `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. |
| `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. |
| `vendor_lock_entry_pruned_kept` | revert `warnings[]` | vendor `--revert` / rollback (yarn berry, #737): vendoring dropped a lock entry only the patched package's implicit `node-gyp` dependency reached (or one descriptor of a shared entry), and the lock has re-added or changed that entry since; it is left as it is (not drift: the artifact is still removed). Run `yarn install` if the lock is incomplete. |
Expand Down
14 changes: 13 additions & 1 deletion crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,19 @@ async fn generate_vex(
),
}
};
// PDM < 2.11 keeps an installed release on `pdm sync` (#641): a
// package its pdm.lock wires gets the remedy that resyncs it.
let pdm_lock = if outcome.vendored_out_of_sync.is_empty() || common.is_global() {
None
} else {
socket_patch_core::vendor::pypi_pdm::stale_install_lock(&common.cwd).await
};
for purl in &outcome.vendored_out_of_sync {
let pdm_note = pdm_lock
.as_ref()
.and_then(|lock| socket_patch_core::vendor::pypi_pdm::stale_install_note(lock, purl))
.map(|note| format!(" {note}"))
.unwrap_or_default();
note_warning(
warnings,
common,
Expand All @@ -1068,7 +1080,7 @@ async fn generate_vex(
"{purl}: the installed tree does not match its vendored artifact; the \
attestation is based on the committed .socket/vendor artifact (the lockfile \
consumes it), but the live tree carries different bytes — re-run your \
package manager's install to resync it.{hatch_note}"
package manager's install to resync it.{hatch_note}{pdm_note}"
),
);
}
Expand Down
88 changes: 88 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,14 @@ fn flow(mode: Mode) {
.unwrap();
assert_attested(&doc, PURL, mode.uuid(), mode.marker(), VULNS);
std::fs::remove_file(&vex_out).unwrap();
// The vendored scan ran over a venv that already holds upstream six.
let stale_warnings: Vec<String> = env["events"]
.as_array()
.into_iter()
.flatten()
.filter(|e| e["errorCode"] == "pypi_pdm_stale_install")
.map(|e| e.to_string())
.collect();
let wired_lock = std::fs::read_to_string(project.join("pdm.lock")).unwrap();
assert!(
!project
Expand Down Expand Up @@ -533,6 +541,86 @@ fn flow(mode: Mode) {
matrix.run(&fresh, project, &|dir: &Path| {
std::fs::write(dir.join("pdm.lock"), &pristine_lock).unwrap();
});

if mode == Mode::Vendored {
stale_install_flow(&pdm, tmp.path(), &locked, &wired_lock, &stale_warnings);
}
}

/// #641: PDM before 2.11 (lock_version 2 / 4.3 / 4.4) keeps the upstream six
/// the project venv already held when the vendored scan wired pdm.lock, so
/// the scan must warn `pypi_pdm_stale_install`, and the remedy it names must
/// install the patched wheel. A 2.11+ lock reinstalls on `pdm sync`: silent.
fn stale_install_flow(
pdm: &Pdm,
case: &Path,
locked: &Locked,
wired_lock: &str,
stale_warnings: &[String],
) {
let what = format!(
"pdm {} lock {} stale install",
pdm.version, locked.lock_version
);
let project = &locked.project;
let keeps = matches!(locked.lock_version.as_str(), "2" | "4.3" | "4.4");
if !keeps {
assert!(stale_warnings.is_empty(), "{what}: {stale_warnings:?}");
return;
}
assert_eq!(stale_warnings.len(), 1, "{what}: {stale_warnings:?}");
let python = venv_bin(&project.join(".venv"), "python");
let (_, _, marked) = six_oracle(&python, project).unwrap();
assert!(
!marked,
"{what}: the scan never touches the installed files"
);
assert_eq!(
std::fs::read_to_string(project.join("pdm.lock")).unwrap(),
wired_lock,
"{what}: the project keeps the wired lock"
);

// The next `pdm sync` keeps the upstream bytes, as the warning says.
let envs = pdm.case_env(&case.join("stale-case"), project);
let out = pdm.sync(project, &envs);
let (_, _, marked) = six_oracle(&python, project).unwrap();
assert!(!marked, "{what}: PDM < 2.11 keeps six: {}", out_text(&out));

// The named remedy installs the patched wheel.
let out = if locked.lock_version == "2" {
assert!(
stale_warnings[0].contains("no `pdm sync --reinstall`"),
"{what}: {}",
stale_warnings[0]
);
let (module, _, _) = six_oracle(&python, project).unwrap();
let site = module.parent().unwrap();
std::fs::remove_file(&module).unwrap();
std::fs::remove_dir_all(site.join("six-1.16.0.dist-info")).unwrap();
pdm.sync(project, &envs)
} else {
assert!(
stale_warnings[0].contains("`pdm sync --reinstall`"),
"{what}: {}",
stale_warnings[0]
);
pdm.run(project, &envs, &["sync", "--reinstall", "--no-self"])
};
let (_, _, marked) = six_oracle(&python, project)
.unwrap_or_else(|| panic!("{what}: six missing after the remedy: {}", out_text(&out)));
assert!(
marked,
"{what}: the remedy installs the patch: {}",
out_text(&out)
);
record(
"pdm",
&pdm.version,
&format!("lock {}/vendored", locked.lock_version),
"stale-install-warned",
"pass",
);
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion crates/socket-patch-core/src/patch/redirect/pdm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ pub(super) fn rewrite(
// warning: warn proactively once per lock, mirroring poetry's
// `redirect_poetry_stale_install_risk`.
if !stale_warned
&& matches!(lock_ver.as_deref(), Some("2") | Some("4.3") | Some("4.4"))
&& lock_ver
.as_deref()
.is_some_and(crate::utils::pdm_lock::keeps_installed_release)
{
stale_warned = true;
result.warnings.push(RewriteWarning {
Expand Down
8 changes: 8 additions & 0 deletions crates/socket-patch-core/src/utils/pdm_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ pub fn lock_version(lock: &Table) -> Result<&str, String> {
}
}

/// Whether the PDM release that writes `lock_version` keeps a release
/// already installed at the same version on `pdm sync` / `pdm install`:
/// PDM before 2.11 (lock_version `2` / `4.3` / `4.4`; measured on 1.4.5,
/// 2.8.2, 2.9.3 and 2.10.4, while 2.11+ reinstall).
pub fn keeps_installed_release(lock_version: &str) -> bool {
matches!(lock_version, "2" | "4.3" | "4.4")
}

pub fn validate_strategy(lock: &Table) -> Result<Vec<String>, String> {
let mut result = Vec::new();
let Some(metadata) = lock.get("metadata") else {
Expand Down
Loading
Loading