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/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 c097ad022..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) - || !first.starts_with(|c: char| c.is_ascii_alphanumeric()) + 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)); } @@ -1740,6 +1743,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 +1802,77 @@ 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"), + ("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 + // 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")), + ); + } + /// 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