diff --git a/booth/app.py b/booth/app.py index 65605e9..be743ac 100644 --- a/booth/app.py +++ b/booth/app.py @@ -987,28 +987,37 @@ UPLOAD_NAME_MAX_BYTES = 200 # NAME_MAX is 255 bytes; the rest is _dedupe_name's def safe_upload_name(name: str, fallback: str) -> str: """Reduce a client-supplied filename to a safe basename (no path, no hidden) - that the filesystem can actually hold. + that the filesystem can actually hold, and whose kind is the one it came in + with. Two names used to reach `open()` and raise, a 500 with the booth torn down (r3 heid bug hunt, hulda): a NUL, the one byte no POSIX name can hold (ValueError), and a name over NAME_MAX, which is 255 BYTES — a 200-character - cap let 200 two-byte characters through (ENAMETOOLONG). The NUL goes FIRST, - so it cannot shield a leading dot from the hide rule. The cap is 200 UTF-8 - bytes, leaving room for `_dedupe_name`'s suffix, and it comes out of the - STEM: the extension is what `classify` reads, so a cut `.png` would no - longer be an image. A cut through a multibyte character drops the partial - character, and a lone surrogate, which no filename can encode either, goes - the same way. + cap let 200 two-byte characters through (ENAMETOOLONG). + + ORDER IS THE POINT. Everything no filename can hold goes FIRST — the NUL, + and a lone surrogate, which no UTF-8 name can encode — so nothing dropped + later can shield a leading dot from the hide rule: dropped last, the + surrogate in "\\ud800.forever" left `.forever`, which is the keep marker + (hulda, second round). Then the basename and the dot rule, then the cap: + 200 UTF-8 bytes, leaving room for `_dedupe_name`'s suffix, taken out of the + STEM so the extension `classify` reads survives, and never through the middle + of a character. A suffix too long to be an extension is cut like any other + text, and a cut that lands on a SHORTER suffix meaning something else + (`….png` out of `….pngxxxx…`) has its dots neutralised, so the kind is never + manufactured. """ - base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip() + base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") + base = base.replace("\\", "/").split("/")[-1].strip() base = base.lstrip(".") # a leading dot would hide the file from every listing stem, dot, ext = base.rpartition(".") - tail = dot + ext if stem and len((dot + ext).encode("utf-8", "surrogatepass")) <= 16 else "" + tail = dot + ext if stem and len((dot + ext).encode("utf-8")) <= 16 else "" head = stem if tail else base - room = UPLOAD_NAME_MAX_BYTES - len(tail.encode("utf-8", "surrogatepass")) - base = (head.encode("utf-8", "surrogatepass")[:room] - + tail.encode("utf-8", "surrogatepass")).decode("utf-8", "ignore") - return base or fallback + room = UPLOAD_NAME_MAX_BYTES - len(tail.encode("utf-8")) + cut = head.encode("utf-8")[:room].decode("utf-8", "ignore") + tail + if (classify(cut), doc_kind(cut)) != (classify(base), doc_kind(base)): + cut = cut.replace(".", "_") + return cut or fallback def _dedupe_name(name: str, used: set) -> str: diff --git a/tests/mutations/upload_names.toml b/tests/mutations/upload_names.toml index 36bdbc3..d7015a1 100644 --- a/tests/mutations/upload_names.toml +++ b/tests/mutations/upload_names.toml @@ -1,7 +1,10 @@ -# Upload filenames the filesystem cannot hold. Two reached open() and raised, -# a 500 with the booth torn down (r3 heid bug hunt, hulda, 2026-09-24): a NUL, -# and a name over NAME_MAX (255 BYTES) that a 200-CHARACTER cap let through. -# Every row is a change tests/test_booth.py claims to forbid. +# Upload filenames the filesystem cannot hold, and names whose kind a cut could +# change. Two reached open() and raised, a 500 with the booth torn down (r3 +# heid bug hunt, hulda, 2026-09-24): a NUL, and a name over NAME_MAX (255 +# BYTES) that a 200-CHARACTER cap let through. The second round (hulda) found a +# surrogate dropped after the dot rule (`.forever`, the keep marker) and a cut +# that manufactured `.png`. Every row is a change tests/test_booth.py claims to +# forbid. unit = "upload names the filesystem can hold" @@ -10,30 +13,44 @@ label = "a NUL in an upload name reaches open() (ValueError, a 500)" file = "booth/app.py" test = "tests/test_booth.py::test_upload_a_nul_in_a_filename_never_500s" old = ''' - base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip()''' + base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")''' new = ''' - base = (name or "").replace("\\", "/").split("/")[-1].strip()''' + base = (name or "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore")''' [[mutation]] label = "the NUL is stripped after the dot rule (a NUL shields a leading dot)" file = "booth/app.py" test = "tests/test_booth.py::test_safe_upload_name_drops_nul_before_the_dot_rule" old = ''' - base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip() + base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") + base = base.replace("\\", "/").split("/")[-1].strip() base = base.lstrip(".") # a leading dot would hide the file from every listing''' new = ''' - base = (name or "").replace("\\", "/").split("/")[-1].strip() + base = (name or "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") + base = base.replace("\\", "/").split("/")[-1].strip() base = base.lstrip(".").replace("\x00", "") # a leading dot would hide the file from every listing''' +[[mutation]] +label = "a lone surrogate is dropped after the dot rule (`.forever`, the keep marker, comes out)" +file = "booth/app.py" +test = "tests/test_booth.py::test_safe_upload_name_drops_every_unencodable_character_before_the_dot_rule" +old = ''' + base = (name or "").replace("\x00", "").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") + base = base.replace("\\", "/").split("/")[-1].strip() + base = base.lstrip(".") # a leading dot would hide the file from every listing''' +new = ''' + base = (name or "").replace("\x00", "") + base = base.replace("\\", "/").split("/")[-1].strip() + base = base.lstrip(".").encode("utf-8", "surrogatepass").decode("utf-8", "ignore") # a leading dot would hide the file from every listing''' + [[mutation]] label = "the cap counts characters, not bytes (ENAMETOOLONG, a 500)" file = "booth/app.py" test = "tests/test_booth.py::test_upload_a_name_over_name_max_in_bytes_never_500s" old = ''' - base = (head.encode("utf-8", "surrogatepass")[:room] - + tail.encode("utf-8", "surrogatepass")).decode("utf-8", "ignore")''' + cut = head.encode("utf-8")[:room].decode("utf-8", "ignore") + tail''' new = ''' - base = base[:200]''' + cut = head[:room] + tail''' [[mutation]] label = "the cut comes out of the whole name (a long .png stops being an image)" @@ -43,3 +60,22 @@ old = ''' head = stem if tail else base''' new = ''' head, tail = base, ""''' + +[[mutation]] +label = "only a 4-byte extension survives the cut (`.jpeg` is lost)" +file = "booth/app.py" +test = "tests/test_booth.py::test_safe_upload_name_keeps_the_extension_through_the_cut" +old = ''' + tail = dot + ext if stem and len((dot + ext).encode("utf-8")) <= 16 else ""''' +new = ''' + tail = dot + ext if stem and len((dot + ext).encode("utf-8")) <= 4 else ""''' + +[[mutation]] +label = "a cut may land on a shorter suffix and manufacture a kind (`….png` out of `….pngxxx…`)" +file = "booth/app.py" +test = "tests/test_booth.py::test_safe_upload_name_never_manufactures_a_kind" +old = ''' + if (classify(cut), doc_kind(cut)) != (classify(base), doc_kind(base)): + cut = cut.replace(".", "_")''' +new = ''' +''' diff --git a/tests/test_booth.py b/tests/test_booth.py index aabeac0..c0a0855 100644 --- a/tests/test_booth.py +++ b/tests/test_booth.py @@ -366,11 +366,28 @@ def test_safe_upload_name_keeps_the_extension_through_the_cut(): # name that USED to fit (80 CJK characters, 240 bytes) must not lose its kind assert safe_upload_name("é" * 200 + ".png", "fb") == "é" * 98 + ".png" assert classify(safe_upload_name("画" * 80 + ".png", "fb")) == "image" + # a 5-byte extension is kept as well as a 4-byte one + assert safe_upload_name("é" * 200 + ".jpeg", "fb") == "é" * 97 + ".jpeg" # an "extension" too long to be one is cut like any other text long_ext = safe_upload_name("a." + "é" * 150, "fb") assert len(long_ext.encode("utf-8")) <= 200 and long_ext.startswith("a.é") +def test_safe_upload_name_never_manufactures_a_kind(): + # a cut through a long suffix can land on a SHORTER one: `.pngxxx…` is not + # an image, and its cut `….png` would be (heid bug hunt, hulda) + name = safe_upload_name("a" * 196 + ".png" + "x" * 17, "fb") + assert classify(name) == "other" and doc_kind(name) is None + assert name == "a" * 196 + "_png" + + +def test_safe_upload_name_drops_every_unencodable_character_before_the_dot_rule(): + # a lone surrogate is dropped too, and it must go FIRST like the NUL: dropped + # last, it shielded the dot and `.forever` came out, which is the keep marker + assert safe_upload_name("\ud800.forever", "fb") == "forever" + assert safe_upload_name("\ud800..", "fb") == "fb" + + def _upload(client, files): return client.post("/upload", files=files, follow_redirects=False)