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
49 changes: 49 additions & 0 deletions crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,55 @@ async fn pypi_requirements_hosted_rewrites_pinned_line() {
});
}

/// #475: pip resolves `==` under PEP 440, so `requests==2.31` (and
/// `==2.31.0.0`, `==02.31.0`) installs exactly the patched 2.31.0. The
/// hosted grant must rewrite each such pin instead of reporting "no entry"
/// and exiting 0 with the project unpatched.
#[tokio::test]
#[serial]
async fn pypi_requirements_hosted_rewrites_pep440_equivalent_pin() {
const UUID: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a2";
const PURL: &str = "pkg:pypi/requests@2.31.0";
const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let url = format!(
"http://patch.test/patch/pypi/requests/2.31.0/{TOKEN}/{UUID}/requests-2.31.0-py3-none-any.whl"
);

for pin in ["requests==2.31", "requests==2.31.0.0", "Requests==02.31.0"] {
let server = MockServer::start().await;
mock_view(&server, UUID, PURL).await;
mock_reference(
&server,
UUID,
PURL,
&url,
serde_json::json!({ "sha256": SHA256 }),
serde_json::Value::Null,
)
.await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("requirements.txt"),
format!("flask==2.0.1\n{pin}\n"),
)
.unwrap();

let code =
socket_patch_cli::commands::get::run(get_hosted_args(UUID, tmp.path(), server.uri()))
.await;
assert_eq!(code, 0, "{pin}: get <uuid> --mode hosted should succeed");

let reqs = std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap();
assert_eq!(
reqs,
format!("flask==2.0.1\nrequests @ {url} --hash=sha256:{SHA256}\n"),
"{pin}: the PEP 440-equivalent pin must be redirected"
);
assert_no_manifest_no_blobs(tmp.path());
}
}

// ---------------------------------------------------------------------------
// maven — pom.xml fail-closed suffixed-version pin (rewrite_maven_pom)
// ---------------------------------------------------------------------------
Expand Down
61 changes: 56 additions & 5 deletions crates/socket-patch-core/src/patch/redirect/requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ fn without_hashes(text: &str) -> String {
}

enum RequirementVersion {
/// `==X` (PEP 440 equality), or a direct reference whose archive names X.
Exact(String),
/// `===X`: arbitrary equality, a plain string comparison.
Arbitrary(String),
Unpinned,
Ambiguous,
}
Expand Down Expand Up @@ -166,14 +169,21 @@ fn requirement_version(specifier: &str, name_re: &Regex, name: &str) -> Requirem
return archive_version(location.trim(), name)
.map_or(RequirementVersion::Ambiguous, RequirementVersion::Exact);
}
if let Some(version) = tail.strip_prefix("===").or_else(|| tail.strip_prefix("==")) {
let version = version.trim();
let (arbitrary, version) = match tail.strip_prefix("===") {
Some(version) => (true, Some(version)),
None => (false, tail.strip_prefix("==")),
};
if let Some(version) = version.map(str::trim) {
if !version.is_empty()
&& !version
.chars()
.any(|character| character.is_whitespace() || ",*<>=~".contains(character))
{
return RequirementVersion::Exact(version.to_string());
return if arbitrary {
RequirementVersion::Arbitrary(version.to_string())
} else {
RequirementVersion::Exact(version.to_string())
};
}
}
RequirementVersion::Ambiguous
Expand Down Expand Up @@ -245,8 +255,14 @@ pub(super) fn rewrite(
(&cleaned[..index], &cleaned[index..])
});
match requirement_version(specifier, &name_re, &target) {
RequirementVersion::Exact(version) if version != dep.version => continue,
RequirementVersion::Exact(_) => {}
// pip resolves `==` under PEP 440 (`==1.16` installs 1.16.0).
RequirementVersion::Exact(version)
if !crate::utils::pep440::versions_equal(&version, &dep.version) =>
{
continue
}
RequirementVersion::Arbitrary(version) if version != dep.version => continue,
RequirementVersion::Exact(_) | RequirementVersion::Arbitrary(_) => {}
RequirementVersion::Unpinned
if row_counts.get(&target) == Some(&1)
&& override_versions
Expand Down Expand Up @@ -388,6 +404,41 @@ mod tests {
assert!(rerun.warnings.is_empty());
}

/// #475: pip resolves `==2.28`, `==2.28.1.0` and `==02.28.1` under
/// PEP 440, so each pins exactly the patched 2.28.1 and is rewritten.
#[test]
fn pep440_equivalent_pins_are_rewritten() {
let mut short = patch();
short.version = "2.28.0".into();
for (source, dep) in [
("requests==2.28\n", short.clone()),
("requests==2.28.1.0\n", patch()),
("Requests==02.28.1\n", patch()),
("requests == 2.28.01 ; python_version >= \"3.7\"\n", patch()),
] {
let result = rewrite_registry_redirect(&input(source), std::slice::from_ref(&dep));
assert!(
result.warnings.is_empty(),
"{source}: {:?}",
result.warnings
);
assert!(
result.files["requirements.txt"].contains(&format!(" @ {URL}")),
"{source}"
);
assert!(result
.confirmed_requirements_uuids
.contains(&dep.patch_uuid));
}
// A different release is still not this patch's entry.
let result = rewrite_registry_redirect(&input("requests==2.28.1.1\n"), &[patch()]);
assert!(result.files.is_empty());
assert_eq!(
result.warnings[0].code,
"redirect_requirements_entry_not_found"
);
}

#[test]
fn markers_after_hashes_and_hash_text_in_quoted_markers_are_preserved() {
let source = "requests==2.28.1 --hash=sha256:OLD ; platform_version != \"text --hash=keep # retained\"\n";
Expand Down
43 changes: 42 additions & 1 deletion crates/socket-patch-core/src/utils/hatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result<Optio
"{name}: an existing direct source must be reverted before patching"
));
}
if constraint != format!("=={version}") {
if !crate::utils::pep440::is_exact_pin_of(&constraint, version) {
return Err(format!(
"{name}: Hatch patching requires an exact =={version} declaration"
));
Expand Down Expand Up @@ -510,6 +510,47 @@ mod tests {
}
}

/// #475: Hatch (via pip/uv) selects a release under PEP 440, so
/// `==1.26.18.0` and `==01.26.18` are exact pins of 1.26.18.
#[test]
fn pep440_equivalent_pins_are_exact_declarations() {
for pin in [
"urllib3==1.26.18.0",
"urllib3 == 01.26.18",
"Urllib3==v1.26.18",
] {
let text = format!("[project]\ndependencies=[\"{pin}\"]\n");
let result = rewrite(
&files(&text),
"urllib3",
"1.26.18",
"https://patch.test/a.whl",
)
.unwrap_or_else(|error| panic!("{pin}: {error}"));
assert!(
result["pyproject.toml"].contains("@ https://patch.test/a.whl"),
"{pin}"
);
}
for pin in [
"urllib3==1.26.18.1",
"urllib3===1.26.18.0",
"urllib3==1.26.*",
] {
let text = format!("[project]\ndependencies=[\"{pin}\"]\n");
assert!(
rewrite(
&files(&text),
"urllib3",
"1.26.18",
"https://patch.test/a.whl"
)
.is_err(),
"{pin}"
);
}
}

#[test]
fn external_tables_override_inline_and_metadata_permissions() {
let mut inputs = files("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3>=0\"]\n");
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod notice;
pub(crate) mod http;
pub(crate) mod line_endings;
pub mod pdm_lock;
pub(crate) mod pep440;
pub mod pipenv;
pub mod poetry_lock;
pub mod process;
Expand Down
Loading
Loading