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
1 change: 1 addition & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). |
| `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. |
| `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. |
| `redirect_pypi_platform_wheel` | `redirect.warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform- or ABI-tagged wheel (any tag triple other than `<py>-none-any`, e.g. `cp311-cp311-manylinux…`). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. |
| `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. |
| `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) |
| `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. |
Expand Down
11 changes: 8 additions & 3 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1824,8 +1824,9 @@ async fn vendored_takeover(
};
// The takeover refusal (if any) for one candidate: bun gates every
// npm purl, berry and vlt only their own vendored entries, Gradle each
// of its own purls, and a requirements.txt entry is gated on the hosted
// rewriter's reach (it pins only the root file, #699). Berry also runs
// of its own purls, a pypi purl on a platform-tagged grant (#701), and a
// requirements.txt entry on the hosted rewriter's reach (it pins only
// the root file, #699). Berry also runs
// the rewriter's per-dep grant gate (a grant without the berry cache
// checksum is skipped by the rewriter, so reverting first would leave
// the package in neither mode). A refused purl is never dispatched (see
Expand All @@ -1837,8 +1838,12 @@ async fn vendored_takeover(
return gradle_takeover_refusals.get(&c.purl).cloned();
}
if c.purl.starts_with("pkg:pypi/") {
// A platform-tagged grant is never pinned (#701 / #932): keep
// the vendored patch rather than revert it to nothing.
return entry.and_then(|e| {
socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err()
socket_patch_core::patch::redirect::pypi_platform_wheel_refusal(&c.dep).or_else(
|| socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err(),
)
});
}
if !c.purl.starts_with("pkg:npm/") {
Expand Down
46 changes: 44 additions & 2 deletions crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs {
}

async fn mock_api(server: &MockServer) {
mock_api_serving(server, HOSTED_URL).await;
}

/// [`mock_api`] with the grant serving `hosted_url` as the patched artifact.
async fn mock_api_serving(server: &MockServer, hosted_url: &str) {
Mock::given(method("POST"))
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
Expand Down Expand Up @@ -143,11 +148,11 @@ async fn mock_api(server: &MockServer) {
"results": {
UUID: {
"status": "granted",
"url": HOSTED_URL,
"url": hosted_url,
"purl": PURL,
"artifacts": [{
"kind": "tarball",
"url": HOSTED_URL,
"url": hosted_url,
"integrity": { "sha256": sha256() }
}],
"registryOverride": null
Expand Down Expand Up @@ -515,6 +520,43 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() {
assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE);
}

/// #932: a patch granted as a platform-tagged wheel (cp311 manylinux) is
/// never pinned into the cross-platform Pipfile.lock, nor into the sibling
/// requirements.txt: the run leaves both files alone and exits 0 like every
/// hosted refusal (the `redirect_pypi_platform_wheel` warning says why),
/// and a same-run VEX never attests the unpinned patch.
#[tokio::test]
#[serial]
async fn platform_wheel_is_not_pinned_into_the_lock() {
let _major = MajorGuard::set("2026");
let server = MockServer::start().await;
let platform_url = HOSTED_URL.replace(
"py2.py3-none-any",
"cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64",
);
mock_api_serving(&server, &platform_url).await;
let tmp = tempfile::tempdir().unwrap();
write_project(tmp.path());
const REQS: &str = "urllib3==1.26.18\n";
std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap();
let code = run(hosted_args(tmp.path(), server.uri(), None)).await;
assert_eq!(code, 0, "a hosted refusal exits 0 with a warning");
assert_eq!(read(&tmp.path().join("Pipfile.lock")), LOCK);
assert_eq!(read(&tmp.path().join("requirements.txt")), REQS);
assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE);
assert_no_ledger(tmp.path());

// With nothing pinned, a same-run `--vex` has nothing to attest (it
// fails `manifest_not_found`) and never claims the patch.
let vex_path = tmp.path().join("out.vex.json");
run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await;
assert_eq!(read(&tmp.path().join("Pipfile.lock")), LOCK);
if vex_path.exists() {
let vex = read(&vex_path);
assert!(!vex.contains("not_affected"), "{vex}");
}
}

/// The same conflict in an ABANDONED lock (no Pipfile beside it) says
/// nothing about the project's install files: the sibling requirements.txt
/// is still redirected.
Expand Down
59 changes: 53 additions & 6 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,19 @@ fn run_raw(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, String,
/// rewriters) — or, with `wheel_served: false`, a 404 for it. Returns the
/// hosted URL.
async fn mount_hosted_api(server: &MockServer, wheel_served: bool) -> String {
mount_hosted_api_serving(server, wheel_served, WHEEL).await
}

/// [`mount_hosted_api`] with the grant naming the wheel file `wheel_name`.
async fn mount_hosted_api_serving(
server: &MockServer,
wheel_served: bool,
wheel_name: &str,
) -> String {
let wheel = hosted_wheel();
let sha = hex::encode(Sha256::digest(&wheel));
let route =
format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{WHEEL}");
format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{wheel_name}");
let hosted_url = format!("{}{route}", server.uri());
Mock::given(method("POST"))
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
Expand Down Expand Up @@ -799,11 +808,30 @@ async fn vendor_check_fails_after_hatch_dependency_reset() {
/// revert — the vendored patch, ledger entry and wheel are kept — and the
/// dry run must predict that refusal instead of a clean takeover.
async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run: bool) {
assert_takeover_refused_before_revert(
root,
wired,
dry_run,
WHEEL,
"redirect_requirements_takeover_unreachable",
)
.await;
}

/// A takeover hosted mode cannot complete is refused before the revert:
/// the vendored line, ledger entry and wheel are kept and `code` names why.
async fn assert_takeover_refused_before_revert(
root: &Path,
wired: &str,
dry_run: bool,
wheel_name: &str,
code_name: &str,
) {
let before = std::fs::read_to_string(root.join(wired)).unwrap();
let root_before = std::fs::read_to_string(root.join("requirements.txt")).unwrap();
let state = root.join(".socket/vendor/state.json");
let server = MockServer::start().await;
mount_hosted_api(&server, true).await;
mount_hosted_api_serving(&server, true, wheel_name).await;
let uri = server.uri();
let mut args = hosted_scan_args(&uri);
if dry_run {
Expand All @@ -820,10 +848,7 @@ async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run:
!text.contains("redirect_takeover_unpatched"),
"the package is never stranded: {env:#}"
);
assert!(
text.contains("redirect_requirements_takeover_unreachable"),
"the refusal is named: {env:#}"
);
assert!(text.contains(code_name), "the refusal is named: {env:#}");
assert_eq!(env["redirect"]["redirected"], 0, "{env:#}");
assert_eq!(
code, 0,
Expand Down Expand Up @@ -915,3 +940,25 @@ async fn dry_run_previews_root_pin_takeover() {
);
assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
}

/// #701 / #932: a hosted grant that is a platform-tagged wheel is never
/// pinned, so a vendored → hosted takeover onto it must be refused BEFORE
/// the revert (keeping the vendored patch) instead of stranding the
/// package unpatched — on the dry run too.
#[tokio::test]
async fn platform_wheel_takeover_is_refused_before_revert() {
const PLATFORM: &str = "six-1.16.0-cp311-cp311-manylinux_2_17_x86_64.whl";
for dry_run in [true, false] {
let (_tmp, root) = project();
let files = stage_requirements(&root);
vendor_project(&root, files);
assert_takeover_refused_before_revert(
&root,
"requirements.txt",
dry_run,
PLATFORM,
"redirect_pypi_platform_wheel",
)
.await;
}
}
9 changes: 4 additions & 5 deletions crates/socket-patch-core/src/crawlers/gradle_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool {
/// Whether `bytes` are the pristine download Gradle stored in the hash
/// directory `dir_name` (their sha1 names it).
pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool {
use sha1::{Digest, Sha1};
hash_eq(dir_name, &hex::encode(Sha1::digest(bytes)))
hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes))
}

/// Whether `path` is a version directory of a `files-2.1` tree
Expand Down Expand Up @@ -432,8 +431,6 @@ impl DerivedIndex {
/// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes
/// hash to `pristine_sha1`.
pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies {
use sha1::{Digest, Sha1};

let instrumented = format!("instrumented-{jar_leaf}");
let mut out = DerivedCopies {
incomplete: self.incomplete,
Expand All @@ -460,7 +457,9 @@ impl DerivedIndex {
out.stale.push(path.clone());
} else if name == jar_leaf || name == instrumented {
match crate::utils::fs::read_regular_to_bytes_sync(path) {
Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => {
Ok(bytes)
if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) =>
{
out.stale.push(path.clone())
}
Ok(_) => out.unknown.push(path.clone()),
Expand Down
7 changes: 2 additions & 5 deletions crates/socket-patch-core/src/patch/jvm_jar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use sha1::Digest as _;

use crate::crawlers::gradle_cache;
use crate::hash::git_sha256::compute_git_sha256_from_bytes;
use crate::manifest::schema::PatchFileInfo;
Expand Down Expand Up @@ -353,12 +351,11 @@ fn unpatched_members(
}

fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
crate::utils::digest::sha256_hex_of(bytes)
}

fn sha1_hex(bytes: &[u8]) -> String {
hex::encode(sha1::Sha1::digest(bytes))
crate::utils::digest::sha1_hex_of(bytes)
}

/// `<socket_dir>/jvm-originals/<sha256>.jar`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ fn serial_oracle(
bun_lockb_present: bool,
) -> RewriteResult {
let mut result = RewriteResult::default();
let overrides = withhold_pypi_platform_wheels(overrides, &mut result);
let overrides: &[DepOverride] = &overrides;
if pdm_drives(files) {
pdm::rewrite(files, overrides, &mut result);
}
Expand Down Expand Up @@ -118,6 +120,8 @@ fn assert_same_with_metadata(
// And the parallel merge itself — not the serial fallback — is what
// produced it: real rewriters only append, to files of their own.
let mut prefix = RewriteResult::default();
let overrides = withhold_pypi_platform_wheels(overrides, &mut prefix);
let overrides: &[DepOverride] = &overrides;
if pdm_drives(files) {
pdm::rewrite(files, overrides, &mut prefix);
}
Expand Down
62 changes: 62 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ use crate::formats::pnpm::hosted::pnpm_unrewritten_instances;
use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin};
#[cfg(test)]
mod pnpm_equivalence_tests;
#[cfg(test)]
mod platform_wheel_tests;
mod poetry;
#[cfg(test)]
mod python_lock_equivalence_tests;
Expand Down Expand Up @@ -510,6 +512,64 @@ pub fn rewrite_registry_redirect_with_pipenv_version(
)
}

/// #701 / #932: the patch service can grant a pypi patch as a platform- or
/// ABI-tagged wheel (`…-cp311-cp311-manylinux…whl`). Every hosted PyPI lock
/// (uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock,
/// pdm.lock, requirements.txt, Hatch's pyproject) is meant to install on
/// any platform its markers allow, and a hosted pin narrows the entry to
/// that one wheel: installs on any other interpreter, OS or architecture
/// then fail, and hosted rollback cannot derive which upstream wheels to
/// put back. Fail closed like hosted gem (`redirect_gem_platform_unsupported`).
/// The tags are read the way vendored mode reads them for
/// `vendor_platform_locked`. `None` for a portable wheel, an sdist, or a
/// non-pypi override. The vendored→hosted takeover asks this BEFORE it
/// reverts a vendored purl, so the refusal never strips a live patch.
pub fn pypi_platform_wheel_refusal(dep: &DepOverride) -> Option<RewriteWarning> {
if dep.ecosystem != "pypi" {
return None;
}
let path = dep
.artifact_url
.split(['?', '#'])
.next()
.unwrap_or_default();
let file_name = path.rsplit('/').next().unwrap_or_default();
// An sdist carries no platform tags.
if !file_name.ends_with(".whl") {
return None;
}
let (platform_locked, tags) =
crate::vendor::pypi_distribution::wheel_platform_from_filename(file_name);
platform_locked.then(|| RewriteWarning {
code: "redirect_pypi_platform_wheel".into(),
detail: format!(
"the patched wheel for {}=={} is platform-specific ({tags}); pinning it \
would make the project's Python lockfiles install it on this platform \
only, so the redirect is skipped and nothing was written for it",
dep.name, dep.version
),
})
}

/// Withhold every [`pypi_platform_wheel_refusal`] patch from all the PyPI
/// rewriters, warning once per patch.
fn withhold_pypi_platform_wheels<'a>(
overrides: &'a [DepOverride],
result: &mut RewriteResult,
) -> Cow<'a, [DepOverride]> {
let mut refused = std::collections::BTreeSet::new();
for dep in overrides {
if refused.contains(&dep.patch_uuid) {
continue;
}
if let Some(warning) = pypi_platform_wheel_refusal(dep) {
refused.insert(dep.patch_uuid.clone());
result.warnings.push(warning);
}
}
withhold(overrides, &refused)
}

/// [`rewrite_registry_redirect_with_pipenv_version`] with the patch uuids
/// in `vlt_withheld` kept out of the vlt rewrite only: their artifact
/// failed vlt's preflight while another npm-family lock may be the one the
Expand All @@ -526,6 +586,8 @@ pub fn rewrite_registry_redirect_withholding_vlt(
gradle_unreadable: &std::collections::BTreeSet<String>,
) -> RewriteResult {
let mut result = RewriteResult::default();
let overrides = withhold_pypi_platform_wheels(overrides, &mut result);
let overrides: &[DepOverride] = &overrides;
// pdm runs FIRST, but only when `pdm.lock` is the project's PyPI install
// driver (see [`pdm_drives`]). When it does, a patch it refuses is
// withheld from every other pypi rewriter so a sibling `Pipfile.lock` /
Expand Down
Loading
Loading