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
13 changes: 13 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
],
);
Expand Down
114 changes: 114 additions & 0 deletions crates/socket-patch-core/src/utils/requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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@<location>`: 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=<hex>` fragment (pip's
/// `<url>#sha256=…`, Poetry lock 1.0's `<url>#sha256=<hex>&`): the FIRST
/// `sha256=` parameter after the first `#`, when it is 64 hex (lowercased).
Expand Down Expand Up @@ -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!(
Expand Down
115 changes: 109 additions & 6 deletions crates/socket-patch-core/src/vex/discover/pypi_other.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -356,17 +357,19 @@ fn registry_pin(code: &str) -> Option<String> {
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));
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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@<location>` 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
Expand Down
Loading