diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 1ebed5d6..fd6edfb2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -40,7 +40,7 @@ jobs: uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: extra_nix_config: | - extra-trusted-substituters = https://nix-community.cachix.org + extra-substituters = https://nix-community.cachix.org extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= - name: Verify Nix dependency lockfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f62d9f5..06ad3e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed +- Preserved Git log ANSI colors when `hunk pager` falls back to a plain-text terminal pager for non-diff output. - Capped inline context expansion source reads so huge files cannot freeze or exhaust memory when expanding unchanged lines. - Hardened plain-text pager startup so `PAGER` and `HUNK_TEXT_PAGER` shell metacharacters are passed as arguments instead of being evaluated implicitly. - Hardened terminal rendering against control-sequence injection from diffs, file paths, notes, expanded context, copied selections, and pager fallback output. diff --git a/flake.nix b/flake.nix index a77a3ed7..c5db9065 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,7 @@ bun2nix.inputs.nixpkgs.follows = "nixpkgs"; }; nixConfig = { - extra-trusted-substituters = [ + extra-substituters = [ "https://nix-community.cachix.org" ]; extra-trusted-public-keys = [ diff --git a/src/core/pager.test.ts b/src/core/pager.test.ts index 20eebf7d..16eeacf1 100644 --- a/src/core/pager.test.ts +++ b/src/core/pager.test.ts @@ -233,6 +233,32 @@ describe("plain text pager fallback", () => { expectNoUnsafeTerminalControls(written); }); + test("preserves Git log SGR colors when sending plain text to a terminal pager", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + let written = ""; + pager.stdin.on("data", (chunk) => { + written += String(chunk); + }); + + await pagePlainText( + `* \x1b[1;34mabc1234\x1b[m - \x1b[1;32m(HEAD -> main)\x1b[m${CSI_CLEAR_SCREEN}`, + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + pager.emit("close", 0); + }); + return pager as never; + }, + }), + ); + + expect(written).toContain("\x1b[1;34mabc1234\x1b[m"); + expect(written).toContain("\x1b[1;32m(HEAD -> main)\x1b[m"); + expect(written).not.toContain(CSI_CLEAR_SCREEN); + }); + test("passes shell metacharacters as pager arguments instead of evaluating them", async () => { await pagePlainText( "plain text", diff --git a/src/core/pager.ts b/src/core/pager.ts index 9d4e5ee9..8ebe61fd 100644 --- a/src/core/pager.ts +++ b/src/core/pager.ts @@ -150,13 +150,13 @@ export async function pagePlainText( spawnImpl: spawn, }, ) { - const safeText = sanitizeTerminalText(text); - if (!deps.stdout.isTTY) { - deps.stdout.write(safeText); + deps.stdout.write(sanitizeTerminalText(text)); return; } + const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); + const pagerSpec = resolveTextPagerSpec(env); const pagerCommand = pagerSpec.displayCommand; diff --git a/src/lib/terminalText.test.ts b/src/lib/terminalText.test.ts index c227722d..2128db60 100644 --- a/src/lib/terminalText.test.ts +++ b/src/lib/terminalText.test.ts @@ -59,6 +59,31 @@ describe("sanitizeTerminalText", () => { expect(sanitizeTerminalText("alpha\n\tbeta")).toBe("alpha\n\tbeta"); }); + test("can preserve ANSI SGR styling while removing unsafe controls", () => { + const output = sanitizeTerminalText( + `plain\x1b[1;34mblue\x1b[m${OSC52_CLIPBOARD}${CSI_CLEAR_SCREEN}\x1b[2Kdone`, + { preserveAnsiStyle: true }, + ); + + expect(output).toBe("plain\x1b[1;34mblue\x1b[mdone"); + }); + + test("does not preserve non-SGR CSI sequences as ANSI styling", () => { + const output = sanitizeTerminalText("safe\x1b[2J\x1b[H\x1b[?25ltext", { + preserveAnsiStyle: true, + }); + + expect(output).toBe("safetext"); + }); + + test("removes crafted style placeholder delimiters before restoring ANSI styling", () => { + const output = sanitizeTerminalText("safe\u{f0000}0\u{f0001}\x1b[31mred\x1b[m", { + preserveAnsiStyle: true, + }); + + expect(output).toBe("safe0\x1b[31mred\x1b[m"); + }); + test("sanitizes span text while preserving styling metadata", () => { const spans = [ { text: `before${OSC52_CLIPBOARD}`, fg: "#fff" }, diff --git a/src/lib/terminalText.ts b/src/lib/terminalText.ts index 6c8ebdef..7f0384a5 100644 --- a/src/lib/terminalText.ts +++ b/src/lib/terminalText.ts @@ -3,6 +3,8 @@ export interface SanitizeTerminalTextOptions { preserveNewlines?: boolean; /** Preserve horizontal tabs for text fields that intentionally support them. Defaults to true. */ preserveTabs?: boolean; + /** Preserve ANSI SGR style sequences such as Git color output. Defaults to false. */ + preserveAnsiStyle?: boolean; } const controlCodeRegex = /[\x00-\x1f\x7f-\x9f]/; @@ -12,11 +14,16 @@ const sevenBitControlStrings = /\x1b(?:\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[PX^_][\s\S]*?(?:\x1b\\|\x9c)|\[[0-?]*[ -/]*[@-~])/g; const c1ControlStrings = /[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x1b\\|\x9c)/g; const c1Csi = /\x9b[0-?]*[ -/]*[@-~]/g; +const preservedStyleTokenDelimiters = /[\u{f0000}\u{f0001}]/gu; /** Normalize untrusted terminal-bound text before rendering it in Hunk UI surfaces. */ export function sanitizeTerminalText( text: string, - { preserveNewlines = true, preserveTabs = true }: SanitizeTerminalTextOptions = {}, + { + preserveNewlines = true, + preserveTabs = true, + preserveAnsiStyle = false, + }: SanitizeTerminalTextOptions = {}, ) { if (!controlCodeRegex.test(text)) { return text; @@ -29,12 +36,32 @@ export function sanitizeTerminalText( : preserveTabs ? /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g : /[\x00-\x1f\x7f-\x9f]/g; + const preservedStyles: string[] = []; + const preserveStyle = (sequence: string) => { + if (!preserveAnsiStyle || !/^\x1b\[[0-9;:]*m$/.test(sequence)) { + return ""; + } + + const token = `\u{f0000}${preservedStyles.length}\u{f0001}`; + preservedStyles.push(sequence); + return token; + }; - return text - .replace(sevenBitControlStrings, "") + // Strip placeholder delimiters from untrusted input so authored text cannot spoof + // an internal token that later restores an ANSI sequence at the wrong location. + const tokenSafeText = preserveAnsiStyle ? text.replace(preservedStyleTokenDelimiters, "") : text; + + let sanitized = tokenSafeText + .replace(sevenBitControlStrings, preserveStyle) .replace(c1ControlStrings, "") .replace(c1Csi, "") .replace(controlCharacters, ""); + + for (const [index, sequence] of preservedStyles.entries()) { + sanitized = sanitized.replaceAll(`\u{f0000}${index}\u{f0001}`, sequence); + } + + return sanitized; } /** Sanitize a single terminal row or cell where newlines must never be preserved. */