diff --git a/tests/test_api_annotations.py b/tests/test_api_annotations.py index aef67aeb..c09dcbc0 100644 --- a/tests/test_api_annotations.py +++ b/tests/test_api_annotations.py @@ -282,6 +282,18 @@ class TestObserve(AnnotationAPITestCase): self.assertEqual(response.status, 400) capture.assert_not_called() + async def test_observe_denies_case_spellings_of_the_listed_file(self): + make_png(self.output_dir / "sub" / "a.png") + for alias in ("sub/A.PNG", "SUB/a.png"): + with self.subTest(alias=alias): + status, body = await self.observe({"target": self.target(alias)}) + # Denied as an alias, like a symlink to the listed file. + self.assertEqual((status, body["code"]), (409, "source-changed")) + self.assert_no_paths(body) + store = self.api._captures + self.assertFalse(any(Path(store.directory).iterdir())) + await self.observe_ok("sub/a.png") + async def test_observe_returns_saved_tags_for_exact_bytes_only(self): path = make_png(self.output_dir / "sub" / "a.png") original = path.read_bytes() diff --git a/tests/test_media_capture.py b/tests/test_media_capture.py index a183ab36..53f3fdbf 100644 --- a/tests/test_media_capture.py +++ b/tests/test_media_capture.py @@ -7,6 +7,7 @@ import struct import tempfile import threading import time +import unicodedata import unittest import warnings import zlib @@ -240,6 +241,41 @@ class CaptureStoreTestCase(unittest.TestCase): self.capture(store, "alias.png") self.assert_empty(store) + def test_case_and_unicode_spellings_of_a_listed_file_are_denied(self): + # On a case- or normalization-insensitive filesystem (macOS APFS, + # Windows) these spellings open the listed file while realpath keeps + # the requested spelling, which would store its tags under a second + # locator. Every OS denies them; the listed spelling is the control. + nfc = unicodedata.normalize("NFC", "caf\u00e9.png") + nfd = unicodedata.normalize("NFD", nfc) + self.assertNotEqual(nfc, nfd) + self.write("a.png", png_bytes(1, 1)) + self.write(nfc, png_bytes(1, 1)) + self.write("Sub/b.png", png_bytes(1, 1)) + store = self.make_store() + + for relative in ("a.png", nfc, "Sub/b.png"): + with self.subTest(listed=relative): + cap = self.capture(store, relative) + self.assertEqual( + cap.locator, + os.path.normcase( + os.path.join(os.path.realpath(self.root), *relative.split("/")) + ), + ) + self.assertTrue(store.release(cap.token)) + for alias in ("A.PNG", "A.png", nfd, "sub/b.png", "SUB/B.PNG", "Sub/B.png"): + with self.subTest(alias=alias): + with self.assertRaises(CaptureDenied): + self.capture(store, alias) + with self.assertRaises(CaptureDenied): + store.verify(self.root, alias) + # A missing file with no other spelling keeps its usual outcome. + with self.assertRaises(CaptureChanged): + self.capture(store, "Sub/missing.png") + self.assert_empty(store) + assert_closed(self, self.parent) + def test_locator_matches_listing_identity_for_symlinked_root(self): real_root = self.parent / "real-output" real_root.mkdir() diff --git a/utils/media_capture.py b/utils/media_capture.py index e8a49d6e..1932f0b0 100644 --- a/utils/media_capture.py +++ b/utils/media_capture.py @@ -26,6 +26,7 @@ import stat as stat_module import tempfile import threading import time +import unicodedata from dataclasses import dataclass from typing import Callable @@ -652,4 +653,33 @@ def _resolve(root, parts: list[str]) -> tuple[str, str]: locator = os.path.normcase(candidate) if os.path.normcase(os.path.realpath(candidate)) != locator: raise CaptureDenied("The file is not a listed rooted file") + _deny_spelling_aliases(real_root, parts) return candidate, locator + + +def _spelling_key(name: str) -> str: + return unicodedata.normalize("NFC", name).casefold() + + +def _deny_spelling_aliases(real_root: str, parts: list[str]) -> None: + """Deny a case or Unicode spelling of an entry the directory spells otherwise. + + On a case- or normalization-insensitive filesystem (macOS APFS, Windows) + ``A.PNG`` or an NFD name opens the listed ``a.png`` or NFC name, while + ``realpath`` keeps the requested spelling (macOS), so the tags would be + stored under a second locator. Each part must be spelled as its directory + lists it. A part with no entry of any spelling is left to the open that + follows (a missing file keeps its usual outcome). + """ + directory = real_root + for part in parts: + try: + names = os.listdir(directory) + except OSError: + return + if part not in names: + key = _spelling_key(part) + if any(_spelling_key(name) == key for name in names): + raise CaptureDenied("The file is not a listed rooted file") + return + directory = os.path.join(directory, part)