Skip to content
Open
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
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

30 changes: 24 additions & 6 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1880,11 +1880,30 @@ async fn vendored_takeover(
} else {
std::collections::HashMap::new()
};
// A PyPI entry is gated on the hosted rewriter's reach after the
// revert: requirements.txt pins only the root file (#699), uv pins
// only the version the restored lock resolves (#723), and Poetry
// refuses every 0.x lock (#945). Checked once per purl, here, because
// the uv and Poetry checks read the ledger and the lock on disk.
let mut pypi_takeover_refusals: std::collections::HashMap<
String,
socket_patch_core::patch::redirect::RewriteWarning,
> = std::collections::HashMap::new();
for (c, entry) in &takeover {
let Some(entry) = entry.as_ref().filter(|_| c.purl.starts_with("pkg:pypi/")) else {
continue;
};
if let Err(warning) =
socket_patch_core::patch::redirect::preflight_pypi_takeover(&common.cwd, entry).await
{
pypi_takeover_refusals.insert(c.purl.clone(), warning);
}
}
// 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, 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
// PyPI entry on the hosted rewriter's reach (`pypi_takeover_refusals`
// above). 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 @@ -1898,10 +1917,9 @@ async fn vendored_takeover(
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::pypi_platform_wheel_refusal(&c.dep).or_else(
|| socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err(),
)
return entry.and_then(|_| {
socket_patch_core::patch::redirect::pypi_platform_wheel_refusal(&c.dep)
.or_else(|| pypi_takeover_refusals.get(&c.purl).cloned())
});
}
if !c.purl.starts_with("pkg:npm/") {
Expand Down
249 changes: 225 additions & 24 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -952,27 +952,36 @@ 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(
assert_takeover_refused(
root,
wired,
dry_run,
WHEEL,
&[wired, "requirements.txt"],
"redirect_requirements_takeover_unreachable",
dry_run,
)
.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(
/// A vendored → hosted takeover the hosted rewriter cannot carry through
/// is refused BEFORE the revert, in the wet run and the dry run alike:
/// the refusal `code` is named, nothing is redirected, the run exits 0,
/// and every wiring file in `files`, the ledger entry and the vendored
/// wheel are kept byte for byte.
async fn assert_takeover_refused(root: &Path, files: &[&str], code_name: &str, dry_run: bool) {
assert_takeover_refused_serving(root, files, code_name, dry_run, WHEEL).await;
}

/// [`assert_takeover_refused`] with the hosted API granting `wheel_name`.
async fn assert_takeover_refused_serving(
root: &Path,
wired: &str,
files: &[&str],
code_name: &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 before: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
.collect();
let state = root.join(".socket/vendor/state.json");
let server = MockServer::start().await;
mount_hosted_api_serving(&server, true, wheel_name).await;
Expand All @@ -998,16 +1007,13 @@ async fn assert_takeover_refused_before_revert(
code, 0,
"a refused takeover keeps the package vendored: {env:#}"
);
assert_eq!(
std::fs::read_to_string(root.join(wired)).unwrap(),
before,
"{wired}: the vendored line is kept"
);
assert_eq!(
std::fs::read_to_string(root.join("requirements.txt")).unwrap(),
root_before,
"requirements.txt is untouched"
);
for (f, before) in files.iter().zip(&before) {
assert_eq!(
&std::fs::read_to_string(root.join(f)).unwrap(),
before,
"{f}: the vendored wiring is kept"
);
}
assert!(
std::fs::read_to_string(&state).unwrap().contains(UUID),
"the ledger entry is kept"
Expand Down Expand Up @@ -1085,6 +1091,201 @@ async fn dry_run_previews_root_pin_takeover() {
assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
}

/// uv.lock resolving `six` 1.17.0, either as a direct `six>=1.15` or
/// through `python-dateutil`. Vendoring the manifest's `six@1.16.0` pins
/// the lock entry down to 1.16.0, so the revert brings 1.17.0 back.
const UV_LOCK_DRIFTED_DIRECT: &str = r#"version = 1
revision = 2
requires-python = ">=3.9"

[[package]]
name = "demo"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "six" },
]

[package.metadata]
requires-dist = [{ name = "six", specifier = ">=1.15" }]

[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
"#;

const UV_LOCK_DRIFTED_TRANSITIVE: &str = r#"version = 1
revision = 2
requires-python = ">=3.9"

[[package]]
name = "demo"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "python-dateutil" },
]

[package.metadata]
requires-dist = [{ name = "python-dateutil", specifier = "==2.9.0.post0" }]

[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]

[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
"#;

/// #723: vendor a uv project whose lock resolves `six` 1.17.0 while the
/// manifest patches `six@1.16.0`; vendored uv pins the lock down to the
/// patch version.
fn drifted_uv_project(dependency: &str, lock: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let (tmp, root) = project();
std::fs::write(
root.join("pyproject.toml"),
format!(
"[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"{dependency}\"]\n"
),
)
.unwrap();
std::fs::write(root.join("uv.lock"), lock).unwrap();
vendor_project(&root, &["uv.lock", "pyproject.toml"]);
(tmp, root)
}

fn drifted_uv_direct() -> (tempfile::TempDir, std::path::PathBuf) {
drifted_uv_project("six>=1.15", UV_LOCK_DRIFTED_DIRECT)
}

fn drifted_uv_transitive() -> (tempfile::TempDir, std::path::PathBuf) {
drifted_uv_project("python-dateutil==2.9.0.post0", UV_LOCK_DRIFTED_TRANSITIVE)
}

#[tokio::test]
async fn uv_pinned_down_direct_takeover_is_refused_before_revert() {
let (_tmp, root) = drifted_uv_direct();
assert_takeover_refused(
&root,
&["uv.lock", "pyproject.toml"],
"redirect_uv_takeover_version_unreachable",
false,
)
.await;
}

#[tokio::test]
async fn dry_run_predicts_uv_pinned_down_direct_takeover_refusal() {
let (_tmp, root) = drifted_uv_direct();
assert_takeover_refused(
&root,
&["uv.lock", "pyproject.toml"],
"redirect_uv_takeover_version_unreachable",
true,
)
.await;
}

#[tokio::test]
async fn uv_pinned_down_transitive_takeover_is_refused_before_revert() {
let (_tmp, root) = drifted_uv_transitive();
assert_takeover_refused(
&root,
&["uv.lock", "pyproject.toml"],
"redirect_uv_takeover_version_unreachable",
false,
)
.await;
}

#[tokio::test]
async fn dry_run_predicts_uv_pinned_down_transitive_takeover_refusal() {
let (_tmp, root) = drifted_uv_transitive();
assert_takeover_refused(
&root,
&["uv.lock", "pyproject.toml"],
"redirect_uv_takeover_version_unreachable",
true,
)
.await;
}

/// A Poetry 0.12 lock: no `lock-version`, hashes in `[metadata.hashes]`.
const POETRY_0_LOCK: &str = r#"[[package]]
category = "main"
description = "Python 2 and 3 compatibility utilities"
name = "six"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.16.0"

[metadata]
content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01"
python-versions = ">=3.9"

[metadata.hashes]
six = ["sha256:WHEEL_SHA", "sha256:SDIST_SHA"]
"#;

/// #945: vendored mode supports a Poetry 0.12 lock, hosted mode refuses
/// every one of them.
fn poetry_0_project() -> (tempfile::TempDir, std::path::PathBuf) {
let (tmp, root) = project();
let files = stage_poetry(&root);
std::fs::write(
root.join("poetry.lock"),
POETRY_0_LOCK
.replace("WHEEL_SHA", WHEEL_SHA)
.replace("SDIST_SHA", SDIST_SHA),
)
.unwrap();
vendor_project(&root, files);
(tmp, root)
}

#[tokio::test]
async fn poetry_0_lock_takeover_is_refused_before_revert() {
let (_tmp, root) = poetry_0_project();
assert_takeover_refused(
&root,
&["poetry.lock", "pyproject.toml"],
"redirect_poetry_lock_unsupported",
false,
)
.await;
}

#[tokio::test]
async fn dry_run_predicts_poetry_0_lock_takeover_refusal() {
let (_tmp, root) = poetry_0_project();
assert_takeover_refused(
&root,
&["poetry.lock", "pyproject.toml"],
"redirect_poetry_lock_unsupported",
true,
)
.await;
}

/// #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
Expand All @@ -1096,12 +1297,12 @@ async fn platform_wheel_takeover_is_refused_before_revert() {
let (_tmp, root) = project();
let files = stage_requirements(&root);
vendor_project(&root, files);
assert_takeover_refused_before_revert(
assert_takeover_refused_serving(
&root,
"requirements.txt",
&["requirements.txt"],
"redirect_pypi_platform_wheel",
dry_run,
PLATFORM,
"redirect_pypi_platform_wheel",
)
.await;
}
Expand Down
2 changes: 2 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ mod pnpm_equivalence_tests;
#[cfg(test)]
mod platform_wheel_tests;
mod poetry;
mod pypi_takeover;
pub use pypi_takeover::preflight_pypi_takeover;
#[cfg(test)]
mod python_lock_equivalence_tests;
mod requirements;
Expand Down
Loading
Loading