Skip to content

fix(utils): reject path separators in wheel/sdist filenames - #1337

Open
r266-tech wants to merge 6 commits into
pypa:mainfrom
r266-tech:r266/pylock-url-filename-component
Open

fix(utils): reject path separators in wheel/sdist filenames#1337
r266-tech wants to merge 6 commits into
pypa:mainfrom
r266-tech:r266/pylock-url-filename-component

Conversation

@r266-tech

@r266-tech r266-tech commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • reject /, \\, and NUL characters in parse_wheel_filename() and parse_sdist_filename()
  • keep PackageWheel.filename and PackageSdist.filename non-raising for manually constructed objects
  • cover the public parsers directly and verify that percent-decoded pylock URL filenames flow through the existing package validation

Why

#1314 correctly percent-decodes the final URL path component, including valid names such as %2B local versions. The same decoding can also produce path-shaped values such as example/../evil-1.0.tar.gz.

The public filename parsers are the canonical validation boundary, and pylock already delegates wheel/sdist validation to them. Rejecting separators and NUL there protects pylock and other downstream callers consistently while preserving non-raising filename property access.

Testing

  • .venv/bin/python -m pytest tests/test_utils.py tests/test_pylock.py -q (148 passed)
  • uvx ruff check src/packaging/utils.py src/packaging/pylock.py tests/test_utils.py tests/test_pylock.py
  • uvx ruff format --check src/packaging/utils.py src/packaging/pylock.py tests/test_utils.py tests/test_pylock.py
  • git diff --check

Comment thread src/packaging/pylock.py Outdated
# The last path component is percent-encoded, so decode it to the file name
return unquote(url_path.rsplit("/", 1)[-1])
filename = unquote(url_path.rsplit("/", 1)[-1])
if "/" in filename or "\\" in filename or "\0" in filename:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And/or, reject os.sep, os.altsep ? That would make the behaviour platform dependent, though, so maybe not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking it should not be platform dependent? Not following closely though.

@sbidoul

sbidoul commented Jul 18, 2026

Copy link
Copy Markdown
Member

Please dismiss my approval. I think the filename property access should not raise. This should be handled at validation time.

@r266-tech

Copy link
Copy Markdown
Contributor Author

Moved the separator/NUL check to parsing validation in b55ec2d.

PackageSdist.filename and PackageWheel.filename now remain non-raising for manually constructed objects, while _from_dict() (and therefore Pylock.from_dict() / validate()) rejects decoded URL filenames that are not a single component. The focused property/validation tests pass for both distribution types, along with Ruff and diff checks.

I cannot dismiss another user's approval as an external contributor, but the requested behavioral change is now on the branch.

@sbidoul

sbidoul commented Jul 19, 2026

Copy link
Copy Markdown
Member

Actually, we already call parse_wheel_filename and parse_sdist_filename in Package validation. So maybe these functions should be expanded to catch these cases? They are supposed to return a NormalizedName but don't in such case.

So I suggest closing this as it will be addressed by #873.

@sbidoul

sbidoul commented Jul 19, 2026

Copy link
Copy Markdown
Member

parse_sdist_filename accepts a/b-1.0.tar.gz.

parse_wheel_filename correctly raises InvalidWheelFilename on a/b-1.0-py3-non-any.whl.

@sbidoul sbidoul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a pylock-specific test, but the validation must be done in parse_wheel_filename (should be ok) and parse_sdist_filename.

@r266-tech
r266-tech force-pushed the r266/pylock-url-filename-component branch from b55ec2d to 3636593 Compare July 19, 2026 16:38
@r266-tech

Copy link
Copy Markdown
Contributor Author

Reworked in 3636593 along the parser boundary you requested.

  • moved separator/NUL rejection into parse_wheel_filename() and parse_sdist_filename();
  • removed the pylock-specific validation helper, so PackageWheel.filename / PackageSdist.filename remain non-raising;
  • added direct parser regressions plus pylock integration coverage for percent-decoded /, \\, and NUL values.

Focused verification: 148 tests passed, Ruff check/format passed, and git diff --check passed.

