1const ESCAPE_BY_CHARACTER = new Map([
2 ['"', '\\"'],
3 ["\\", "\\\\"],
4 ["\u0007", "\\a"],
5 ["\b", "\\b"],
6 ["\t", "\\t"],
7 ["\n", "\\n"],
8 ["\v", "\\v"],
9 ["\f", "\\f"],
10 ["\r", "\\r"],
11]);
12
13const CHARACTER_BY_ESCAPE: Record<string, number> = {
14 '"': 0x22,
15 "\\": 0x5c,
16 a: 0x07,
17 b: 0x08,
18 f: 0x0c,
19 n: 0x0a,
20 r: 0x0d,
21 t: 0x09,
22 v: 0x0b,
23};
24
25const QUOTE = '"';
26const DELETE_CHARACTER = 0x7f;
27const LOWEST_PRINTABLE = 0x20;
28const BACKSLASH = 0x5c;
29
30const utf8 = new TextEncoder();
31// A leading U+FEFF is part of the filename, not an encoding marker.
32const fromUtf8 = new TextDecoder("utf-8", { ignoreBOM: true });
33
34/** Include the a/ or b/ prefix in the quoted token, as Git does. */
35export function quoteGitPatchPath(path: string): string {
36 let body = "";
37 let quoting = false;
38 for (const character of path) {
39 const escape = ESCAPE_BY_CHARACTER.get(character);
40 if (escape !== undefined) {
41 body += escape;
42 quoting = true;
43 continue;
44 }
45 const code = character.codePointAt(0) ?? 0;
46 if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) {
47 body += `\\${code.toString(8).padStart(3, "0")}`;
48 quoting = true;
49 continue;
50 }
51 body += character;
52 }
53 return quoting ? `${QUOTE}${body}${QUOTE}` : path;
54}
55
56// Octal escapes represent bytes; rejoin before UTF-8 decoding.
57function unescapeBody(body: string): string {
58 if (!body.includes("\\")) return body;
59 const bytes: Array<number> = [];
60 // Encode literal runs together to preserve surrogate pairs.
61 let literal = "";
62 const flush = () => {
63 if (literal.length === 0) return;
64 bytes.push(...utf8.encode(literal));
65 literal = "";
66 };
67 let at = 0;
68 while (at < body.length) {
69 const character = body.charAt(at);
70 if (character !== "\\") {
71 literal += character;
72 at += 1;
73 continue;
74 }
75 const escaped = body.charAt(at + 1);
76 if (escaped === "") {
77 flush();
78 bytes.push(BACKSLASH);
79 break;
80 }
81 const named = CHARACTER_BY_ESCAPE[escaped];
82 if (named !== undefined) {
83 flush();
84 bytes.push(named);
85 at += 2;
86 continue;
87 }
88 const octal = body.slice(at + 1, at + 4);
89 if (/^[0-7]{3}$/.test(octal)) {
90 flush();
91 bytes.push(Number.parseInt(octal, 8));
92 at += 4;
93 continue;
94 }
95 literal += escaped;
96 at += 2;
97 }
98 flush();
99 return fromUtf8.decode(new Uint8Array(bytes));
100}
101
102/** Unescape with or without quotes; client parsers strip them inconsistently across patch headers. */
103export function unquoteGitPatchPath(token: string): string {
104 if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) {
105 return unescapeBody(token.slice(1, -1));
106 }
107 return unescapeBody(token);
108}