Sanitize Windows-invalid characters in exported filenames (#12407)

* Sanitize Windows-invalid ZIP filename characters

Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Refine Windows filename sanitization regex handling

Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* PR feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Copilot
2026-05-20 17:49:30 -04:00
committed by GitHub
parent c875a92b86
commit 841ab022a6
2 changed files with 63 additions and 2 deletions
+24
View File
@@ -19,6 +19,18 @@ describe("serializeFilename", () => {
"this %5C and %5C this"
);
});
it("should serialize Windows-invalid filename characters", () => {
expect(serializeFilename(`this: * ? " < > | that`)).toBe(
"this%3A %2A %3F %22 %3C %3E %7C that"
);
});
it("should serialize trailing Windows-invalid filename characters", () => {
expect(serializeFilename("file.")).toBe("file%2E");
expect(serializeFilename("file ")).toBe("file%20");
expect(serializeFilename("file. ")).toBe("file%2E%20");
});
});
describe("deserializeFilename", () => {
@@ -35,6 +47,18 @@ describe("deserializeFilename", () => {
`this \\ and \\ this`
);
});
it("should deserialize Windows-invalid filename characters", () => {
expect(deserializeFilename("%3A %2A %3F %22 %3C %3E %7C")).toBe(
`: * ? " < > |`
);
});
it("should deserialize trailing Windows-invalid filename characters", () => {
expect(deserializeFilename("file%2E")).toBe("file.");
expect(deserializeFilename("file%20")).toBe("file ");
expect(deserializeFilename("file%2E%20")).toBe("file. ");
});
});
describe("stringByteLength", () => {
+39 -2
View File
@@ -1,6 +1,33 @@
import path from "node:path";
import fs from "fs-extra";
const windowsInvalidFileNameCharsRegex = /[\\/:*?"<>|]/g;
const windowsTrailingFileNameCharsRegex = /[. ]+$/g;
const encodedWindowsCharacters: Record<string, string> = {
"%2F": "/",
"%5C": "\\",
"%3A": ":",
"%2A": "*",
"%3F": "?",
"%22": '"',
"%3C": "<",
"%3E": ">",
"%7C": "|",
"%2E": ".",
"%20": " ",
};
const encodedWindowsCharactersRegex = /%(?:2F|5C|3A|2A|3F|22|3C|3E|7C|2E|20)/gi;
/**
* Encodes a single character to uppercase percent-encoding.
*
* @param char The character to encode.
* @returns The encoded character.
*/
function encodeWindowsUnsafeCharacter(char: string): string {
return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
}
/**
* Serialize a file name for inclusion in a ZIP.
*
@@ -8,7 +35,14 @@ import fs from "fs-extra";
* @returns The serialized file name.
*/
export function serializeFilename(text: string): string {
return text.replace(/\//g, "%2F").replace(/\\/g, "%5C");
const encoded = text.replace(
windowsInvalidFileNameCharsRegex,
encodeWindowsUnsafeCharacter
);
return encoded.replace(windowsTrailingFileNameCharsRegex, (trailing) =>
trailing.split("").map(encodeWindowsUnsafeCharacter).join("")
);
}
/**
@@ -18,7 +52,10 @@ export function serializeFilename(text: string): string {
* @returns The deserialized file name.
*/
export function deserializeFilename(text: string): string {
return text.replace(/%2F/g, "/").replace(/%5C/g, "\\");
return text.replace(
encodedWindowsCharactersRegex,
(match) => encodedWindowsCharacters[match.toUpperCase()] ?? match
);
}
/**