@henryiii henryiii changed the title fix(pylock): reject path separators decoded from artifact URLs fix(utils): reject path separators in wheel/sdist filenames Jul 20, 2026
Comment thread src/packaging/utils.py Outdated
"""
if "/" in filename or "\\" in filename or "\0" in filename:
raise InvalidWheelFilename(f"Invalid wheel filename: {filename!r}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is unnecessary. Something in this function already catches this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept the whole-filename guard after finding that the existing parser accepts these characters outside the project-name field—for example, example-1.0-1/../evil-py3-none-any.whl parses with /../evil as the build suffix, and separators are also accepted in tag components. a50fa5e moves the regression cases into those build/tag positions so the guard's necessity is covered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting find. Still I would rather address that by refining parse_tag() and _build_tag_regex. I've not checked, but surely the specification for these things do not allow arbitrary characters?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented that parser-boundary approach in fcfaa6f.

  • parse_tag() now rejects non-ASCII or otherwise invalid interpreter/ABI/platform components;
  • _build_tag_regex now uses an ASCII-digit prefix plus fullmatch(), rejecting /, \\, and NUL while preserving the existing free-form suffix behavior (covered by a positive 1+vendor case);
  • parse_sdist_filename() continues to validate the name through canonicalize_name(..., validate=True) rather than a separate character list.

I removed the whole-wheel-filename guard, rebased the unchanged patch onto current main, and reran the focused parser/Pylock suite: 384 tests passed, with Ruff and diff checks clean. The full suite on the same patch also passed previously (62,360 passed, 1 platform skip).

Comment thread src/packaging/utils.py Outdated
.. _Source distribution format: https://packaging.python.org/specifications/source-distribution-format/#source-distribution-file-name
"""
if "/" in filename or "\\" in filename or "\0" in filename:
raise InvalidSdistFilename(f"Invalid sdist filename: {filename!r}")

@sbidoul sbidoul Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If hard coding these characters is unnecessary for wheel filenames, then there is probably a better way to do it for sdist filenames.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the sdist character list with canonicalize_name(name_part, validate=True), translating InvalidName to InvalidSdistFilename. a50fa5e adds general invalid-name/cause coverage plus path and %00 Pylock cases.

@r266-tech
r266-tech force-pushed the r266/pylock-url-filename-component branch from a50fa5e to fcfaa6f Compare July 21, 2026 01:02
Comment thread src/packaging/utils.py
try:
name = canonicalize_name(name_part, validate=True)
except InvalidName as e:
raise InvalidSdistFilename(f"Invalid project name: {filename!r}") from e

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise InvalidSdistFilename(f"Invalid project name: {filename!r}") from e
raise InvalidSdistFilename(f"Invalid sdist filename (invalid project name): {filename!r}") from e

Comment thread src/packaging/tags.py
"""


_tag_component_regex = re.compile(r"\w+", re.ASCII)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP 425 says The platform tag is simply distutils.util.get_platform() with all hyphens - and periods . replaced with underscore _. In its FAQ it has Why normalise hyphens and other non-alphanumeric characters to underscores?.

So I suppose this regex is ok?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so based on the definition of \w from the re docs.

Comment thread src/packaging/tags.py
raise InvalidTag(f"Tag {tag!r} has an empty component: {component!r}")
for part in parts:
if _tag_component_regex.fullmatch(part) is None:
raise InvalidTag(f"Tag {tag!r} has an invalid component: {part!r}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise InvalidTag(f"Tag {tag!r} has an invalid component: {part!r}")
component = ".".join(parts)
raise InvalidTag(f"Tag {tag!r} has an invalid component: {component!r}")

?

@sbidoul

sbidoul commented Jul 21, 2026

Copy link
Copy Markdown
Member

In addition to the PR title, the changelog should also mention that parse_tag() becomes stricter.

Comment thread src/packaging/utils.py

name = canonicalize_name(name_part)
try:
name = canonicalize_name(name_part, validate=True)

@sbidoul sbidoul Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this sounds correct, parse_wheel_filename does not use canonicalize_name(validate=True) and accepts non ascii characters (foo_bár is tested), which are rejected by canonicalize_name(validate=True) and is_normalized_name(). However parse_wheel_filename is typed to return NormalizedName.

PEP 427 explicitly allows non ascii characters in the project name: The archive filename is Unicode. It will be some time before the tools are updated to support non-ASCII filenames, but they are supported in this specification.

So there is a discrepancy that's probably worth discussing separately.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #1350 to discuss that.

@r266-tech

Copy link
Copy Markdown
Contributor Author

Added the requested changelog entry; it now explicitly calls out that parse_tag() rejects invalid tag components.

A post-change adversarial review also found one remaining cross-platform filename edge: : in the wheel build suffix can address an NTFS alternate data stream. The parser now rejects that delimiter while retaining the existing free-form suffix behavior for values such as 1+vendor; direct parser and percent-decoded %3A Pylock regressions cover it. The focused suite passes (386 tests), and all changed-file pre-commit hooks pass, including Ruff, mypy, RST checks, and typos. CI is running on b6910f3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants