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
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ to **six flavors**.
| pypi / poetry (poetry.lock: legacy `[metadata.hashes]`, lock 1.0/1.1 `[metadata.files]`, 2.x `files`) | (rebuilt wheel) | lock-only: the target `[[package]]` gets `[package.source] type="file"` (+ `reference = ""` on the 0.12/1.0 layouts, which read it unconditionally) and the single `{file, hash: sha256-of-our-wheel}` entry in whichever table the generation keeps it. pyproject + `metadata.content-hash` untouched; CRLF locks keep their line endings. A lock written by Poetry < 1.4 emits `pypi_poetry_integrity_unverified` (that installer verifies no local hashes and skips an already-installed version) | `poetry check --lock && poetry sync`, cold cache (hash fail-closed from Poetry 1.4; byte-stable lock) — see `docs/testing/poetry-compatibility.md` |
| pypi / pdm (pdm.lock) | (rebuilt wheel) | lock-only: the `[[package]]` gains the local-file `path` + `files[]` hash. pyproject + `content_hash` untouched. Non-fixture `[metadata] strategy` / hash-less locks refused | `pdm sync` (+ `pdm install --check`), cold cache |
| pypi / pipenv (Pipfile.lock) | (rebuilt wheel) | lock-only: the `default`/`develop` entry → `{file, hashes:[sha256-of-our-wheel]}`. Pipfile + `_meta.hash` untouched. Emits `vendor_integrity_unverified` — pipenv does not hash-check file entries; the committed wheel bytes are the protection | `pipenv install --deploy` (+ `pipenv verify`), cold cache |
| pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./<wheel> --hash=sha256:<hex>` (markers carried over; transitive deps appended) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) |
| pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./<wheel>` (markers carried over; transitive deps appended), plus `--hash=sha256:<hex>` only when the requirements tree is already in pip's hash-checking mode (any `--hash` or `--require-hashes`) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) |
| nuget | deterministically rebuilt `.nupkg` at `<idLower>.<versionNorm>.nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `<package pattern="*" />` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` when the lock exists (`vendor_nuget_no_lockfile` warning otherwise) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) |
| maven | deterministically rebuilt `.jar` + the **verbatim upstream pom** (transitives survive; refused via `vendor_maven_pom_unavailable` rather than fabricated) + `.sha1` sidecars, laid out as a maven2 repository under the uuid dir | `pom.xml` `<repository>` (`id=socket-patch-vendor-<uuid>`, `url=file://${project.basedir}/.socket/vendor/maven/<uuid>`, `checksumPolicy=fail`, snapshots disabled). Multi-module aggregator poms refused (`vendor_maven_multimodule_unsupported`); gradle-only projects refused (`vendor_gradle_unsupported`); always-on `vendor_maven_local_cache_shadow` advisory (warm `~/.m2` wins over any repository) | `mvn` build on a fresh checkout with the GAV purged from the local repo, `--network none` (docker capstone; note `mvn -o` refuses `file://` repositories outright) |

