Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 82.0.1
current_version = 82.0.1.post1
commit = True
tag = True

Expand Down
18 changes: 18 additions & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
v82.0.1.post1
=============

ActiveState security release: backports the fix from v83.0.0 while
keeping Python 3.9 support.

Bugfixes
--------

- ``MANIFEST.in`` matching (via ``FileList``) is now insensitive to Unicode
normalization form. A pattern authored in one form (e.g. NFC, as typically
saved by editors) now matches a file whose name is stored on disk in another
(e.g. NFD, as produced by macOS APFS/HFS+). Previously an ``exclude``,
``global-exclude``, ``recursive-exclude``, or ``prune`` rule could silently
fail to drop a non-ASCII-named file from the source distribution, publishing
it despite the exclusion -- CVE-2026-59890 / GHSA-h35f-9h28-mq5c.


v82.0.1
=======

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ backend-path = ["."]

[project]
name = "setuptools"
version = "82.0.1"
version = "82.0.1.post1"
authors = [
{ name = "Python Packaging Authority", email = "[email protected]" },
]
Expand Down
28 changes: 27 additions & 1 deletion setuptools/command/egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@
PY_MAJOR = f'{sys.version_info.major}.{sys.version_info.minor}'


class _NormalizedMatcher:
"""
Wrap a compiled pattern so that matching is insensitive to Unicode
normalization form.
File names walked from disk (NFD on macOS APFS/HFS+) and patterns from
``MANIFEST.in`` (typically NFC) can denote the same file while differing
byte-for-byte. Normalizing both sides before matching keeps an exclusion
(or inclusion) from silently failing. See GHSA-h35f-9h28-mq5c.
"""

def __init__(self, pattern: re.Pattern) -> None:
self._pattern = pattern

def match(self, path):
return self._pattern.match(unicode_utils.normalize(path))

def search(self, path):
return self._pattern.search(unicode_utils.normalize(path))


def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME
"""
Translate a file path glob like '*.txt' in to a regular expression.
Expand All @@ -46,6 +67,11 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME
"""
pat = ''

# Normalize the pattern so it matches paths regardless of the Unicode
# normalization form used on disk (GHSA-h35f-9h28-mq5c). Candidate paths
# are normalized to the same form by ``_NormalizedMatcher``.
glob = unicode_utils.normalize(glob)

# This will split on '/' within [character classes]. This is deliberate.
chunks = glob.split(os.path.sep)

Expand Down Expand Up @@ -117,7 +143,7 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME
pat += sep

pat += r'\Z'
return re.compile(pat, flags=re.MULTILINE | re.DOTALL)
return _NormalizedMatcher(re.compile(pat, flags=re.MULTILINE | re.DOTALL))


class InfoCommon:
Expand Down
45 changes: 45 additions & 0 deletions setuptools/tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import shutil
import sys
import tempfile
import unicodedata

import pytest

Expand Down Expand Up @@ -157,6 +158,21 @@ def test_translated_pattern_mismatch(pattern_mismatch):
assert not translate_pattern(pattern).match(target)


def test_translate_pattern_unicode_normalization():
"""
Matching is insensitive to Unicode normalization form: a pattern authored
in one form matches a path stored on disk in another (and vice versa), so
that an exclusion cannot be bypassed by an NFC/NFD mismatch.
Regression test for GHSA-h35f-9h28-mq5c.
"""
nfc = unicodedata.normalize('NFC', 'café.txt') # 'café.txt' composed
nfd = unicodedata.normalize('NFD', 'café.txt') # 'café.txt' decomposed
assert nfc != nfd # the two byte forms genuinely differ
assert translate_pattern(nfc).match(nfd)
assert translate_pattern(nfd).match(nfc)


class TempDirTestCase:
def setup_method(self, method):
self.temp_dir = tempfile.mkdtemp()
Expand Down Expand Up @@ -331,6 +347,35 @@ def test_graft_prune(self):
files = default_files | set([ml('app/a.txt'), ml('app/b.txt'), ml('app/c.rst')])
assert files == self.get_files()

def test_global_exclude_unicode_normalization(self):
"""
A ``global-exclude`` authored NFC must drop a file whose on-disk name
is NFD: on macOS APFS/HFS+ the two are the same file, and even on
case/normalization-exact filesystems the decomposed name can be
committed and reach the build. Otherwise the file is published in the
sdist despite the exclusion.
Regression test for GHSA-h35f-9h28-mq5c.
"""
nfc_name = unicodedata.normalize('NFC', 'café.txt')
nfd_name = unicodedata.normalize('NFD', 'café.txt')
assert nfc_name != nfd_name
# write the file under its decomposed (NFD) name ...
touch(os.path.join(self.temp_dir, 'app', nfd_name))
# ... and exclude it with the composed (NFC) form.
self.make_manifest(
f"""
global-include *.txt
global-exclude {nfc_name}
"""
)
leaked = {
f
for f in self.get_files()
if unicodedata.normalize('NFC', os.path.basename(f)) == nfc_name
}
assert not leaked, f"excluded file leaked into manifest: {leaked}"


class TestFileListTest(TempDirTestCase):
"""
Expand Down
14 changes: 14 additions & 0 deletions setuptools/unicode_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ def decompose(path):
return path


def normalize(text):
"""
Return *text* in a canonical Unicode form (NFC) so that names which are
visually identical but encoded differently compare equal.
macOS APFS/HFS+ store file names in decomposed form (NFD), while patterns
in ``MANIFEST.in`` are typically authored composed (NFC). The two denote
the same file but differ byte-for-byte, so matching them directly lets an
exclusion silently fail. Normalizing both the walked path and the pattern
to a single form before matching avoids that (GHSA-h35f-9h28-mq5c).
"""
return unicodedata.normalize('NFC', text) if isinstance(text, str) else text


def filesys_decode(path):
"""
Ensure that the given path is decoded,
Expand Down
Loading