|
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 } 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,154 @@ 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 tokens are distinctive enough to drop 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 | + // Ambiguous lone tokens and ordinary words are preserved. |
| 1704 | + assert.equal(sanitize("see commit 1;2c now").visibleText, "see commit 1;2c now"); |
| 1705 | + assert.equal(sanitize("running a connection").visibleText, "running a connection"); |
| 1706 | + }); |
| 1707 | + |
| 1708 | + it("handles a report split across chunks via the pending buffer", () => { |
| 1709 | + const first = sanitize("tail\x1b[?69;0"); |
| 1710 | + assert.equal(first.visibleText, "tail"); |
| 1711 | + assert.notEqual(first.pendingControlSequence, ""); |
| 1712 | + const second = sanitize("$ydone", first.pendingControlSequence); |
| 1713 | + assert.equal(second.visibleText, "done"); |
| 1714 | + }); |
| 1715 | + |
| 1716 | + it("strips the real-world restore residue reported in issue #1238", () => { |
| 1717 | + // The exact escape-reply fragments a user saw flood the prompt on terminal |
| 1718 | + // restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports |
| 1719 | + // (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST), |
| 1720 | + // reconstructed as the raw sequences the replayed history carried. |
| 1721 | + const residue = |
| 1722 | + "\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\"; |
| 1723 | + assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ "); |
| 1724 | + }); |
| 1725 | + |
| 1726 | + describe("responsesOnly (live stream)", () => { |
| 1727 | + const live = (data: string, pending = "") => |
| 1728 | + sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true }); |
| 1729 | + |
| 1730 | + it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => { |
| 1731 | + const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07"; |
| 1732 | + assert.equal(live(`a${responses}b`).visibleText, "ab"); |
| 1733 | + }); |
| 1734 | + |
| 1735 | + it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => { |
| 1736 | + const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07"; |
| 1737 | + assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`); |
| 1738 | + }); |
| 1739 | + |
| 1740 | + it("keeps ordinary display sequences", () => { |
| 1741 | + assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up"); |
| 1742 | + }); |
| 1743 | + |
| 1744 | + it("relays a query split across chunks while history strips it", () => { |
| 1745 | + // The query (DECRQM `$p`) arrives in two pieces. The live view must relay |
| 1746 | + // it across the pending boundary; the scrollback view strips it. |
| 1747 | + const liveFirst = live("x\x1b[?2026"); |
| 1748 | + assert.equal(liveFirst.visibleText, "x"); |
| 1749 | + assert.notEqual(liveFirst.pendingControlSequence, ""); |
| 1750 | + assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py"); |
| 1751 | + |
| 1752 | + const histFirst = sanitize("x\x1b[?2026"); |
| 1753 | + assert.equal(histFirst.visibleText, "x"); |
| 1754 | + assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y"); |
| 1755 | + }); |
| 1756 | + |
| 1757 | + it("diverges within one chunk: strips the response, relays the query", () => { |
| 1758 | + // `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the |
| 1759 | + // cursor-position *query* the client must answer (relayed live, stripped |
| 1760 | + // from scrollback). Same input, two outputs from one parse. |
| 1761 | + const data = "A\x1b[0n B\x1b[6n C"; |
| 1762 | + assert.equal(live(data).visibleText, "A B\x1b[6n C"); |
| 1763 | + assert.equal(sanitize(data).visibleText, "A B C"); |
| 1764 | + }); |
| 1765 | + }); |
| 1766 | + |
| 1767 | + describe("8-bit C1 introducers", () => { |
| 1768 | + it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => { |
| 1769 | + assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab"); |
| 1770 | + // Live view strips the report too (it is a response, not a query). |
| 1771 | + assert.equal( |
| 1772 | + sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText, |
| 1773 | + "ab", |
| 1774 | + ); |
| 1775 | + }); |
| 1776 | + |
| 1777 | + it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => { |
| 1778 | + assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab"); |
| 1779 | + // The `?` colour query is relayed live (the client must answer it) but |
| 1780 | + // stripped from scrollback so a replay cannot re-trigger it. |
| 1781 | + assert.equal( |
| 1782 | + sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText, |
| 1783 | + "a\x9d11;?\x07b", |
| 1784 | + ); |
| 1785 | + assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab"); |
| 1786 | + }); |
| 1787 | + |
| 1788 | + it("buffers an incomplete 8-bit CSI across chunks", () => { |
| 1789 | + const first = sanitize("tail\x9b?69;0"); |
| 1790 | + assert.equal(first.visibleText, "tail"); |
| 1791 | + assert.notEqual(first.pendingControlSequence, ""); |
| 1792 | + assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done"); |
| 1793 | + }); |
| 1794 | + }); |
| 1795 | + |
| 1796 | + describe("DCS status strings (DECRQSS / DECRPSS)", () => { |
| 1797 | + const live = (data: string) => |
| 1798 | + sanitizeTerminalHistoryChunk("", data, { responsesOnly: true }); |
| 1799 | + |
| 1800 | + it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => { |
| 1801 | + assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); |
| 1802 | + assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab"); |
| 1803 | + }); |
| 1804 | + |
| 1805 | + it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => { |
| 1806 | + assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b"); |
| 1807 | + assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab"); |
| 1808 | + }); |
| 1809 | + |
| 1810 | + it("leaves other DCS strings (sixel, DECUDK) untouched", () => { |
| 1811 | + const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\"; |
| 1812 | + assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`); |
| 1813 | + assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`); |
| 1814 | + }); |
| 1815 | + }); |
| 1816 | +}); |
0 commit comments