Skip to content

Commit 3fea8d0

Browse files
bloveclaude
andcommitted
fix(react): make the filename sanitizer do what its comments claim
Two places the code and its own documentation disagreed, both found by review. RESERVED was `$`-anchored on the whole name while the comment above it said "reserved names remain reserved with an extension — `CON.csv` is still `CON`". Only a bare `CON` was caught; `CON.csv`, `nul.txt` and `COM1.csv` sailed through. Device names are now matched against the stem before the first dot, with `desktop.ini` / `thumbs.db` kept as whole-name matches since those are files rather than devices. Reachable through the `fileName` escape hatch. The byte clamp ran last, so a 180-byte cut landing on a `.` or `-` re-created exactly the trailing punctuation the trim exists to remove — and Windows strips a trailing dot, so the name on disk would differ again. Trimming now happens after clamping. Both mutation-proven: re-anchoring RESERVED and moving the clamp back to last each turn the suite red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8f3b1e2 commit 3fea8d0

2 files changed

Lines changed: 42 additions & 4 deletions

File tree

‎packages/react/src/__tests__/save-file.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildExportFileName,
66
defaultSaveFile,
77
exportTimestamp,
8+
sanitizeStem,
89
toCsvBlob,
910
} from "../save-file";
1011

@@ -351,3 +352,23 @@ describe("sanitizeStem is linear, not polynomial", () => {
351352
expect(elapsed).toBeLessThan(500);
352353
});
353354
});
355+
356+
describe("save-file, the two comment/code disagreements", () => {
357+
it("treats a reserved device name as reserved WITH an extension", () => {
358+
// `CON.csv` resolves to \\.\CON on Windows. The regex was $-anchored on the
359+
// whole string, so only a bare `CON` was caught while the comment claimed
360+
// otherwise. Reachable through the `fileName` escape hatch.
361+
expect(sanitizeStem("CON.csv")).toBe("_CON.csv");
362+
expect(sanitizeStem("nul.txt")).toBe("_nul.txt");
363+
// Not a device: only reserved as the whole name.
364+
expect(sanitizeStem("console.csv")).toBe("console.csv");
365+
});
366+
367+
it("does not leave trailing punctuation created by the byte clamp", () => {
368+
// The clamp used to run last, so a cut landing on a "." restored exactly
369+
// what the trim exists to remove.
370+
const cut = sanitizeStem("a".repeat(179) + "." + "b".repeat(50));
371+
expect(cut.endsWith(".")).toBe(false);
372+
expect(cut.endsWith("-")).toBe(false);
373+
});
374+
});

‎packages/react/src/save-file.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,22 @@ const ILLEGAL = /[<>:"/\\|?*\s\x00-\x1f\u202a-\u202e\u2066-\u2069]/g;
4646
* Chromium's own list is larger than Microsoft's and prefixes matches with `_`.
4747
* Doing it here keeps the name predictable across platforms.
4848
*/
49-
const RESERVED =
50-
/^(con|prn|aux|nul|com[1-9]|lpt[1-9]|clock\$|conin\$|conout\$|desktop\.ini|thumbs\.db)$/i;
49+
const RESERVED_DEVICE =
50+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9]|clock\$|conin\$|conout\$)$/i;
51+
52+
/** Whole-name matches — these are only reserved as the complete filename. */
53+
const RESERVED_WHOLE = /^(desktop\.ini|thumbs\.db)$/i;
54+
55+
/**
56+
* A device name is reserved WITH an extension too — `CON.csv` resolves to
57+
* `\\.\CON`, which is why Windows treats it as the device rather than a file.
58+
* The comment above said so while the regex was `$`-anchored on the whole
59+
* string, so only a bare `CON` was caught; `CON.csv` sailed through.
60+
*/
61+
function isReservedName(stem: string): boolean {
62+
const beforeDot = stem.split(".")[0] as string;
63+
return RESERVED_DEVICE.test(beforeDot) || RESERVED_WHOLE.test(stem);
64+
}
5165

5266
/**
5367
* Bytes, not characters. Every mainstream filesystem caps a path component at
@@ -164,13 +178,16 @@ export function sanitizeStem(name: string): string {
164178
// anything that later parses the name.
165179
.replace(/\.{2,}/g, ".");
166180

167-
if (stem === "" || RESERVED.test(stem)) {
181+
if (stem === "" || isReservedName(stem)) {
168182
// A name that sanitizes to nothing, or collides with a device name, gets a
169183
// deterministic stand-in rather than whatever the browser would have chosen.
170184
stem = stem === "" ? "export" : `_${stem}`;
171185
}
172186

173-
return clampBytes(stem, MAX_STEM_BYTES);
187+
// Trim AFTER clamping. A 180-byte cut can land on a `.` or `-` and re-create
188+
// exactly the trailing punctuation the first trim removed — and Windows
189+
// strips a trailing dot, so the name on disk would differ again.
190+
return trimDotsAndSpace(clampBytes(stem, MAX_STEM_BYTES));
174191
}
175192

176193
/**

0 commit comments

Comments
 (0)