diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..5f09ee21 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Opencode", + "image": "oven/bun:1.3.0" +} \ No newline at end of file diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 99dd88ae..fd0d67bd 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -50,10 +50,61 @@ describe("file path helpers", () => { expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts") }) + test("unquotes all named escape sequences", () => { + expect(unquoteGitPath('"new\\nline"')).toBe("new\nline") + expect(unquoteGitPath('"tab\\tend"')).toBe("tab\tend") + expect(unquoteGitPath('"cr\\rend"')).toBe("cr\rend") + expect(unquoteGitPath('"backspace\\bend"')).toBe("backspace\bend") + expect(unquoteGitPath('"formfeed\\fend"')).toBe("formfeed\fend") + expect(unquoteGitPath('"vtab\\vend"')).toBe("vtab\vend") + expect(unquoteGitPath('"back\\\\slash"')).toBe("back\\slash") + expect(unquoteGitPath('"quo\\"te"')).toBe('quo"te') + }) + + test("returns input unchanged when not fully wrapped in quotes", () => { + expect(unquoteGitPath('"unterminated')).toBe('"unterminated') + expect(unquoteGitPath('unwrapped"')).toBe('unwrapped"') + expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) + + test("preserves a trailing lone backslash", () => { + expect(unquoteGitPath('"trailing\\"')).toBe("trailing\\") + }) + test("unquotes git escaped octal path strings", () => { expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") - expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") - expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) + + test("unquotes single and double digit octal escapes", () => { + expect(unquoteGitPath('"\\7"')).toBe("\u0007") + expect(unquoteGitPath('"\\41"')).toBe("!") + }) + + test("unquotes multiple octal escapes in one string", () => { + expect(unquoteGitPath('"\\303\\251\\303\\251.txt"')).toBe("\u00e9\u00e9.txt") + }) + + test("stops octal match at a non-octal digit", () => { + // "8" is not a valid octal digit, so \18 should decode \1 as octal + // and then treat "8" as a separate literal character + expect(unquoteGitPath('"\\18"')).toBe("\u00018") + }) + + test("returns the literal character for unrecognized escape sequences", () => { + expect(unquoteGitPath('"foo\\xbar"')).toBe("fooxbar") + }) + + test("returns the literal character for unrecognized escape sequences with digits outside octal range", () => { + expect(unquoteGitPath('"foo\\8bar"')).toBe("foo8bar") + expect(unquoteGitPath('"foo\\9bar"')).toBe("foo9bar") + }) + + test("preserves a trailing lone backslash", () => { + expect(unquoteGitPath('"trailing\\"')).toBe("trailing\\") + }) + + test("preserves a trailing lone backslash after other content", () => { + expect(unquoteGitPath('"a\\\\b\\"')).toBe("a\\b\\") }) }) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 2bc4bde5..b93e5e16 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -21,6 +21,16 @@ export function unquoteGitPath(input: string) { if (!input.endsWith('"')) return input const body = input.slice(1, -1) const bytes: number[] = [] + const ESCAPE_MAP: Record = { + "n": "\n", + "r": "\r", + "t": "\t", + "b": "\b", + "f": "\f", + "v": "\v", + "\\": "\\", + '"': '"', + }; for (let i = 0; i < body.length; i++) { const char = body[i]! @@ -37,35 +47,13 @@ export function unquoteGitPath(input: string) { if (next >= "0" && next <= "7") { const chunk = body.slice(i + 1, i + 4) - const match = chunk.match(/^[0-7]{1,3}/) - if (!match) { - bytes.push(next.charCodeAt(0)) - i++ - continue - } + const match = chunk.match(/^[0-7]{1,3}/)! bytes.push(parseInt(match[0], 8)) i += match[0].length continue } - const escaped = - next === "n" - ? "\n" - : next === "r" - ? "\r" - : next === "t" - ? "\t" - : next === "b" - ? "\b" - : next === "f" - ? "\f" - : next === "v" - ? "\v" - : next === "\\" || next === '"' - ? next - : undefined - - bytes.push((escaped ?? next).charCodeAt(0)) + bytes.push((ESCAPE_MAP[next] ?? next).charCodeAt(0)) i++ }