| 1 | /** |
| 2 | * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in |
| 3 | * a header by where it stops: `diff --git a/<old> b/<new>` splits on a space, `--- a/<name>` stops |
| 4 | * at a tab. So a name holding a tab or a newline reads back as the part of itself before that |
| 5 | * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted |
| 6 | * with C-style escapes. Both halves live here so what one writes is what the other reads. |
| 7 | */ |
| 8 | |
| 9 | /** |
| 10 | * What git escapes by name, as the character written and the escape written for it. Not every byte |
| 11 | * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for |
| 12 | * what a terminal can show rather than anything the format needs, and a patch here is read by a |
| 13 | * diff viewer. |
| 14 | */ |
| 15 | const ESCAPE_BY_CHARACTER = new Map([ |
| 16 | ['"', '\\"'], |
| 17 | ["\\", "\\\\"], |
| 18 | ["\u0007", "\\a"], |
| 19 | ["\b", "\\b"], |
| 20 | ["\t", "\\t"], |
| 21 | ["\n", "\\n"], |
| 22 | ["\v", "\\v"], |
| 23 | ["\f", "\\f"], |
| 24 | ["\r", "\\r"], |
| 25 | ]); |
| 26 | |
| 27 | /** The escape written for a character, as the byte it stands for. */ |
| 28 | const CHARACTER_BY_ESCAPE: Record<string, number> = { |
| 29 | '"': 0x22, |
| 30 | "\\": 0x5c, |
| 31 | a: 0x07, |
| 32 | b: 0x08, |
| 33 | f: 0x0c, |
| 34 | n: 0x0a, |
| 35 | r: 0x0d, |
| 36 | t: 0x09, |
| 37 | v: 0x0b, |
| 38 | }; |
| 39 | |
| 40 | const QUOTE = '"'; |
| 41 | const DELETE_CHARACTER = 0x7f; |
| 42 | const LOWEST_PRINTABLE = 0x20; |
| 43 | const BACKSLASH = 0x5c; |
| 44 | |
| 45 | const utf8 = new TextEncoder(); |
| 46 | const fromUtf8 = new TextDecoder(); |
| 47 | |
| 48 | /** |
| 49 | * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form |
| 50 | * where it is not, which is any name holding a quote, a backslash or a control character. A header |
| 51 | * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git |
| 52 | * quotes is the whole token a reader takes off the line, side letter and all. |
| 53 | */ |
| 54 | export function quoteGitPatchPath(path: string): string { |
| 55 | let body = ""; |
| 56 | let quoting = false; |
| 57 | for (const character of path) { |
| 58 | const escape = ESCAPE_BY_CHARACTER.get(character); |
| 59 | if (escape !== undefined) { |
| 60 | body += escape; |
| 61 | quoting = true; |
| 62 | continue; |
| 63 | } |
| 64 | const code = character.codePointAt(0) ?? 0; |
| 65 | // Every character git has no name for is written as the octal of its byte, and a control |
| 66 | // character is one byte in UTF-8, so the character's own code point is that byte. |
| 67 | if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) { |
| 68 | body += `\\${code.toString(8).padStart(3, "0")}`; |
| 69 | quoting = true; |
| 70 | continue; |
| 71 | } |
| 72 | body += character; |
| 73 | } |
| 74 | return quoting ? `${QUOTE}${body}${QUOTE}` : path; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name |
| 79 | * holding no backslash is already itself, and an escape git would never write reads the way C |
| 80 | * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and |
| 81 | * only reads back as itself once those bytes are rejoined and decoded together. |
| 82 | */ |
| 83 | function unescapeBody(body: string): string { |
| 84 | if (!body.includes("\\")) return body; |
| 85 | const bytes: Array<number> = []; |
| 86 | // Anything left as itself is encoded a run at a time rather than a unit at a time, so a |
| 87 | // character outside the basic plane keeps its pair together rather than coming back as halves. |
| 88 | let literal = ""; |
| 89 | const flush = () => { |
| 90 | if (literal.length === 0) return; |
| 91 | bytes.push(...utf8.encode(literal)); |
| 92 | literal = ""; |
| 93 | }; |
| 94 | let at = 0; |
| 95 | while (at < body.length) { |
| 96 | const character = body.charAt(at); |
| 97 | if (character !== "\\") { |
| 98 | literal += character; |
| 99 | at += 1; |
| 100 | continue; |
| 101 | } |
| 102 | const escaped = body.charAt(at + 1); |
| 103 | if (escaped === "") { |
| 104 | flush(); |
| 105 | bytes.push(BACKSLASH); |
| 106 | break; |
| 107 | } |
| 108 | const named = CHARACTER_BY_ESCAPE[escaped]; |
| 109 | if (named !== undefined) { |
| 110 | flush(); |
| 111 | bytes.push(named); |
| 112 | at += 2; |
| 113 | continue; |
| 114 | } |
| 115 | const octal = body.slice(at + 1, at + 4); |
| 116 | if (/^[0-7]{3}$/.test(octal)) { |
| 117 | flush(); |
| 118 | bytes.push(Number.parseInt(octal, 8)); |
| 119 | at += 4; |
| 120 | continue; |
| 121 | } |
| 122 | literal += escaped; |
| 123 | at += 2; |
| 124 | } |
| 125 | flush(); |
| 126 | return fromUtf8.decode(new Uint8Array(bytes)); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * One header's name token as the name it stands for. The escapes are undone whether the quotes are |
| 131 | * still there or not, because patch parsers disagree about how much of the quoting they hand back: |
| 132 | * the one the clients read diffs with takes the quotes off the `diff --git` line's names and |
| 133 | * leaves them on a rename's. |
| 134 | */ |
| 135 | export function unquoteGitPatchPath(token: string): string { |
| 136 | if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) { |
| 137 | return unescapeBody(token.slice(1, -1)); |
| 138 | } |
| 139 | return unescapeBody(token); |
| 140 | } |