|
1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
2 | | -import { assert, it } from "@effect/vitest"; |
| 2 | +import { assert, describe, it } from "@effect/vitest"; |
3 | 3 | import { |
4 | 4 | DEFAULT_TERMINAL_ID, |
5 | 5 | type TerminalAttachStreamEvent, |
@@ -28,6 +28,7 @@ import { expect } from "vite-plus/test"; |
28 | 28 |
|
29 | 29 | import * as ProcessRunner from "../processRunner.ts"; |
30 | 30 | import * as TerminalManager from "./Manager.ts"; |
| 31 | +import { sanitizeTerminalHistoryChunk, stripTerminalResponsesFromInput } from "./Manager.ts"; |
31 | 32 | import * as PtyAdapter from "./PtyAdapter.ts"; |
32 | 33 |
|
33 | 34 | class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ |
@@ -991,6 +992,23 @@ it.layer( |
991 | 992 | }), |
992 | 993 | ); |
993 | 994 |
|
| 995 | + it.effect("sanitizes a pre-existing raw history log on load (older builds wrote it dirty)", () => |
| 996 | + Effect.gen(function* () { |
| 997 | + const { manager, logsDir } = yield* createManager(); |
| 998 | + const logPath = yield* historyLogPath(logsDir); |
| 999 | + // A log an older build persisted without sanitizing: the exact repeating |
| 1000 | + // DECRPM residue from #1238, ESC introducers intact. On load it must be |
| 1001 | + // stripped so it cannot replay (and re-trigger) at the prompt. |
| 1002 | + const garble = |
| 1003 | + "[?69;0$y[?2026;2$y[?2027;0$y[?2031;0$y[?2048;0$y"; |
| 1004 | + yield* writeFileString(logPath, `prompt$ ${garble.repeat(15)}done\n`); |
| 1005 | + |
| 1006 | + const opened = yield* manager.open(openInput()); |
| 1007 | + assert.equal(opened.history, "prompt$ done\n"); |
| 1008 | + // The cleaned history is persisted back, so it stays clean on re-read. |
| 1009 | + assert.equal(yield* readFileString(logPath), "prompt$ done\n"); |
| 1010 | + }), |
| 1011 | + ); |
994 | 1012 | it.effect( |
995 | 1013 | "preserves clear and style control sequences while dropping chunk-split query traffic", |
996 | 1014 | () => |
@@ -1645,3 +1663,265 @@ it.layer( |
1645 | 1663 | }).pipe(Effect.provide(TestClock.layer())), |
1646 | 1664 | ); |
1647 | 1665 | }); |
| 1666 | + |
| 1667 | +describe("sanitizeTerminalHistoryChunk", () => { |
| 1668 | + const sanitize = (data: string, pending = "") => sanitizeTerminalHistoryChunk(pending, data); |
| 1669 | + |
| 1670 | + it("strips DECRPM mode reports (CSI ? Pm ; Ps $ y) from history", () => { |
| 1671 | + const reports = "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?2048;0$y"; |
| 1672 | + const { visibleText } = sanitize(`before${reports}after`); |
| 1673 | + assert.equal(visibleText, "beforeafter"); |
| 1674 | + // The residue users were seeing must not survive. |
| 1675 | + assert.ok(!visibleText.includes("$y")); |
| 1676 | + assert.ok(!visibleText.includes("2026")); |
| 1677 | + }); |
| 1678 | + |
| 1679 | + it("strips DECRQM mode queries (CSI ? Pm $ p) so replay can't re-trigger them", () => { |
| 1680 | + const { visibleText } = sanitize("x\x1b[?2026$p\x1b[?2048$py"); |
| 1681 | + assert.equal(visibleText, "xy"); |
| 1682 | + }); |
| 1683 | + |
| 1684 | + it("keeps ordinary text and non-report CSI sequences", () => { |
| 1685 | + // SGR colour (m) and cursor moves stay; a plain 'p'/'y' without the `$` |
| 1686 | + // intermediate is not a mode sequence and must be preserved. |
| 1687 | + const { visibleText } = sanitize("\x1b[31mred\x1b[0m \x1b[2Aup happy"); |
| 1688 | + assert.equal(visibleText, "\x1b[31mred\x1b[0m \x1b[2Aup happy"); |
| 1689 | + }); |
| 1690 | + |
| 1691 | + it("drops the flattened mode-reply residue a shell echoes at the prompt", () => { |
| 1692 | + // The ESC introducer is already gone (the shell flattened the reply), so the |
| 1693 | + // escape-aware strip can't see it. A run of flattened DECRPM / DA / OSC-colour |
| 1694 | + // replies is dropped (DSR "n"/BEL/CR may separate them). |
| 1695 | + assert.equal( |
| 1696 | + sanitize("prompt$ 69;0$y2026;2$y2027;0$y2031;0$y2048;0$y").visibleText, |
| 1697 | + "prompt$ ", |
| 1698 | + ); |
| 1699 | + assert.equal(sanitize("a 1;2c11;rgb:1616/1616/1616n1;2c b").visibleText, "a b"); |
| 1700 | + // Lone DECRPM / OSC-colour / DECRPSS tokens are distinctive enough on their own. |
| 1701 | + assert.equal(sanitize("x 2026;2$y y").visibleText, "x y"); |
| 1702 | + assert.equal(sanitize("c 4;0;rgb:1818/1e1e/2626 d").visibleText, "c d"); |
| 1703 | + assert.equal(sanitize("tail 1$r0m end").visibleText, "tail end"); // flattened DECRPSS (#1238) |
| 1704 | + // Ambiguous lone tokens and ordinary words are preserved. |
| 1705 | + assert.equal(sanitize("see commit 1;2c now").visibleText, "see commit 1;2c now"); |
| 1706 | + assert.equal(sanitize("running a connection").visibleText, "running a connection"); |
| 1707 | + }); |
| 1708 | + |
| 1709 | + it("drops a flattened cursor-position-report (CPR) run, keeps a lone one", () => { |
| 1710 | + // The `;1RR`/`<row>;<col>R` flood from a prompt's CSI 6n re-query echoing at |
| 1711 | + // an idle prompt. Stripped as a run; a lone `<n>;<n>R` is ambiguous and kept. |
| 1712 | + assert.equal(sanitize(`prompt$ ${";1RR".repeat(40)}`).visibleText, "prompt$ "); |
| 1713 | + assert.equal(sanitize(`x ${"1;1R".repeat(20)} y`).visibleText, "x y"); |
| 1714 | + assert.equal(sanitize("at 12;5R done").visibleText, "at 12;5R done"); // lone, kept |
| 1715 | + }); |
| 1716 | + |
| 1717 | + it("does not over-match ordinary text that merely looks reply-shaped", () => { |
| 1718 | + // The colour alternative is pinned to OSC 10/11/12 and OSC 4 (`4;<idx>;`), so |
| 1719 | + // an arbitrary "<n>;rgb:…" in program output survives. |
| 1720 | + assert.equal(sanitize("set 1;rgb:ff/00/00 now").visibleText, "set 1;rgb:ff/00/00 now"); |
| 1721 | + assert.equal(sanitize("hsl 7;rgb:aabbcc done").visibleText, "hsl 7;rgb:aabbcc done"); |
| 1722 | + // A DECRPM/DA token immediately followed by a word must not swallow its first |
| 1723 | + // letter (regression: a trailing "n?" used to eat the "n" of "next"). |
| 1724 | + assert.equal(sanitize("v 1;2$ynext").visibleText, "v next"); |
| 1725 | + // The DECRPSS payload is length-bounded so it can't eat a following number run. |
| 1726 | + assert.equal( |
| 1727 | + sanitize("tail 1$r0;120;340;Hello there").visibleText, |
| 1728 | + "tail 1$r0;120;340;Hello there", |
| 1729 | + ); |
| 1730 | + }); |
| 1731 | + |
| 1732 | + it("preserves a framed OSC 4 palette report instead of mangling its inner rgb", () => { |
| 1733 | + // The escape walk keeps a framed OSC 4 report (only OSC 10/11/12 are stripped), |
| 1734 | + // so the flattened pass must not delete the inner `4;<idx>;rgb:…` and leave a |
| 1735 | + // broken `ESC ] … ST` shell — in either view. The flattened (unframed) form is |
| 1736 | + // still dropped. |
| 1737 | + const framed = "\x1b]4;1;rgb:ff/00/00\x07"; |
| 1738 | + assert.equal(sanitize(`a ${framed} b`).visibleText, `a ${framed} b`); |
| 1739 | + assert.equal( |
| 1740 | + sanitizeTerminalHistoryChunk("", `a ${framed} b`, { responsesOnly: true }).visibleText, |
| 1741 | + `a ${framed} b`, |
| 1742 | + ); |
| 1743 | + assert.equal(sanitize("echo 4;1;rgb:ff/00/00 here").visibleText, "echo here"); |
| 1744 | + }); |
| 1745 | + |
| 1746 | + it("strips a huge adversarial ';'-run in linear time (no ReDoS)", () => { |
| 1747 | + // A program-controlled buffer of many "<digits>;" groups that never reaches |
| 1748 | + // "rgb:" used to drive catastrophic backtracking (tens of seconds). The |
| 1749 | + // pinned colour alternative makes this fail fast. |
| 1750 | + const evil = "1".repeat(20) + ";"; |
| 1751 | + const start = process.hrtime.bigint(); |
| 1752 | + sanitize(`${evil.repeat(16000)}rgb`); |
| 1753 | + const ms = Number(process.hrtime.bigint() - start) / 1e6; |
| 1754 | + assert.ok(ms < 1000, `flattened strip took ${ms}ms — possible ReDoS`); |
| 1755 | + }); |
| 1756 | + |
| 1757 | + it("handles a report split across chunks via the pending buffer", () => { |
| 1758 | + const first = sanitize("tail\x1b[?69;0"); |
| 1759 | + assert.equal(first.visibleText, "tail"); |
| 1760 | + assert.notEqual(first.pendingControlSequence, ""); |
| 1761 | + const second = sanitize("$ydone", first.pendingControlSequence); |
| 1762 | + assert.equal(second.visibleText, "done"); |
| 1763 | + }); |
| 1764 | + |
| 1765 | + it("self-heals a flattened token split across PTY chunks on the next reload", () => { |
| 1766 | + // A *flattened* reply (ESC introducer already gone) has no escape framing for |
| 1767 | + // the pending buffer to hold, so if a PTY read splits it mid-token both halves |
| 1768 | + // are written to history live (transient garble). But they land contiguously, |
| 1769 | + // so readHistory()'s whole-buffer sanitize rejoins and strips them on restore — |
| 1770 | + // the residue does not return after a restart (the persistence concern in the |
| 1771 | + // cross-chunk #1238 follow-up). |
| 1772 | + const live = sanitize("prompt$ 2026;2").visibleText + sanitize("$y done").visibleText; |
| 1773 | + assert.equal(live, "prompt$ 2026;2$y done"); // contiguous in the persisted log |
| 1774 | + assert.equal(sanitize(live).visibleText, "prompt$ done"); // stripped on reload |
| 1775 | + }); |
| 1776 | + |
| 1777 | + it("strips the real-world restore residue reported in issue #1238", () => { |
| 1778 | + // The exact escape-reply fragments a user saw flood the prompt on terminal |
| 1779 | + // restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports |
| 1780 | + // (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST), |
| 1781 | + // reconstructed as the raw sequences the replayed history carried. |
| 1782 | + const residue = |
| 1783 | + "\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\"; |
| 1784 | + assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ "); |
| 1785 | + }); |
| 1786 | + |
| 1787 | + describe("responsesOnly (live stream)", () => { |
| 1788 | + const live = (data: string, pending = "") => |
| 1789 | + sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true }); |
| 1790 | + |
| 1791 | + it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => { |
| 1792 | + const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07"; |
| 1793 | + assert.equal(live(`a${responses}b`).visibleText, "ab"); |
| 1794 | + }); |
| 1795 | + |
| 1796 | + it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => { |
| 1797 | + const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07"; |
| 1798 | + assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`); |
| 1799 | + }); |
| 1800 | + |
| 1801 | + it("keeps ordinary display sequences", () => { |
| 1802 | + assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up"); |
| 1803 | + }); |
| 1804 | + |
| 1805 | + it("relays a query split across chunks while history strips it", () => { |
| 1806 | + // The query (DECRQM `$p`) arrives in two pieces. The live view must relay |
| 1807 | + // it across the pending boundary; the scrollback view strips it. |
| 1808 | + const liveFirst = live("x\x1b[?2026"); |
| 1809 | + assert.equal(liveFirst.visibleText, "x"); |
| 1810 | + assert.notEqual(liveFirst.pendingControlSequence, ""); |
| 1811 | + assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py"); |
| 1812 | + |
| 1813 | + const histFirst = sanitize("x\x1b[?2026"); |
| 1814 | + assert.equal(histFirst.visibleText, "x"); |
| 1815 | + assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y"); |
| 1816 | + }); |
| 1817 | + |
| 1818 | + it("diverges within one chunk: strips the response, relays the query", () => { |
| 1819 | + // `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the |
| 1820 | + // cursor-position *query* the client must answer (relayed live, stripped |
| 1821 | + // from scrollback). Same input, two outputs from one parse. |
| 1822 | + const data = "A\x1b[0n B\x1b[6n C"; |
| 1823 | + assert.equal(live(data).visibleText, "A B\x1b[6n C"); |
| 1824 | + assert.equal(sanitize(data).visibleText, "A B C"); |
| 1825 | + }); |
| 1826 | + }); |
| 1827 | + |
| 1828 | + describe("8-bit C1 introducers", () => { |
| 1829 | + it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => { |
| 1830 | + assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab"); |
| 1831 | + // Live view strips the report too (it is a response, not a query). |
| 1832 | + assert.equal( |
| 1833 | + sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText, |
| 1834 | + "ab", |
| 1835 | + ); |
| 1836 | + }); |
| 1837 | + |
| 1838 | + it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => { |
| 1839 | + assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab"); |
| 1840 | + // The `?` colour query is relayed live (the client must answer it) but |
| 1841 | + // stripped from scrollback so a replay cannot re-trigger it. |
| 1842 | + assert.equal( |
| 1843 | + sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText, |
| 1844 | + "a\x9d11;?\x07b", |
| 1845 | + ); |
| 1846 | + assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab"); |
| 1847 | + }); |
| 1848 | + |
| 1849 | + it("buffers an incomplete 8-bit CSI across chunks", () => { |
| 1850 | + const first = sanitize("tail\x9b?69;0"); |
| 1851 | + assert.equal(first.visibleText, "tail"); |
| 1852 | + assert.notEqual(first.pendingControlSequence, ""); |
| 1853 | + assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done"); |
| 1854 | + }); |
| 1855 | + }); |
| 1856 | + |
| 1857 | + describe("DCS status strings (DECRQSS / DECRPSS)", () => { |
| 1858 | + const live = (data: string) => |
| 1859 | + sanitizeTerminalHistoryChunk("", data, { responsesOnly: true }); |
| 1860 | + |
| 1861 | + it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => { |
| 1862 | + assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); |
| 1863 | + assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); |
| 1864 | + }); |
| 1865 | + |
| 1866 | + it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => { |
| 1867 | + assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b"); |
| 1868 | + assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab"); |
| 1869 | + }); |
| 1870 | + |
| 1871 | + it("leaves other DCS strings (sixel, DECUDK) untouched", () => { |
| 1872 | + const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\"; |
| 1873 | + assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`); |
| 1874 | + assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`); |
| 1875 | + }); |
| 1876 | + }); |
| 1877 | +}); |
| 1878 | + |
| 1879 | +describe("stripTerminalResponsesFromInput", () => { |
| 1880 | + it("drops the browser's auto-replies that drive the echo loop", () => { |
| 1881 | + const flood = |
| 1882 | + "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?1;2c\x1b]11;rgb:1616/1616/1616\x1b\\\x1b[0n\x1bP1$r0m\x1b\\\x1b[>0;276;0c"; |
| 1883 | + assert.equal(stripTerminalResponsesFromInput(flood), ""); |
| 1884 | + }); |
| 1885 | + |
| 1886 | + it("accepts the 8-bit ST (0x9c) terminator for OSC/DCS replies", () => { |
| 1887 | + assert.equal(stripTerminalResponsesFromInput("\x1b]11;rgb:1616/1616/1616\x9c"), ""); |
| 1888 | + assert.equal(stripTerminalResponsesFromInput("\x1bP1$r0m\x9c"), ""); |
| 1889 | + }); |
| 1890 | + |
| 1891 | + it("strips OSC 4 palette colour replies so they can't re-arm the echo loop", () => { |
| 1892 | + assert.equal(stripTerminalResponsesFromInput("\x1b]4;1;rgb:1616/1616/1616\x07"), ""); |
| 1893 | + assert.equal(stripTerminalResponsesFromInput("\x1b]4;255;rgb:ffff/0000/0000\x1b\\"), ""); |
| 1894 | + }); |
| 1895 | + |
| 1896 | + it("strips replies that use 8-bit C1 introducers (0x9b CSI, 0x9d OSC, 0x90 DCS)", () => { |
| 1897 | + assert.equal(stripTerminalResponsesFromInput("\x9b?69;0$y"), ""); // C1 CSI DECRPM |
| 1898 | + assert.equal(stripTerminalResponsesFromInput("\x9b>0;276;0c"), ""); // C1 CSI secondary DA |
| 1899 | + assert.equal(stripTerminalResponsesFromInput("\x9d4;1;rgb:1616/1616/1616\x9c"), ""); // C1 OSC 4 + C1 ST |
| 1900 | + assert.equal(stripTerminalResponsesFromInput("\x901$r0m\x9c"), ""); // C1 DCS DECRPSS |
| 1901 | + }); |
| 1902 | + |
| 1903 | + it("keeps focus events so DECSET ?1004 programs (vim/tmux) still receive them", () => { |
| 1904 | + assert.equal(stripTerminalResponsesFromInput("\x1b[I"), "\x1b[I"); // focus in |
| 1905 | + assert.equal(stripTerminalResponsesFromInput("\x1b[O"), "\x1b[O"); // focus out |
| 1906 | + }); |
| 1907 | + |
| 1908 | + it("strips cursor-position report (CPR) replies that drive the prompt redraw flood", () => { |
| 1909 | + assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R"), ""); // CPR reply |
| 1910 | + assert.equal(stripTerminalResponsesFromInput("\x1b[;1R"), ""); // empty-row CPR |
| 1911 | + assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R\x1b[1;1R\x1b[1;1R"), ""); // flood |
| 1912 | + assert.equal(stripTerminalResponsesFromInput("\x9b5;10R"), ""); // 8-bit C1 CPR |
| 1913 | + }); |
| 1914 | + |
| 1915 | + it("keeps real user input, cursor moves, and bare query forms", () => { |
| 1916 | + assert.equal(stripTerminalResponsesFromInput("ls -la\r"), "ls -la\r"); // keystrokes |
| 1917 | + assert.equal( |
| 1918 | + stripTerminalResponsesFromInput("\x1b[A\x1b[B\x1b[C\x1b[D"), |
| 1919 | + "\x1b[A\x1b[B\x1b[C\x1b[D", |
| 1920 | + ); // arrows |
| 1921 | + assert.equal(stripTerminalResponsesFromInput("\x03"), "\x03"); // Ctrl-C |
| 1922 | + assert.equal(stripTerminalResponsesFromInput("\x1b[1;5H"), "\x1b[1;5H"); // cursor-move (H, not CPR) |
| 1923 | + assert.equal(stripTerminalResponsesFromInput("\x1b[c"), "\x1b[c"); // bare DA query kept |
| 1924 | + assert.equal(stripTerminalResponsesFromInput("\x1b[>c"), "\x1b[>c"); // bare secondary DA query kept |
| 1925 | + assert.equal(stripTerminalResponsesFromInput("\x1b[6n"), "\x1b[6n"); // DSR query kept |
| 1926 | + }); |
| 1927 | +}); |
0 commit comments