fix(upload): drop what no name can hold BEFORE the dot rule; a cut never manufactures a kind

Heid bug hunt, hulda, second round on 92c774e:

- A lone surrogate was dropped at the final decode, after the leading-dot
  rule had already run, so "\ud800.forever" came out as .forever, the
  keep marker, and "\ud800.." as "..". The NUL and every unencodable
  character now go first, in one pass, so nothing dropped later can shield
  a dot. Starlette decodes a multipart filename strictly (utf-8, else
  latin-1), so this was not reachable over HTTP; the helper is now right by
  construction regardless.
- A suffix too long to keep was cut like text, and the cut could land on a
  shorter suffix that means something: "….png" out of "….pngxxxx…"
  became an image. A cut that changes classify/doc_kind now has its dots
  neutralised.
- The 16-byte extension threshold was unguarded (every test suffix was 4
  bytes); a .jpeg case pins it.

Falsifiers: tests/mutations/upload_names.toml, 7/7 proved. Not taken here,
as they sit in the upload route rather than this helper: the pickup-id
mkdir outside the try (a FileExistsError race), rmtree(ignore_errors)
hiding a failed cleanup, and a CancelledError skipping cleanup.
This commit is contained in:
vh
2026-09-24 17:04:35 -07:00
parent 92c774e105
commit 225ba32209
3 changed files with 87 additions and 25 deletions
+47 -11
View File
@@ -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 = '''
'''