Summary
dedup_batch() already has the shape a reservation table needs — it takes a collection and collapses values that reduce to the same thing — but it reports nothing about what collapsed:
disarm.dedup_batch(["groß.txt", "gross.txt", "other.txt"])
# ['gross.txt', 'gross.txt', 'other.txt']
Two distinct inputs became one value and the caller has no way to know it happened. That is CVE-2026-23950 exactly: node-tar's PathReservations failed to notice that groß.txt and gross.txt occupy one slot on a case-insensitive filesystem, so they were processed in parallel and the symlink guard was bypassed.
Why this is the shape that matters
Every other detector in disarm is a single-string predicate, and a collision is not a property of a single string. groß.txt is a perfectly good German filename. The question is only answerable about a set:
given these names, which pairs of them are the same name?
dedup_batch is the only public entry point that takes a set. It is currently a performance helper — "process each distinct value only once" — but the information it needs to do its job is precisely the information the caller needs for safety, and it is thrown away.
What a caller does today
It works with the public API, and that is worth saying plainly — this is an ergonomics gap, not a capability gap:
from disarm import canonicalize, fold_case
seen, collisions = {}, []
for name in ["groß.txt", "gross.txt", "GROSS.TXT", "other.txt"]:
key = fold_case(canonicalize(name))
if key in seen:
collisions.append((seen[key], name, key))
seen[key] = name
# [('groß.txt', 'gross.txt', 'gross.txt'), ('gross.txt', 'GROSS.TXT', 'gross.txt')]
The hard part is not the loop. It is knowing that fold_case is the right reducer, that str.casefold() would work but str.lower() would not, and that the class of characters involved has 2,345 members (see the sibling issue). A caller who reaches for str.lower() gets a check that silently misses ß.
Suggested shape
Something that returns the groups rather than the flattened list — for example a dedup_batch(..., report=True) mode, or a separate find_collisions(values, key=...), returning the sets of inputs that share a key. The choice of reducer should be the caller's, since the right key differs by use: a filesystem reservation wants fold_case, an account lookup wants search_key, a catalog wants catalog_key.
Whatever the surface, the property worth guaranteeing is that collapsing and reporting use the same reducer, so the answer cannot drift from the behaviour.
Related
Current state
Pinned in tests/test_cve_vectors.py::TestTarPathCollision, which asserts the key builders collide the pair and the canonicalizers deliberately do not. That row shows Detected by — in docs/security/cve-validation.md, and this is what would change it.
Refs #618, #619
Summary
dedup_batch()already has the shape a reservation table needs — it takes a collection and collapses values that reduce to the same thing — but it reports nothing about what collapsed:Two distinct inputs became one value and the caller has no way to know it happened. That is CVE-2026-23950 exactly: node-tar's
PathReservationsfailed to notice thatgroß.txtandgross.txtoccupy one slot on a case-insensitive filesystem, so they were processed in parallel and the symlink guard was bypassed.Why this is the shape that matters
Every other detector in disarm is a single-string predicate, and a collision is not a property of a single string.
groß.txtis a perfectly good German filename. The question is only answerable about a set:dedup_batchis the only public entry point that takes a set. It is currently a performance helper — "process each distinct value only once" — but the information it needs to do its job is precisely the information the caller needs for safety, and it is thrown away.What a caller does today
It works with the public API, and that is worth saying plainly — this is an ergonomics gap, not a capability gap:
The hard part is not the loop. It is knowing that
fold_caseis the right reducer, thatstr.casefold()would work butstr.lower()would not, and that the class of characters involved has 2,345 members (see the sibling issue). A caller who reaches forstr.lower()gets a check that silently missesß.Suggested shape
Something that returns the groups rather than the flattened list — for example a
dedup_batch(..., report=True)mode, or a separatefind_collisions(values, key=...), returning the sets of inputs that share a key. The choice of reducer should be the caller's, since the right key differs by use: a filesystem reservation wantsfold_case, an account lookup wantssearch_key, a catalog wantscatalog_key.Whatever the surface, the property worth guaranteeing is that collapsing and reporting use the same reducer, so the answer cannot drift from the behaviour.
Related
search_key/catalog_key/sort_keyleave 134 characters verbatim thattransliterate()deletes. Any collision API inherits whatever those keys do, so the two interact.Current state
Pinned in
tests/test_cve_vectors.py::TestTarPathCollision, which asserts the key builders collide the pair and the canonicalizers deliberately do not. That row shows Detected by — indocs/security/cve-validation.md, and this is what would change it.Refs #618, #619