From 53981f109c6f903ea5409640c6832c3bbf68bc32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:22:38 +0000 Subject: [PATCH 1/3] Start fix for #1587 Assisted-by: Claude Code:claude-opus-5-5 From fcf64b0b5a40dae69435aa749edefb1fdc42ce2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:39:36 +0000 Subject: [PATCH 2/3] Contest VEX for bare requirements wheel paths A requirements.txt line such as `wheels/six-1.16.0-py3-none-any.whl`, `six-1.16.0-py3-none-any.whl` or `dist/six-1.16.0.tar.gz` is installed by pip as a local, unpatched artifact. VEX discovery only treated a line as a path when it began with `.`, `/` or another non-alphanumeric character, so these spellings gave no evidence and `vex` attested not_affected for a Socket wiring of the same release in Pipfile.lock. Read a line as a path the way pip does: it holds a path separator, starts with something other than a letter or digit, or names an archive (`.whl`, `.tar.gz`, `.zip`, ...). `name@` stays a PEP 508 direct reference, as in pip. Fixes #1587 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vex_lockfile/pipenv.rs | 13 ++ .../src/vex/discover/pypi_other.rs | 140 +++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs index f922cfb4e..0b1ee43af 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs @@ -363,6 +363,19 @@ fn requirements_direct_reference_contests_the_lock_wiring() { "bare wheel path", format!("./wheels/{NAME}-{VERSION}-py3-none-any.whl\n"), ), + // #1587: pip installs these relative spellings too. + ( + "wheel path without ./", + format!("wheels/{NAME}-{VERSION}-py3-none-any.whl\n"), + ), + ( + "wheel filename only", + format!("{NAME}-{VERSION}-py3-none-any.whl\n"), + ), + ( + "sdist path without ./", + format!("dist/{NAME}-{VERSION}.tar.gz\n"), + ), ("registry pin (control)", format!("{NAME}=={VERSION}\n")), ], ); diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index c097ad022..3a0b62146 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -359,7 +359,7 @@ fn requirement_location(code: &str) -> Option<(Option<&str>, &str)> { if first.contains("://") || first.starts_with("file:") || mentions_vendor_dir(first) - || !first.starts_with(|c: char| c.is_ascii_alphanumeric()) + || pip_reads_as_path(first) { // pip separates a marker from a URL with `; ` (whitespace-split // above leaves a trailing `;`) and from a path with a bare `;`. @@ -374,6 +374,52 @@ fn requirement_location(code: &str) -> Option<(Option<&str>, &str)> { Some((Some(name), location)) } +/// Whether pip reads a requirement's first token (not a url) as a local +/// path or archive (`pip._internal.req.constructors._get_url_from_path`), +/// not a registry name: it starts with something other than a letter or +/// digit, holds a path separator, or names an archive (#1587). As in +/// pip, `name@` stays a PEP 508 direct reference unless the part +/// before the `@` itself looks like a path. +fn pip_reads_as_path(first: &str) -> bool { + let path = first.split(';').next().unwrap_or(first); + if !path.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return true; + } + let looks_like_path = |s: &str| s.starts_with('.') || s.contains(['/', '\\']); + if let Some((prefix, _)) = path.split_once('@') { + if !looks_like_path(prefix) { + return false; + } + } + // pip strips `[extras]` before testing the archive suffix. + let bare = match path.strip_suffix(']').and_then(|p| p.rfind('[')) { + Some(open) => &path[..open], + None => path, + }; + looks_like_path(path) || is_archive_file(bare) +} + +/// pip's `is_archive_file`: a name ending in one of its archive suffixes, +/// compared case-insensitively. +fn is_archive_file(name: &str) -> bool { + const ARCHIVE_EXTENSIONS: &[&str] = &[ + ".zip", + ".whl", + ".tar.bz2", + ".tbz", + ".tar.xz", + ".txz", + ".tlz", + ".tar.lz", + ".tar.lzma", + ".tar.gz", + ".tgz", + ".tar", + ]; + let name = name.to_ascii_lowercase(); + ARCHIVE_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) +} + /// `name[extras] @ [ ; marker]` → `(name, location)`. fn pep508_direct_reference(spec: &str) -> Option<(&str, &str)> { let spec = spec.trim_start(); @@ -1740,6 +1786,35 @@ mod tests { ("Pipfile.lock", vendored_pipfile.clone()), ("requirements.txt", format!("./wheels/{WHL}\n")), ), + // #1587: pip installs a relative path without a leading `./`. + ( + "requirements wheel path without ./", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", format!("wheels/{WHL}\n")), + ), + ( + "requirements wheel filename only", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", format!("{WHL}\n")), + ), + ( + "requirements sdist path without ./", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", "dist/six-1.16.0.tar.gz\n".to_string()), + ), + ( + "requirements windows wheel path without .\\", + ("Pipfile.lock", vendored_pipfile.clone()), + ("requirements.txt", format!("wheels\\{WHL}\n")), + ), + ( + "requirements wheel path with a marker", + ("Pipfile.lock", vendored_pipfile.clone()), + ( + "requirements.txt", + format!("wheels/{WHL}; python_version >= \"3\"\n"), + ), + ), ]; for (name, wired, other) in cases { let p = Project::new(); @@ -1770,6 +1845,69 @@ mod tests { } } + /// REGRESSION (#1587): a requirements line is a local artifact the way + /// pip reads one (`_looks_like_path` / `is_archive_file`): a path + /// separator, a leading `.`, or an archive suffix. Registry specifiers + /// and PEP 508 `name @ url` lines keep their old reading. + #[test] + fn issue_1587_requirement_location_reads_paths_as_pip_does() { + for (line, location) in [ + ( + "./wheels/six-1.16.0-py3-none-any.whl", + "./wheels/six-1.16.0-py3-none-any.whl", + ), + ( + "wheels/six-1.16.0-py3-none-any.whl", + "wheels/six-1.16.0-py3-none-any.whl", + ), + ( + "wheels\\six-1.16.0-py3-none-any.whl", + "wheels\\six-1.16.0-py3-none-any.whl", + ), + ("six-1.16.0-py3-none-any.whl", "six-1.16.0-py3-none-any.whl"), + ("six-1.16.0-py3-none-any.WHL", "six-1.16.0-py3-none-any.WHL"), + ("dist/six-1.16.0.tar.gz", "dist/six-1.16.0.tar.gz"), + ("six-1.16.0.tar.gz --hash=sha256:ab", "six-1.16.0.tar.gz"), + ("six-1.16.0.zip", "six-1.16.0.zip"), + ("six-1.16.0.tar.bz2", "six-1.16.0.tar.bz2"), + ("six-1.16.0.tgz", "six-1.16.0.tgz"), + ( + "wheels/six-1.16.0-py3-none-any.whl; python_version >= \"3\"", + "wheels/six-1.16.0-py3-none-any.whl", + ), + ( + "wheels/six-1.16.0-py3-none-any.whl;python_version>=\"3\"", + "wheels/six-1.16.0-py3-none-any.whl", + ), + ("src/six", "src/six"), + ] { + assert_eq!( + super::requirement_location(line), + Some((None, location)), + "{line}" + ); + } + for line in [ + "six==1.16.0", + "six>=1", + "six", + "six[socks]==1.16.0", + "six; python_version < \"3\"", + "six==1.16.0 --hash=sha256:ab", + ] { + assert_eq!(super::requirement_location(line), None, "{line}"); + } + assert_eq!( + super::requirement_location("six @ https://h/six-1.16.0-py3-none-any.whl"), + Some((Some("six"), "https://h/six-1.16.0-py3-none-any.whl")), + ); + // pip reads `name@` as a PEP 508 reference, not a path. + assert_eq!( + super::requirement_location("six@six-1.16.0-py3-none-any.whl"), + Some((Some("six"), "six-1.16.0-py3-none-any.whl")), + ); + } + /// REGRESSION (#1549): a non-Socket direct reference whose version /// can't be read (a source directory, a VCS url, an artifact named /// for no release) may install any version of its package, so it From 3a4de37406ed368b3d7490e7030612e67c713b80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:48:37 +0000 Subject: [PATCH 3/3] Share pip's path rule in the requirements grammar Move the "pip installs this as a local artifact" rule into utils::requirements beside the shared requirements grammar, and follow pip more closely: - `six@https://...` (no spaces) is a PEP 508 reference to six, not a bare url, as pip reads it. - A path's `[extras]` are cut before its filename is read, so `wheels/six-1.16.0-...whl[socks]` also contests the wiring. - The archive test splits `.tar.` the way pip's splitext does. Refs #1587 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/requirements.rs | 114 ++++++++++++++++++ .../src/vex/discover/pypi_other.rs | 73 +++-------- 2 files changed, 133 insertions(+), 54 deletions(-) diff --git a/crates/socket-patch-core/src/utils/requirements.rs b/crates/socket-patch-core/src/utils/requirements.rs index 7809d367e..cc7384db3 100644 --- a/crates/socket-patch-core/src/utils/requirements.rs +++ b/crates/socket-patch-core/src/utils/requirements.rs @@ -630,6 +630,81 @@ pub(crate) fn archive_filename_coords(filename: &str) -> Option<(&str, &str)> { (!dist.is_empty() && !version.is_empty()).then_some((dist, version)) } +/// pip's `_looks_like_path`: a path separator, or a leading `.`. `\\` +/// counts on every OS, as it does on Windows. +fn looks_like_path(s: &str) -> bool { + s.starts_with('.') || s.contains(['/', '\\']) +} + +/// Whether a requirement token (not a url) is a no-space PEP 508 direct +/// reference, `name@`: it starts with a name and the part before +/// its first `@` neither looks like a path nor has a url scheme. pip reads +/// `six@https://…` and `six@wheels/six-….whl` this way, not as a path or +/// url (`_get_url_from_path`, #1587). +pub(crate) fn names_before_at(token: &str) -> bool { + token.starts_with(|c: char| c.is_ascii_alphanumeric()) + && token + .split_once('@') + .is_some_and(|(prefix, _)| !looks_like_path(prefix) && !prefix.contains(':')) +} + +/// Whether pip installs a requirement token (not a url, marker after `;` +/// allowed) as a local path or archive, not a registry name +/// (`pip._internal.req.constructors._get_url_from_path`, #1587): it starts +/// with something other than a letter or digit, holds a path separator, or +/// names an archive ([`is_archive_file`], `[extras]` stripped). A +/// [`names_before_at`] token is a PEP 508 reference instead. +pub(crate) fn pip_reads_as_path(token: &str) -> bool { + let path = token.split(';').next().unwrap_or(token); + if !path.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return true; + } + !names_before_at(path) && (looks_like_path(path) || is_archive_file(strip_path_extras(path))) +} + +/// A local path requirement without its trailing `[extras]`, as pip's +/// `_strip_extras` reads it (`wheels/six-….whl[socks]`). +pub(crate) fn strip_path_extras(path: &str) -> &str { + match path.strip_suffix(']').and_then(|p| p.rfind('[')) { + Some(open) if open > 0 && open + 2 < path.len() => &path[..open], + _ => path, + } +} + +/// pip's `ARCHIVE_EXTENSIONS`: the suffixes `is_archive_file` reads as a +/// local archive. [`archive_filename_coords`] reads a release from the +/// wheel and common sdist ones only. +const ARCHIVE_EXTENSIONS: &[&str] = &[ + ".zip", + ".whl", + ".tar.bz2", + ".tbz", + ".tar.xz", + ".txz", + ".tlz", + ".tar.lz", + ".tar.lzma", + ".tar.gz", + ".tgz", + ".tar", +]; + +/// pip's `is_archive_file`: the name's extension, as pip's `splitext` +/// splits it (`.tar` joins the extension after it), is an archive one, +/// case-insensitively. +pub(crate) fn is_archive_file(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + let leaf = name.rsplit(['/', '\\']).next().unwrap_or(&name); + let Some(dot) = leaf.rfind('.') else { + return false; + }; + let mut ext = &leaf[dot..]; + if leaf[..dot].ends_with(".tar") { + ext = &leaf[dot - 4..]; + } + ARCHIVE_EXTENSIONS.contains(&ext) +} + /// The pin in an artifact location's `#sha256=` fragment (pip's /// `#sha256=…`, Poetry lock 1.0's `#sha256=&`): the FIRST /// `sha256=` parameter after the first `#`, when it is 64 hex (lowercased). @@ -919,6 +994,45 @@ mod tests { assert_eq!(lines[1].text, "six==1.0"); } + /// #1587: pip's reading of a requirement token as a local artifact. + #[test] + fn pip_reads_as_path_follows_pip() { + for token in [ + "./wheels/six-1.16.0-py3-none-any.whl", + "wheels/six-1.16.0-py3-none-any.whl", + "wheels\\six-1.16.0-py3-none-any.whl", + "six-1.16.0-py3-none-any.whl", + "six-1.16.0-py3-none-any.whl[socks]", + "dist/six-1.16.0.tar.gz", + "six-1.16.0.tar.gz;python_version>=\"3\"", + "six-1.16.0.tgz", + "six-1.16.0.tar", + "src/six", + "${WHEELS}/six.whl", + ] { + assert!(pip_reads_as_path(token), "{token}"); + } + for token in [ + "six", + "six==1.16.0", + "zope.interface", + "six[socks]==1.16.0", + "six;python_version<\"3\"", + "six@wheels/six-1.16.0-py3-none-any.whl", + "six@six-1.16.0-py3-none-any.whl", + "foo.tar.zip", + ] { + assert!(!pip_reads_as_path(token), "{token}"); + } + assert!(names_before_at("six@https://h/six.whl")); + assert!(!names_before_at("https://u@h/six.whl")); + assert!(!names_before_at("wheels/x@1.whl")); + assert!(!names_before_at("six==1.0")); + assert_eq!(strip_path_extras("six.whl[socks]"), "six.whl"); + assert_eq!(strip_path_extras("six.whl[]"), "six.whl[]"); + assert_eq!(strip_path_extras("six.whl"), "six.whl"); + } + #[test] fn archive_filename_coords_reads_wheels_and_sdists() { assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index 3a0b62146..cf5b4d4d2 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -117,6 +117,7 @@ use super::{ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::digest::sha256_hex; use crate::utils::hatch::dependency_specs; +use crate::utils::requirements; use crate::utils::requirements::{ exact_pin, hash_options, logical_lines, split_comment, url_sha256_fragment, vendor_tag, }; @@ -356,17 +357,19 @@ fn registry_pin(code: &str) -> Option { fn requirement_location(code: &str) -> Option<(Option<&str>, &str)> { let code = code.trim_start(); let first = code.split_whitespace().next()?; - if first.contains("://") - || first.starts_with("file:") - || mentions_vendor_dir(first) - || pip_reads_as_path(first) + if !requirements::names_before_at(first) + && (first.contains("://") + || first.starts_with("file:") + || mentions_vendor_dir(first) + || requirements::pip_reads_as_path(first)) { // pip separates a marker from a URL with `; ` (whitespace-split - // above leaves a trailing `;`) and from a path with a bare `;`. + // above leaves a trailing `;`) and from a path with a bare `;`, + // and strips a path's `[extras]` before reading it. let location = if first.contains("://") { first.trim_end_matches(';') } else { - first.split(';').next().unwrap_or(first) + requirements::strip_path_extras(first.split(';').next().unwrap_or(first)) }; return Some((None, location)); } @@ -374,52 +377,6 @@ fn requirement_location(code: &str) -> Option<(Option<&str>, &str)> { Some((Some(name), location)) } -/// Whether pip reads a requirement's first token (not a url) as a local -/// path or archive (`pip._internal.req.constructors._get_url_from_path`), -/// not a registry name: it starts with something other than a letter or -/// digit, holds a path separator, or names an archive (#1587). As in -/// pip, `name@` stays a PEP 508 direct reference unless the part -/// before the `@` itself looks like a path. -fn pip_reads_as_path(first: &str) -> bool { - let path = first.split(';').next().unwrap_or(first); - if !path.starts_with(|c: char| c.is_ascii_alphanumeric()) { - return true; - } - let looks_like_path = |s: &str| s.starts_with('.') || s.contains(['/', '\\']); - if let Some((prefix, _)) = path.split_once('@') { - if !looks_like_path(prefix) { - return false; - } - } - // pip strips `[extras]` before testing the archive suffix. - let bare = match path.strip_suffix(']').and_then(|p| p.rfind('[')) { - Some(open) => &path[..open], - None => path, - }; - looks_like_path(path) || is_archive_file(bare) -} - -/// pip's `is_archive_file`: a name ending in one of its archive suffixes, -/// compared case-insensitively. -fn is_archive_file(name: &str) -> bool { - const ARCHIVE_EXTENSIONS: &[&str] = &[ - ".zip", - ".whl", - ".tar.bz2", - ".tbz", - ".tar.xz", - ".txz", - ".tlz", - ".tar.lz", - ".tar.lzma", - ".tar.gz", - ".tgz", - ".tar", - ]; - let name = name.to_ascii_lowercase(); - ARCHIVE_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) -} - /// `name[extras] @ [ ; marker]` → `(name, location)`. fn pep508_direct_reference(spec: &str) -> Option<(&str, &str)> { let spec = spec.trim_start(); @@ -1865,7 +1822,6 @@ mod tests { "wheels\\six-1.16.0-py3-none-any.whl", ), ("six-1.16.0-py3-none-any.whl", "six-1.16.0-py3-none-any.whl"), - ("six-1.16.0-py3-none-any.WHL", "six-1.16.0-py3-none-any.WHL"), ("dist/six-1.16.0.tar.gz", "dist/six-1.16.0.tar.gz"), ("six-1.16.0.tar.gz --hash=sha256:ab", "six-1.16.0.tar.gz"), ("six-1.16.0.zip", "six-1.16.0.zip"), @@ -1901,7 +1857,16 @@ mod tests { super::requirement_location("six @ https://h/six-1.16.0-py3-none-any.whl"), Some((Some("six"), "https://h/six-1.16.0-py3-none-any.whl")), ); - // pip reads `name@` as a PEP 508 reference, not a path. + // pip reads `name@` as a PEP 508 reference, not a path + // or url, and a path's `[extras]` are not part of it. + assert_eq!( + super::requirement_location("six@https://h/six-1.16.0-py3-none-any.whl"), + Some((Some("six"), "https://h/six-1.16.0-py3-none-any.whl")), + ); + assert_eq!( + super::requirement_location("wheels/six-1.16.0-py3-none-any.whl[socks]"), + Some((None, "wheels/six-1.16.0-py3-none-any.whl")), + ); assert_eq!( super::requirement_location("six@six-1.16.0-py3-none-any.whl"), Some((Some("six"), "six-1.16.0-py3-none-any.whl")),