diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b0e705d75e..9a979ac378 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 82.0.1 +current_version = 82.0.1.post1 commit = True tag = True diff --git a/NEWS.rst b/NEWS.rst index 0b797c6ec0..91973f71c2 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -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 ======= diff --git a/pyproject.toml b/pyproject.toml index 3439f861bc..cad2055f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ backend-path = ["."] [project] name = "setuptools" -version = "82.0.1" +version = "82.0.1.post1" authors = [ { name = "Python Packaging Authority", email = "distutils-sig@python.org" }, ] diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index a5b5932702..138e29ee61 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -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. @@ -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) @@ -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: diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index 903a528db0..10bd41b883 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -10,6 +10,7 @@ import shutil import sys import tempfile +import unicodedata import pytest @@ -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() @@ -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): """ diff --git a/setuptools/unicode_utils.py b/setuptools/unicode_utils.py index f502f5b089..dbce03e1d1 100644 --- a/setuptools/unicode_utils.py +++ b/setuptools/unicode_utils.py @@ -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,