diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index 86a9cecf3467..0619d9e2a192 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -657,8 +657,13 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool { // Expand any environment variables in the path. let expanded = expand_env_vars(url); + // Strip extras. + let url = split_extras(&expanded) + .map(|(url, _)| url) + .unwrap_or(&expanded); + // Analyze the path. - let mut chars = expanded.chars(); + let mut chars = url.chars(); let Some(first_char) = chars.next() else { return false; @@ -670,18 +675,47 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool { } // Ex) `https://` or `C:` - if split_scheme(&expanded).is_some() { + if split_scheme(url).is_some() { return true; } // Ex) `foo/bar` - if expanded.contains('/') || expanded.contains('\\') { + if url.contains('/') || url.contains('\\') { + return true; + } + + // Ex) `foo.tar.gz` + if looks_like_archive(url) { return true; } false } +/// Returns `true` if a file looks like an archive. +/// +/// See +/// for the list of supported archive extensions. +fn looks_like_archive(file: impl AsRef) -> bool { + let file = file.as_ref(); + + // E.g., `gz` in `foo.tar.gz` + let Some(extension) = file.extension().and_then(|ext| ext.to_str()) else { + return false; + }; + + // E.g., `tar` in `foo.tar.gz` + let pre_extension = file + .file_stem() + .and_then(|stem| Path::new(stem).extension().and_then(|ext| ext.to_str())); + + matches!( + (pre_extension, extension), + (_, "whl" | "tbz" | "txz" | "tlz" | "zip" | "tgz" | "tar") + | (Some("tar"), "bz2" | "xz" | "lz" | "lzma" | "gz") + ) +} + /// parses extras in the `[extra1,extra2] format` fn parse_extras_cursor( cursor: &mut Cursor, @@ -970,7 +1004,9 @@ fn parse_pep508_requirement( // wsp* cursor.eat_whitespace(); // name + let name_start = cursor.pos(); let name = parse_name(cursor)?; + let name_end = cursor.pos(); // wsp* cursor.eat_whitespace(); // extras? @@ -1018,6 +1054,23 @@ fn parse_pep508_requirement( let requirement_end = cursor.pos(); + // If the requirement consists solely of a package name, and that name appears to be an archive, + // treat it as a URL requirement, for consistency and security. (E.g., `requests-2.26.0.tar.gz` + // is a valid Python package name, but we should treat it as a reference to a file.) + // + // See: https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8 + if requirement_kind.is_none() { + if looks_like_archive(cursor.slice(name_start, name_end)) { + let clone = cursor.clone().at(start); + return Err(Pep508Error { + message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).".to_string()), + start, + len: clone.pos() - start, + input: clone.to_string(), + }); + } + } + // wsp* cursor.eat_whitespace(); // quoted_marker? diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index bdb35d7831e1..2fe240b4395a 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -2163,6 +2163,364 @@ mod test { Ok(()) } + #[tokio::test] + #[cfg(not(windows))] + async fn archive_requirement() -> Result<()> { + let temp_dir = assert_fs::TempDir::new()?; + + let requirements_txt = temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc! {r" + # Archive name that's also a valid Python package name. + importlib_metadata-8.3.0-py3-none-any.whl + + # Archive name that's also a valid Python package name, with markers. + importlib_metadata-8.2.0-py3-none-any.whl ; sys_platform == 'win32' + + # Archive name that's also a valid Python package name, with extras. + importlib_metadata-8.2.0-py3-none-any.whl[extra] + + # Archive name that's not a valid Python package name. + importlib_metadata-8.2.0+local-py3-none-any.whl + + # Archive name that's not a valid Python package name, with markers. + importlib_metadata-8.2.0+local-py3-none-any.whl ; sys_platform == 'win32' + + # Archive name that's not a valid Python package name, with extras. + importlib_metadata-8.2.0+local-py3-none-any.whl[extra] + "})?; + + let requirements = RequirementsTxt::parse( + requirements_txt.path(), + temp_dir.path(), + &BaseClientBuilder::new(), + ) + .await + .unwrap(); + + insta::with_settings!({ + filters => path_filters(&path_filter(temp_dir.path())), + }, { + insta::assert_debug_snapshot!(requirements, @r###" + RequirementsTxt { + requirements: [ + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.3.0-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.3.0-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.3.0-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.3.0-py3-none-any.whl", + ), + }, + }, + extras: [], + marker: true, + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.2.0-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.2.0-py3-none-any.whl", + ), + }, + }, + extras: [], + marker: sys_platform == 'win32', + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.2.0-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.2.0-py3-none-any.whl", + ), + }, + }, + extras: [ + ExtraName( + "extra", + ), + ], + marker: true, + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.2.0+local-py3-none-any.whl", + ), + }, + }, + extras: [], + marker: true, + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.2.0+local-py3-none-any.whl", + ), + }, + }, + extras: [], + marker: sys_platform == 'win32', + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + RequirementEntry { + requirement: Unnamed( + UnnamedRequirement { + url: VerbatimParsedUrl { + parsed_url: Path( + ParsedPathUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + install_path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + ext: Wheel, + }, + ), + verbatim: VerbatimUrl { + url: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/importlib_metadata-8.2.0+local-py3-none-any.whl", + query: None, + fragment: None, + }, + given: Some( + "importlib_metadata-8.2.0+local-py3-none-any.whl", + ), + }, + }, + extras: [ + ExtraName( + "extra", + ), + ], + marker: true, + origin: Some( + File( + "/requirements.txt", + ), + ), + }, + ), + hashes: [], + }, + ], + constraints: [], + editables: [], + index_url: None, + extra_index_urls: [], + find_links: [], + no_index: false, + no_binary: None, + only_binary: None, + } + "###); + }); + + Ok(()) + } + #[tokio::test] async fn parser_error_line_and_column() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?;