diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0c68aebbd4..2a49ad99e4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 44.1.1+security.2 +current_version = 44.1.1.post3 commit = True tag = True diff --git a/CHANGES.rst b/CHANGES.rst index b966b19f57..3bbf6e9214 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,19 @@ +v44.1.1.post3 +------------- + +* Version ordering: this release uses the ``.postN`` scheme. Under PEP 440 + it sorts after ``44.1.1+security.2`` but *below* a build labelled + ``44.1.1.1`` (the 2023 CVE-2022-40897 release, listed that way in the + ActiveState Platform inventory). Do not serve a ``44.1.1.1`` alongside this + release: ``setuptools<45`` would resolve to it and miss every fix since. +* Fixed ``MANIFEST.in`` matching being sensitive to Unicode normalization + form. A pattern authored NFC (as editors usually save it) did not match a + file whose name is stored NFD (as on macOS APFS/HFS+), so an ``exclude``, + ``global-exclude``, ``recursive-exclude`` or ``prune`` rule could silently + fail to drop a non-ASCII-named file from the sdist. Patterns and candidate + paths are now both normalized to NFC before matching (CVE-2026-59890, + GHSA-h35f-9h28-mq5c). + v44.1.1+security.2 ------------------ diff --git a/setup.cfg b/setup.cfg index c1672e7126..c2b00d4ebe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,3 @@ -[egg_info] -tag_build = .post -tag_date = 1 - [aliases] clean_egg_info = egg_info -Db '' release = clean_egg_info sdist bdist_wheel @@ -19,7 +15,7 @@ universal = 1 [metadata] name = setuptools -version = 44.1.1+security.2 +version = 44.1.1.post3 description = Easily download, build, install, upgrade, and uninstall Python packages author = Python Packaging Authority author_email = distutils-sig@python.org diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index a5c5a2fc19..8a65c9417d 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -33,6 +33,47 @@ from setuptools.extern import packaging from setuptools import SetuptoolsDeprecationWarning + +class _NormalizedMatcher(object): + """ + 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 CVE-2026-59890. + """ + + def __init__(self, pattern): + self._pattern = pattern + + def _prepare(self, path): + path = unicode_utils.normalize(path) + # On Python 2 a MANIFEST.in pattern and a FileList entry can be of + # different string types (e.g. a file finder returning text paths for + # non-ASCII names), and a byte pattern never matches a non-ASCII text + # path or vice versa. Bring the path to the pattern's type first. + if isinstance(self._pattern.pattern, six.text_type): + if isinstance(path, bytes): + try: + path = path.decode('utf-8') + except UnicodeDecodeError: + pass + elif isinstance(path, six.text_type): + try: + path = path.encode('utf-8') + except UnicodeEncodeError: + pass + return path + + def match(self, path): + return self._pattern.match(self._prepare(path)) + + def search(self, path): + return self._pattern.search(self._prepare(path)) + + def translate_pattern(glob): """ Translate a file path glob like '*.txt' in to a regular expression. @@ -42,6 +83,11 @@ def translate_pattern(glob): """ pat = '' + # Normalize the pattern so it matches paths regardless of the Unicode + # normalization form used on disk (CVE-2026-59890). 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) @@ -113,7 +159,7 @@ def translate_pattern(glob): 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 2a0e9c8625..863c13047a 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -7,6 +7,7 @@ import sys import tempfile import itertools +import unicodedata from distutils import log from distutils.errors import DistutilsTemplateError @@ -159,6 +160,50 @@ def test_translated_pattern_mismatch(pattern_mismatch): assert not translate_pattern(pattern).match(target) +NFC_NAME = unicodedata.normalize('NFC', u'caf\u00e9.txt') +NFD_NAME = unicodedata.normalize('NFD', u'caf\u00e9.txt') + + +@pytest.mark.parametrize('encode', [False, True], ids=['text', 'bytes']) +def test_translate_pattern_unicode_normalization(encode): + """ + 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. The bytes + case covers Python 2, where MANIFEST.in lines and walked file names are + native byte strings. + + Regression test for CVE-2026-59890 (GHSA-h35f-9h28-mq5c). + """ + if encode and six.PY3: + pytest.skip('byte-string patterns are only used on Python 2') + nfc, nfd = NFC_NAME, NFD_NAME + if encode: + nfc, nfd = nfc.encode('utf-8'), nfd.encode('utf-8') + assert nfc != nfd # the two forms genuinely differ + assert translate_pattern(nfc).match(nfd) + assert translate_pattern(nfd).match(nfc) + assert translate_pattern(nfc).search(nfd) + + + +@pytest.mark.skipif(six.PY3, reason='mixed str/bytes paths only arise on Python 2') +@pytest.mark.parametrize('name', [NFC_NAME, NFD_NAME], ids=['nfc', 'nfd']) +def test_translate_pattern_mixed_string_types(name): + """ + On Python 2 a byte-string pattern must match a text path, and a text + pattern a byte-string path, so that an exclusion is not bypassed when + MANIFEST.in lines and FileList entries differ in type. + """ + encoded = name.encode('utf-8') + assert translate_pattern(NFC_NAME.encode('utf-8')).match(name) + assert translate_pattern(NFC_NAME).match(encoded) + + file_list = FileList() + file_list.files = [name, u'other.txt'] + file_list.global_exclude(NFC_NAME.encode('utf-8')) + assert file_list.files == [u'other.txt'] + class TempDirTestCase: def setup_method(self, method): self.temp_dir = tempfile.mkdtemp() @@ -317,6 +362,31 @@ def test_graft_prune(self): 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 + 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 CVE-2026-59890 (GHSA-h35f-9h28-mq5c). + """ + nfd_path = os.path.join(self.temp_dir, 'app', NFD_NAME) + if six.PY2: + nfd_path = nfd_path.encode('utf-8') + # write the file under its decomposed (NFD) name ... + touch(nfd_path) + # ... and exclude it with the composed (NFC) form. + manifest = u'global-include *.txt\nglobal-exclude %s\n' % NFC_NAME + with open(os.path.join(self.temp_dir, 'MANIFEST.in'), 'wb') as f: + f.write(manifest.encode('utf-8')) + leaked = set( + f for f in self.get_files() + if os.path.basename(f).startswith('caf') + ) + assert not leaked, 'excluded file leaked into manifest: %r' % leaked + class TestFileListTest(TempDirTestCase): """ diff --git a/setuptools/unicode_utils.py b/setuptools/unicode_utils.py index 7c63efd20b..dac43bc4bb 100644 --- a/setuptools/unicode_utils.py +++ b/setuptools/unicode_utils.py @@ -17,6 +17,31 @@ def decompose(path): return path +def normalize(path): + """ + Return *path* 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 (CVE-2026-59890). + + Byte strings (the native ``str`` on Python 2) are normalized as UTF-8 and + returned as bytes; anything that is not valid UTF-8 is returned unchanged. + """ + if isinstance(path, six.text_type): + return unicodedata.normalize('NFC', path) + try: + path = path.decode('utf-8') + path = unicodedata.normalize('NFC', path) + path = path.encode('utf-8') + except UnicodeError: + pass # Not UTF-8 + return path + + def filesys_decode(path): """ Ensure that the given path is decoded,