From f5acb4f1076af87477c2c03fee97597a2107c8b7 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 22 Oct 2026 10:47:50 -0500 Subject: [PATCH 1/5] Fix CVE-2026-59890: Normalize Unicode form when matching MANIFEST.in patterns GHSA-h35f-9h28-mq5c. FileList matched MANIFEST.in patterns against on-disk names byte-for-byte with no Unicode normalization. On macOS APFS/HFS+, a file stored NFD and a pattern authored NFC denote the same file but differ byte-for-byte, so an exclude/global-exclude/recursive-exclude/prune rule could silently fail to drop a non-ASCII-named file, publishing it in the sdist despite the rule. Normalize both the pattern and the candidate path to NFC before matching, via a new unicode_utils.normalize() helper and a _NormalizedMatcher wrapper around the compiled pattern returned by translate_pattern(). All five FileList callers (exclude, recursive_exclude, prune, global_include, global_exclude) only call .match() on the result, which the wrapper keeps. Upstream: pypa/setuptools dd9f436a (v83.0.0); already shipped on our 82.0.1 line as v82.0.1.post1. Py2.7 adaptation: on Python 2, MANIFEST.in lines and walked file names are native byte strings, so normalize() follows the existing decompose() helper: text is normalized directly; bytes are decoded as UTF-8, normalized and re-encoded, and left unchanged if they are not UTF-8. Upstream normalized str only, which on Python 2 would have made the fix a no-op. No f-strings or type hints; _NormalizedMatcher is a new-style class; test literals use \u escapes. Tests: translate_pattern NFC<->NFD for text and (Py2) bytes, plus an end-to-end egg_info global-exclude of an NFD-named file. All three fail on the pristine base and pass with the fix under Python 2.7.18; test_egg_info/test_sdist/test_manifest: 145 passed (pristine 142 + 3 new). Co-Authored-By: Claude Opus 5.5 --- setuptools/command/egg_info.py | 28 ++++++++++++++++- setuptools/tests/test_manifest.py | 52 +++++++++++++++++++++++++++++++ setuptools/unicode_utils.py | 25 +++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index a5c5a2fc19..3b24142b42 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -33,6 +33,27 @@ 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 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): """ Translate a file path glob like '*.txt' in to a regular expression. @@ -42,6 +63,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 +139,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..14f61aec3e 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,32 @@ def test_translated_pattern_mismatch(pattern_mismatch): assert not translate_pattern(pattern).match(target) +NFC_NAME = unicodedata.normalize('NFC', u'café.txt') +NFD_NAME = unicodedata.normalize('NFD', u'café.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) + + class TempDirTestCase: def setup_method(self, method): self.temp_dir = tempfile.mkdtemp() @@ -317,6 +344,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, From 62a8f042a6a6a3f89342bc30279ae37d80c8fef9 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 22 Oct 2026 10:48:00 -0500 Subject: [PATCH 2/5] Release 44.1.1.post3: CVE-2026-59890 fix Bump version 44.1.1+security.2 -> 44.1.1.post3 and add a changelog entry for the backported MANIFEST.in Unicode-normalization fix. New security releases use the PEP 440 .postN scheme in place of the +security.N local label; N continues the line's count (44.1.1.1, 44.1.1+security.2, now 44.1.1.post3). 44.1.1.post3 sorts after 44.1.1+security.2 and before 44.1.2. Co-Authored-By: Claude Opus 5.5 --- .bumpversion.cfg | 2 +- CHANGES.rst | 11 +++++++++++ setup.cfg | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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..d0d2d7d6c8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,14 @@ +v44.1.1.post3 +------------- + +* 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..ad3211f8fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,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 From 96796eac98f2299573ef7786d5b949301588c007 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 22 Oct 2026 13:40:41 -0500 Subject: [PATCH 3/5] Fix CVE-2026-59890 (cont.): match across str/bytes on Python 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. On Python 2 a MANIFEST.in pattern and a FileList entry can be of different string types, for example when a file-finder plugin returns text paths for non-ASCII names. A byte pattern never matches a non-ASCII text path or vice versa, so an exclusion leaked the file: the same harm the CVE describes. This happened on the base too, NFC against NFC included. _NormalizedMatcher now brings the normalized path to the pattern's string type (UTF-8 encode or decode; left unchanged if that fails) before matching. New tests cover byte pattern against text path and the reverse, NFC and NFD, through translate_pattern() and FileList.global_exclude(); both fail without this change. Also: add the second blank line before class _NormalizedMatcher (E302), and spell the test literals as é escapes, as the f5acb4f1 message already claimed. Co-Authored-By: Claude Opus 5.5 --- setuptools/command/egg_info.py | 24 ++++++++++++++++++++++-- setuptools/tests/test_manifest.py | 22 ++++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index 3b24142b42..8a65c9417d 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -33,6 +33,7 @@ from setuptools.extern import packaging from setuptools import SetuptoolsDeprecationWarning + class _NormalizedMatcher(object): """ Wrap a compiled pattern so that matching is insensitive to Unicode @@ -47,11 +48,30 @@ class _NormalizedMatcher(object): 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(unicode_utils.normalize(path)) + return self._pattern.match(self._prepare(path)) def search(self, path): - return self._pattern.search(unicode_utils.normalize(path)) + return self._pattern.search(self._prepare(path)) def translate_pattern(glob): diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index 14f61aec3e..863c13047a 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -160,8 +160,8 @@ def test_translated_pattern_mismatch(pattern_mismatch): assert not translate_pattern(pattern).match(target) -NFC_NAME = unicodedata.normalize('NFC', u'café.txt') -NFD_NAME = unicodedata.normalize('NFD', u'café.txt') +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']) @@ -186,6 +186,24 @@ def test_translate_pattern_unicode_normalization(encode): 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() From d5222346205a41c87b92abcb303104a717422297 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 22 Oct 2026 13:40:41 -0500 Subject: [PATCH 4/5] Drop egg_info tag_build/tag_date from setup.cfg With version = 44.1.1.post3, the inherited [egg_info] tag_build = .post / tag_date = 1 made a build from the source tree or tag (pip install ., pip install git+...@v44.1.1.post3) report 44.1.1.post3.post-YYYYMMDD, which PEP 440 rejects (two post segments). That broke test_integration::test_stevedore and would make recent pip refuse the wheel. A source build now reports 44.1.1.post3. The release alias (egg_info -Db '') already cleared the tag, so it is unaffected. Co-Authored-By: Claude Opus 5.5 --- setup.cfg | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index ad3211f8fb..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 From 8cfa41270dab97718b42cddaceddeabc6a151f7b Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 22 Oct 2026 13:46:01 -0500 Subject: [PATCH 5/5] CHANGES: state 44.1.1.post3's PEP 440 ordering against 44.1.1.1 Co-Authored-By: Claude Opus 5.5 --- CHANGES.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d0d2d7d6c8..3bbf6e9214 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ 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``,