Expand Down Expand Up @@ -682,7 +682,7 @@ worse, lets a warm cache silently serve unpatched bytes):
| pypi / poetry | `files = [{file, hash}]` (2.x) / `[metadata.files]` entry (1.0/1.1) / `[metadata.hashes]` entry (0.12) | replaced with a single `{file, hash: sha256-of-our-wheel}` (or the bare hash for 0.12) in the generation's own table (Poetry ≥ 1.4 verifies the artifact against one listed hash; older writers are flagged `pypi_poetry_integrity_unverified`; stale registry hashes removed) |
| pypi / pdm | `[[package]].files[]` hashes | replaced with our wheel's sha256; hash-less locks refused (`pypi_pdm_lock_no_hashes`) |
| pypi / pipenv | per-entry `hashes[]` | replaced with `["sha256:<ours>"]` — but pipenv does **not** enforce hashes on file entries (`vendor_integrity_unverified` warning); the committed wheel bytes are the actual protection |
| pypi / requirements | `--hash=sha256:` | fresh hash of the rebuilt wheel always emitted (turns on pip's hash-checking for the line) |
| pypi / requirements | `--hash=sha256:` | fresh hash of the rebuilt wheel emitted only when the requirements tree is already in pip's hash-checking mode; an unhashed tree stays unhashed, since one `--hash` turns the mode on for every requirement (#376) |

### Ownership, state, and reversal

Expand Down
10 changes: 7 additions & 3 deletions crates/socket-patch-cli/tests/e2e_hosted_production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1930,10 +1930,14 @@ fn pypi_requirements_txt_hosted_install_proof() {
assert_redirected(&env_json, "requirements.txt");
let reqs = read(&proj.join("requirements.txt"));
assert_hosted_pin(&reqs, PYPI_UUIDS, LEG);
// An unhashed file is pinned by the url's `#sha256=` fragment, which
// pip and uv both verify; a `--hash` would put pip in hash-checking
// mode for every other requirement (#376).
assert!(
reqs.contains("--hash=sha256:"),
"{LEG}: rewritten requirements.txt carries no --hash pin, so pip/uv \
would install the hosted wheel unverified:\n{reqs}"
reqs.contains(".whl#sha256=") && !reqs.contains("--hash"),
"{LEG}: rewritten requirements.txt must pin the hosted wheel by its \
url fragment (and add no --hash), or pip/uv would install it \
unverified:\n{reqs}"
);

std::fs::remove_dir_all(&venv).expect("rm venv");
Expand Down
17 changes: 12 additions & 5 deletions crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1033,8 +1033,9 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() {
);
assert_vendored_applied(&parse_envelope(&stdout));

// Artifact + the rewritten pin line (the exact spike-tested shape:
// `./<wheel> --hash=sha256:<hex> # socket-patch vendor: six==1.16.0`).
// Artifact + the rewritten pin line (the spike-tested shape:
// `./<wheel> # socket-patch vendor: six==1.16.0`; `--hash=sha256:<hex>`
// only in a file already in pip's hash-checking mode, #376).
let wheel = vendored_wheel(&proj);
let wheel_rel = format!(
".socket/vendor/pypi/{UUID}/{}",
Expand All @@ -1052,8 +1053,9 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() {
"the path line must be ./-prefixed and project-relative: {vendor_line}"
);
assert!(
vendor_line.contains("--hash=sha256:"),
"the path line must pin the wheel hash (hardens every install): {vendor_line}"
!requirements.contains("--hash"),
"an unhashed requirements.txt must stay unhashed, or pip's \
hash-checking mode refuses every other requirement (#376):\n{requirements}"
);
assert!(
!requirements
Expand Down Expand Up @@ -1201,7 +1203,12 @@ fn pip_vendored_requirements_evaluate_environment_markers() {
"install upstream six",
);
let patched = stage_patch(&project, &site_packages(&venv).join("six.py"));
let original = format!("six==1.16.0 ; {marker}\n");
// Hash-pinned (pip-compile style), so the fresh install below can run
// `--require-hashes`: a hashed file keeps the vendor line hashed
// (#376), and the marker must survive next to the `--hash`.
let original = format!(
"six==1.16.0 ; {marker} \\\n --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\n"
);
std::fs::write(project.join("requirements.txt"), &original).unwrap();
let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &project);
assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}");
Expand Down
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/tests/e2e_vendored_production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1891,8 +1891,9 @@ fn pypi_requirements_txt_vendored_install_proof() {
"{LEG}: requirements.txt was not rewired to the vendored wheel:\n{reqs}"
);
assert!(
reqs.contains("--hash=sha256:"),
"{LEG}: rewritten requirements.txt carries no --hash pin:\n{reqs}"
!reqs.contains("--hash"),
"{LEG}: an unhashed requirements.txt must stay unhashed, or pip's \
hash-checking mode refuses every other requirement (#376):\n{reqs}"
);

// DELIVERY PROOF: requirements.txt + .socket only, fresh venv, --no-index
Expand Down
37 changes: 28 additions & 9 deletions crates/socket-patch-cli/tests/e2e_vex_build/pip.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
//! Real-pip capstone for manifest-less VEX over `requirements.txt`: for
//! every pip major (latest release of each, `SOCKET_PATCH_PIP_E2E_VERSIONS`
//! overrides), HOSTED and VENDORED, three project shapes:
//! overrides), HOSTED and VENDORED, four project shapes:
//!
//! | cell | requirements | hosted | vendored |
//! | --- | --- | --- | --- |
//! | `root` | `six==1.16.0` | yes | yes |
//! | `hashes` | `pip-compile --generate-hashes` style (`\` continued `--hash`, hash-checking mode) | yes | yes |
//! | `include` | `-r requirements/base.txt` | root-only rewriter: stays on the registry, nothing attested | yes |
//! | `unhashed` | `six==1.16.0` + `idna==3.7`, no hashes: the wiring must not add a `--hash` (#376) | yes | yes |
//!
//! Each flow:
//!
Expand Down Expand Up @@ -57,6 +58,10 @@ enum Cell {
Root,
Hashes,
Include,
/// #376: a second, unhashed requirement next to the patched one. One
/// `--hash` on the wired line would put pip in hash-checking mode for
/// the whole install and refuse `idna==3.7`.
Unhashed,
}

impl Cell {
Expand All @@ -65,6 +70,7 @@ impl Cell {
Cell::Root => "root",
Cell::Hashes => "hashes",
Cell::Include => "include",
Cell::Unhashed => "unhashed",
}
}

Expand All @@ -82,6 +88,7 @@ impl Cell {
("requirements.txt", "-r requirements/base.txt\n".into()),
("requirements/base.txt", "six==1.16.0\n".into()),
],
Cell::Unhashed => vec![("requirements.txt", "six==1.16.0\nidna==3.7\n".into())],
}
}
}
Expand Down Expand Up @@ -221,10 +228,19 @@ fn flow(uv: &Path, major: &str, pip_version: &str, cell: Cell, mode: Mode, root:
record("pip", pip_version, &row, "embedded-scan-vex", "pass");
std::fs::remove_file(&embedded).unwrap();
let wired = wiring_text(&proj, cell);
// pip's hash-checking mode is all or nothing (#376): only an already
// hashed file gets a `--hash`; the hosted url otherwise carries the
// pin as a `#sha256=` fragment pip verifies without the mode.
assert_eq!(
wired.contains("--hash="),
cell == Cell::Hashes,
"{what}: the wiring must keep the file's hash-checking mode: {wired}"
);
match mode {
Mode::Hosted => assert!(
wired.contains(&api.artifact_url()) && wired.contains("--hash=sha256:"),
"{what}: requirements must point at the hosted wheel: {wired}"
wired.contains(&api.artifact_url())
&& (cell == Cell::Hashes || wired.contains(".whl#sha256=")),
"{what}: requirements must point at the pinned hosted wheel: {wired}"
),
Mode::Vendored => assert!(
wired.contains(&format!(".socket/vendor/pypi/{}/", mode.uuid())),
Expand All @@ -239,11 +255,14 @@ fn flow(uv: &Path, major: &str, pip_version: &str, cell: Cell, mode: Mode, root:
let fresh_venv = fresh.join(".venv");
pip_venv(uv, major, &fresh_venv).unwrap_or_else(|e| panic!("{what}: fresh venv: {e}"));
let downloads = api.artifact_downloads();
let out = pip(
&fresh_venv,
&fresh,
&["install", "--no-index", "-r", "requirements.txt"],
);
// The unhashed cell's other requirement (idna) comes from PyPI; the
// patched six still can only come from the wiring.
let install: &[&str] = if cell == Cell::Unhashed {
&["install", "-r", "requirements.txt"]
} else {
&["install", "--no-index", "-r", "requirements.txt"]
};
let out = pip(&fresh_venv, &fresh, install);
assert_ok(&out, &format!("{what}: fresh `pip install --no-index -r`"));
let (_, bytes, is_patched) = six_oracle(&venv_bin(&fresh_venv, "python"), &fresh)
.unwrap_or_else(|| panic!("{what}: six not importable in the fresh checkout"));
Expand Down Expand Up @@ -335,7 +354,7 @@ fn pip_every_major_hosted_and_vendored_end_in_manifest_less_vex() {
continue;
}
};
for cell in [Cell::Root, Cell::Hashes, Cell::Include] {
for cell in [Cell::Root, Cell::Hashes, Cell::Include, Cell::Unhashed] {
for mode in [Mode::Hosted, Mode::Vendored] {
let root = scratch.path().join("run");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,11 @@ fn assert_no_manifest_no_blobs(cwd: &Path) {
// ---------------------------------------------------------------------------

/// A pip project pinning `requests==2.31.0`: the hosted grant must rewrite
/// that one line to `requests @ <hosted-url> --hash=sha256:<hex>` (the
/// integrity pin fails closed on tampered bytes), leave the bystander line
/// byte-identical — and write no manifest and no ledger.
/// that one line to `requests @ <hosted-url>#sha256=<hex>` (the integrity
/// pin fails closed on tampered bytes; a url fragment, not `--hash`, since
/// one `--hash` would put pip in hash-checking mode for the unhashed
/// `flask` line too — #376), leave the bystander line byte-identical — and
/// write no manifest and no ledger.
#[tokio::test]
#[serial]
async fn pypi_requirements_hosted_rewrites_pinned_line() {
Expand Down Expand Up @@ -211,7 +213,7 @@ async fn pypi_requirements_hosted_rewrites_pinned_line() {
assert_eq!(code, 0, "get <uuid> --mode hosted (pypi) should succeed");

let reqs = std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap();
let expected_line = format!("requests @ {url} --hash=sha256:{SHA256}");
let expected_line = format!("requests @ {url}#sha256={SHA256}");
assert!(
reqs.lines().any(|l| l == expected_line),
"requirements.txt must pin the hosted wheel URL + sha256; got:\n{reqs}"
Expand All @@ -233,7 +235,7 @@ async fn pypi_requirements_hosted_rewrites_pinned_line() {

// Manifest-less VEX over what `get --mode hosted` committed (it never
// writes a manifest). An EMPTY in-project venv keeps the crawl hermetic
// (nothing installed: the `--hash` pin is the evidence); the grant's
// (nothing installed: the `#sha256=` pin is the evidence); the grant's
// origin is this test's patch server, hence `--patch-server-url`.
let site = if cfg!(windows) {
tmp.path().join(".venv/Lib/site-packages")
Expand Down
31 changes: 30 additions & 1 deletion crates/socket-patch-cli/tests/vendor_ledger_schema_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,43 @@ fn new_ledgers_compact_whole_file_snapshots_and_revert() {
}
}

/// `bytes` with every ` --hash=sha256:<64 hex>` option removed.
fn strip_sha256_hash_options(bytes: &[u8]) -> Vec<u8> {
const NEEDLE: &[u8] = b" --hash=sha256:";
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes.get(i + NEEDLE.len()..i + NEEDLE.len() + 64);
if bytes[i..].starts_with(NEEDLE)
&& hex.is_some_and(|h| h.iter().all(u8::is_ascii_hexdigit))
{
i += NEEDLE.len() + 64;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}

/// Against the integrated base: for every ecosystem this binary wires
/// exactly the files the base binary wired (the checked-in legacy
/// fixtures), and its ledger — whatever its on-disk version — loads to the
/// same entries the base's version-1 ledger loads to.
#[tokio::test]
async fn server_artifacts_preserve_legacy_wiring_shape_and_originals() {
for eco in fx::ALL {
let base_wired = read_tree(&fixtures_dir().join(eco).join("wired"));
let mut base_wired = read_tree(&fixtures_dir().join(eco).join("wired"));
if *eco == "pypi-requirements" {
// The one intended difference: the base binary pinned its vendor
// lines with `--hash` even in this unhashed requirements.txt,
// which put pip in hash-checking mode for every other
// requirement (#376). This binary writes the same lines without
// it — in the file and in the ledger's recorded `new` text.
for (_, bytes) in &mut base_wired {
*bytes = strip_sha256_hash_options(bytes);
}
}
let f = Fixture::new(eco);
let (code, stdout, stderr) = f.vendor(&[], &[]);
assert_eq!(code, 0, "{eco}: {stdout}\n{stderr}");
Expand Down
29 changes: 21 additions & 8 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6286,14 +6286,16 @@ mod tests {
)];
let first = rewrite_registry_redirect(&files, &overrides);
let out = first.files.get("requirements.txt").expect("rewritten");
// An unhashed file pins by the url fragment (#376), so the marker
// follows it.
assert_eq!(
out.matches("--hash=sha256:").count(),
out.matches("sha256").count(),
1,
"exactly one hash after the first pass: {out}"
);
assert!(
out.contains("; python_version >= \"3.7\" --hash="),
"marker preserved ahead of the hash: {out}"
out.contains("-none-any.whl#sha256=") && out.contains(" ; python_version >= \"3.7\"\n"),
"marker preserved after the pinned url: {out}"
);

let mut again = files.clone();
Expand All @@ -6307,10 +6309,11 @@ mod tests {
);
}

/// An inline comment after the marker must not swallow the appended
/// `--hash=…` (pip would then treat the hash as comment text and skip
/// enforcement). The comment is split off and re-appended AFTER the hash
/// so the pin stays active and the user's note survives.
/// An inline comment after the marker must not swallow the appended pin
/// (pip would then treat it as comment text and skip enforcement). The
/// comment is split off and re-appended AFTER the pin — the url's
/// `#sha256=` fragment in an unhashed file (#376), `--hash` in a hashed
/// one — so the pin stays active and the user's note survives.
#[test]
fn requirements_marker_comment_keeps_hash_active() {
let original = "requests==2.28.1 ; python_version >= \"3.7\" # explanation\n";
Expand All @@ -6323,13 +6326,23 @@ mod tests {
assert_eq!(
output,
&format!(
"requests @ {url} ; python_version >= \"3.7\" --hash=sha256:{sha256} # explanation\n"
"requests @ {url}#sha256={sha256} ; python_version >= \"3.7\" # explanation\n"
)
);
let again = BTreeMap::from([("requirements.txt".to_string(), output.clone())]);
let second = rewrite_registry_redirect(&again, &overrides);
assert!(second.files.is_empty());
assert!(second.edits.is_empty());

let hashed = original.replace("# explanation", "--hash=sha256:old # explanation");
let files = BTreeMap::from([("requirements.txt".to_string(), hashed)]);
let first = rewrite_registry_redirect(&files, &overrides);
assert_eq!(
first.files["requirements.txt"],
format!(
"requests @ {url} ; python_version >= \"3.7\" --hash=sha256:{sha256} # explanation\n"
)
);
}

const MAVEN_SUFFIXED: &str = "1.7.36-socket.aaaaaaaa";
Expand Down
Loading
Loading