|
10 | 10 | import shutil |
11 | 11 | import sys |
12 | 12 | import tempfile |
| 13 | +import unicodedata |
13 | 14 |
|
14 | 15 | import pytest |
15 | 16 |
|
@@ -157,6 +158,21 @@ def test_translated_pattern_mismatch(pattern_mismatch): |
157 | 158 | assert not translate_pattern(pattern).match(target) |
158 | 159 |
|
159 | 160 |
|
| 161 | +def test_translate_pattern_unicode_normalization(): |
| 162 | + """ |
| 163 | + Matching is insensitive to Unicode normalization form: a pattern authored |
| 164 | + in one form matches a path stored on disk in another (and vice versa), so |
| 165 | + that an exclusion cannot be bypassed by an NFC/NFD mismatch. |
| 166 | +
|
| 167 | + Regression test for GHSA-h35f-9h28-mq5c. |
| 168 | + """ |
| 169 | + nfc = unicodedata.normalize('NFC', 'café.txt') # 'café.txt' composed |
| 170 | + nfd = unicodedata.normalize('NFD', 'café.txt') # 'café.txt' decomposed |
| 171 | + assert nfc != nfd # the two byte forms genuinely differ |
| 172 | + assert translate_pattern(nfc).match(nfd) |
| 173 | + assert translate_pattern(nfd).match(nfc) |
| 174 | + |
| 175 | + |
160 | 176 | class TempDirTestCase: |
161 | 177 | def setup_method(self, method): |
162 | 178 | self.temp_dir = tempfile.mkdtemp() |
@@ -331,6 +347,35 @@ def test_graft_prune(self): |
331 | 347 | files = default_files | set([ml('app/a.txt'), ml('app/b.txt'), ml('app/c.rst')]) |
332 | 348 | assert files == self.get_files() |
333 | 349 |
|
| 350 | + def test_global_exclude_unicode_normalization(self): |
| 351 | + """ |
| 352 | + A ``global-exclude`` authored NFC must drop a file whose on-disk name |
| 353 | + is NFD: on macOS APFS/HFS+ the two are the same file, and even on |
| 354 | + case/normalization-exact filesystems the decomposed name can be |
| 355 | + committed and reach the build. Otherwise the file is published in the |
| 356 | + sdist despite the exclusion. |
| 357 | +
|
| 358 | + Regression test for GHSA-h35f-9h28-mq5c. |
| 359 | + """ |
| 360 | + nfc_name = unicodedata.normalize('NFC', 'café.txt') |
| 361 | + nfd_name = unicodedata.normalize('NFD', 'café.txt') |
| 362 | + assert nfc_name != nfd_name |
| 363 | + # write the file under its decomposed (NFD) name ... |
| 364 | + touch(os.path.join(self.temp_dir, 'app', nfd_name)) |
| 365 | + # ... and exclude it with the composed (NFC) form. |
| 366 | + self.make_manifest( |
| 367 | + f""" |
| 368 | + global-include *.txt |
| 369 | + global-exclude {nfc_name} |
| 370 | + """ |
| 371 | + ) |
| 372 | + leaked = { |
| 373 | + f |
| 374 | + for f in self.get_files() |
| 375 | + if unicodedata.normalize('NFC', os.path.basename(f)) == nfc_name |
| 376 | + } |
| 377 | + assert not leaked, f"excluded file leaked into manifest: {leaked}" |
| 378 | + |
334 | 379 |
|
335 | 380 | class TestFileListTest(TempDirTestCase): |
336 | 381 | """ |
|
0 commit comments