From c6729927e69c5b2322ae15ffb4d0461a4e333b06 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 09:59:05 -0700 Subject: [PATCH] fix(annotations): deny case and Unicode spellings of a listed file On macOS (case- and normalization-insensitive APFS) a crafted target such as A.PNG or an NFD name opened the listed a.png or NFC file while realpath kept the requested spelling, so the tags were stored under a second locator the Images filter never shows. Capture and verify now require each path part to be spelled as its directory lists it and deny any other case or Unicode spelling as an alias (409 source-changed, like a symlink alias). A missing file with no other spelling keeps its usual outcome. --- tests/test_api_annotations.py | 12 ++++++++++++ tests/test_media_capture.py | 36 +++++++++++++++++++++++++++++++++++ utils/media_capture.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) 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)