504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
|
|
type CsiSequence = {
|
||
|
|
raw: string;
|
||
|
|
end: number;
|
||
|
|
final: string;
|
||
|
|
params: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ESC = "\x1b";
|
||
|
|
const BEL = "\x07";
|
||
|
|
const ST = "\x9c";
|
||
|
|
const CSI = "\x9b";
|
||
|
|
const MAX_PENDING_ESCAPE_CHARS = 4096;
|
||
|
|
|
||
|
|
type ControlStringMode = "osc" | "string";
|
||
|
|
|
||
|
|
export type ReplaySafeTerminalLogSanitizerOptions = {
|
||
|
|
/** Seed when attaching mid-TUI so live redraws stay omitted until leave. */
|
||
|
|
alternateScreenActive?: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
export interface ReplaySafeTerminalLogSanitizer {
|
||
|
|
append(input: string): string;
|
||
|
|
finish(): string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isCsiFinal = (ch: string): boolean => ch >= "@" && ch <= "~";
|
||
|
|
|
||
|
|
const readCsiSequence = (input: string, index: number): CsiSequence | null => {
|
||
|
|
const isEscCsi = input[index] === ESC && input[index + 1] === "[";
|
||
|
|
const isC1Csi = input[index] === CSI;
|
||
|
|
if (!isEscCsi && !isC1Csi) return null;
|
||
|
|
|
||
|
|
const paramsStart = isEscCsi ? index + 2 : index + 1;
|
||
|
|
for (let i = paramsStart; i < input.length; i += 1) {
|
||
|
|
if (isCsiFinal(input[i])) {
|
||
|
|
return {
|
||
|
|
raw: input.slice(index, i + 1),
|
||
|
|
end: i + 1,
|
||
|
|
final: input[i],
|
||
|
|
params: input.slice(paramsStart, i),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const startsCsiSequence = (input: string, index: number): boolean =>
|
||
|
|
(input[index] === ESC && input[index + 1] === "[") || input[index] === CSI;
|
||
|
|
|
||
|
|
const isEscControlStringIntroducer = (ch: string): boolean =>
|
||
|
|
ch === "]" || ch === "P" || ch === "_" || ch === "^" || ch === "X";
|
||
|
|
|
||
|
|
const isC1ControlStringIntroducer = (ch: string): boolean =>
|
||
|
|
ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f";
|
||
|
|
|
||
|
|
const getControlStringStart = (
|
||
|
|
input: string,
|
||
|
|
index: number,
|
||
|
|
): { mode: ControlStringMode; dataStart: number } | null => {
|
||
|
|
const ch = input[index];
|
||
|
|
|
||
|
|
if (ch === ESC) {
|
||
|
|
const introducer = input[index + 1];
|
||
|
|
if (!isEscControlStringIntroducer(introducer)) return null;
|
||
|
|
return {
|
||
|
|
mode: introducer === "]" ? "osc" : "string",
|
||
|
|
dataStart: index + 2,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isC1ControlStringIntroducer(ch)) {
|
||
|
|
return {
|
||
|
|
mode: ch === "\x9d" ? "osc" : "string",
|
||
|
|
dataStart: index + 1,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const parseCsiParams = (params: string): Array<number | undefined> => {
|
||
|
|
const parameterBytes = params.replace(/[?><=]/g, "").replace(/[ -/]+$/g, "");
|
||
|
|
if (!parameterBytes) return [];
|
||
|
|
return parameterBytes.split(";").map((part) => {
|
||
|
|
if (!part) return undefined;
|
||
|
|
const n = Number.parseInt(part, 10);
|
||
|
|
return Number.isFinite(n) ? n : undefined;
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const normalizedPosition = (value: number | undefined): number => Math.max(1, value ?? 1);
|
||
|
|
|
||
|
|
const isCursorHomeSequence = (sequence: CsiSequence): boolean => {
|
||
|
|
if (sequence.final !== "H" && sequence.final !== "f") return false;
|
||
|
|
const values = parseCsiParams(sequence.params);
|
||
|
|
return normalizedPosition(values[0]) === 1 && normalizedPosition(values[1]) === 1;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isCursorMovementSequence = (sequence: CsiSequence): boolean =>
|
||
|
|
sequence.final === "A"
|
||
|
|
|| sequence.final === "B"
|
||
|
|
|| sequence.final === "C"
|
||
|
|
|| sequence.final === "D"
|
||
|
|
|| sequence.final === "E"
|
||
|
|
|| sequence.final === "F"
|
||
|
|
|| sequence.final === "H"
|
||
|
|
|| sequence.final === "f"
|
||
|
|
|| sequence.final === "G"
|
||
|
|
|| sequence.final === "`"
|
||
|
|
|| sequence.final === "d"
|
||
|
|
|| sequence.final === "a"
|
||
|
|
|| sequence.final === "e";
|
||
|
|
|
||
|
|
const isCursorStateSequence = (sequence: CsiSequence): boolean =>
|
||
|
|
sequence.final === "s"
|
||
|
|
|| sequence.final === "u"
|
||
|
|
|| (
|
||
|
|
(sequence.final === "h" || sequence.final === "l")
|
||
|
|
&& sequence.params.includes("?")
|
||
|
|
&& parseCsiParams(sequence.params).includes(1048)
|
||
|
|
);
|
||
|
|
|
||
|
|
const isUnsafeCursorReplaySequence = (sequence: CsiSequence): boolean =>
|
||
|
|
isCursorMovementSequence(sequence) || isCursorStateSequence(sequence);
|
||
|
|
|
||
|
|
const getEraseDisplayMode = (sequence: CsiSequence): number | null => {
|
||
|
|
if (sequence.final !== "J") return null;
|
||
|
|
const values = parseCsiParams(sequence.params);
|
||
|
|
return values[0] ?? 0;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isEraseSequence = (sequence: CsiSequence): boolean =>
|
||
|
|
sequence.final === "J" || sequence.final === "K" || sequence.final === "X";
|
||
|
|
|
||
|
|
const isSafePendingAfterCursorHomeSequence = (sequence: CsiSequence): boolean =>
|
||
|
|
!isUnsafeCursorReplaySequence(sequence) && !isEraseSequence(sequence);
|
||
|
|
|
||
|
|
const getAlternateScreenMode = (sequence: CsiSequence): "enter" | "exit" | null => {
|
||
|
|
if (sequence.final !== "h" && sequence.final !== "l") return null;
|
||
|
|
if (!sequence.params.includes("?")) return null;
|
||
|
|
const isAlternateScreen = parseCsiParams(sequence.params).some((value) =>
|
||
|
|
value === 47 || value === 1047 || value === 1049,
|
||
|
|
);
|
||
|
|
if (!isAlternateScreen) return null;
|
||
|
|
return sequence.final === "h" ? "enter" : "exit";
|
||
|
|
};
|
||
|
|
|
||
|
|
const isC1SingleCharCursorControl = (ch: string): boolean =>
|
||
|
|
ch === "\x84" || ch === "\x85" || ch === "\x8d";
|
||
|
|
|
||
|
|
const isEscSingleCharCursorControl = (ch: string): boolean =>
|
||
|
|
ch === "D" || ch === "E" || ch === "M";
|
||
|
|
|
||
|
|
// ESC or any C1 control (0x80-0x9f): every branch of the sanitizer's parser
|
||
|
|
// that can rewrite output starts at one of these characters.
|
||
|
|
// eslint-disable-next-line no-control-regex
|
||
|
|
const REPLAY_CONTROL_CANDIDATE = /[\u001b\u0080-\u009f]/;
|
||
|
|
// eslint-disable-next-line no-control-regex
|
||
|
|
const REPLAY_CONTROL_CANDIDATE_SCAN = /[\u001b\u0080-\u009f]/g;
|
||
|
|
|
||
|
|
const hasReplayControlCandidate = (input: string): boolean =>
|
||
|
|
REPLAY_CONTROL_CANDIDATE.test(input);
|
||
|
|
|
||
|
|
/** Index of the next ESC/C1 control at or after `from`, or -1. */
|
||
|
|
const nextReplayControlCandidate = (input: string, from: number): number => {
|
||
|
|
REPLAY_CONTROL_CANDIDATE_SCAN.lastIndex = from;
|
||
|
|
const match = REPLAY_CONTROL_CANDIDATE_SCAN.exec(input);
|
||
|
|
return match === null ? -1 : match.index;
|
||
|
|
};
|
||
|
|
|
||
|
|
class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitizer {
|
||
|
|
private pendingInput = "";
|
||
|
|
private pendingCursorHome = "";
|
||
|
|
private pendingAfterCursorHome = "";
|
||
|
|
private replaySafePendingAfterCursorHome = "";
|
||
|
|
private pendingAfterCursorHomeOverflowed = false;
|
||
|
|
private controlStringMode: ControlStringMode | null = null;
|
||
|
|
private controlStringEscPending = false;
|
||
|
|
private discardingCsi = false;
|
||
|
|
private inClearCluster = false;
|
||
|
|
private protectingClearedHistory = false;
|
||
|
|
private alternateScreenActive: boolean;
|
||
|
|
private hasOutput = false;
|
||
|
|
private lastOutputChar = "";
|
||
|
|
|
||
|
|
constructor(options: ReplaySafeTerminalLogSanitizerOptions = {}) {
|
||
|
|
this.alternateScreenActive = options.alternateScreenActive === true;
|
||
|
|
}
|
||
|
|
|
||
|
|
append(input: string): string {
|
||
|
|
let output = "";
|
||
|
|
const data = this.pendingInput + input;
|
||
|
|
this.pendingInput = "";
|
||
|
|
|
||
|
|
const appendOutput = (next: string) => {
|
||
|
|
if (!next) return;
|
||
|
|
output += next;
|
||
|
|
this.hasOutput = true;
|
||
|
|
this.lastOutputChar = next[next.length - 1];
|
||
|
|
};
|
||
|
|
|
||
|
|
if (
|
||
|
|
!this.alternateScreenActive
|
||
|
|
&& !this.pendingCursorHome
|
||
|
|
&& !this.discardingCsi
|
||
|
|
&& !this.controlStringMode
|
||
|
|
&& !hasReplayControlCandidate(data)
|
||
|
|
) {
|
||
|
|
appendOutput(data);
|
||
|
|
if (data) {
|
||
|
|
this.inClearCluster = false;
|
||
|
|
}
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
const flushPendingCursorHome = () => {
|
||
|
|
if (!this.pendingCursorHome) return;
|
||
|
|
if (this.protectingClearedHistory) {
|
||
|
|
appendOutput(this.replaySafePendingAfterCursorHome);
|
||
|
|
} else {
|
||
|
|
appendOutput(this.pendingCursorHome);
|
||
|
|
appendOutput(this.pendingAfterCursorHome);
|
||
|
|
}
|
||
|
|
this.pendingCursorHome = "";
|
||
|
|
this.pendingAfterCursorHome = "";
|
||
|
|
this.replaySafePendingAfterCursorHome = "";
|
||
|
|
this.pendingAfterCursorHomeOverflowed = false;
|
||
|
|
};
|
||
|
|
|
||
|
|
const emitClearSeparator = (preservePendingControls: boolean) => {
|
||
|
|
const preservedControls = preservePendingControls ? this.replaySafePendingAfterCursorHome : "";
|
||
|
|
this.pendingCursorHome = "";
|
||
|
|
this.pendingAfterCursorHome = "";
|
||
|
|
this.replaySafePendingAfterCursorHome = "";
|
||
|
|
this.pendingAfterCursorHomeOverflowed = false;
|
||
|
|
if (!this.inClearCluster && this.hasOutput) {
|
||
|
|
appendOutput(/[\r\n]$/.test(this.lastOutputChar) ? "\r\n" : "\r\n\r\n");
|
||
|
|
}
|
||
|
|
appendOutput(preservedControls);
|
||
|
|
this.inClearCluster = true;
|
||
|
|
this.protectingClearedHistory = true;
|
||
|
|
};
|
||
|
|
|
||
|
|
for (let i = 0; i < data.length;) {
|
||
|
|
if (this.discardingCsi) {
|
||
|
|
i = this.consumeDiscardedCsi(data, i);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.controlStringMode) {
|
||
|
|
i = this.consumeControlString(data, i);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const controlStringStart = getControlStringStart(data, i);
|
||
|
|
if (controlStringStart) {
|
||
|
|
this.controlStringMode = controlStringStart.mode;
|
||
|
|
this.controlStringEscPending = false;
|
||
|
|
i = controlStringStart.dataStart;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sequence = readCsiSequence(data, i);
|
||
|
|
|
||
|
|
if (!sequence && startsCsiSequence(data, i)) {
|
||
|
|
this.setPendingEscapeInput(data.slice(i));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (sequence) {
|
||
|
|
const alternateScreenMode = getAlternateScreenMode(sequence);
|
||
|
|
if (alternateScreenMode) {
|
||
|
|
this.alternateScreenActive = alternateScreenMode === "enter";
|
||
|
|
if (alternateScreenMode === "enter") {
|
||
|
|
this.pendingCursorHome = "";
|
||
|
|
this.pendingAfterCursorHome = "";
|
||
|
|
this.replaySafePendingAfterCursorHome = "";
|
||
|
|
this.pendingAfterCursorHomeOverflowed = false;
|
||
|
|
}
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.alternateScreenActive) {
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isCursorHomeSequence(sequence)) {
|
||
|
|
if (!this.inClearCluster) {
|
||
|
|
if (!this.pendingCursorHome) {
|
||
|
|
flushPendingCursorHome();
|
||
|
|
}
|
||
|
|
this.pendingCursorHome = sequence.raw;
|
||
|
|
}
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.protectingClearedHistory && isUnsafeCursorReplaySequence(sequence)) {
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const eraseMode = getEraseDisplayMode(sequence);
|
||
|
|
if (eraseMode !== null) {
|
||
|
|
if (eraseMode === 3) {
|
||
|
|
emitClearSeparator(false);
|
||
|
|
} else if (eraseMode === 1) {
|
||
|
|
emitClearSeparator(false);
|
||
|
|
} else if (eraseMode === 2 || (eraseMode === 0 && this.pendingCursorHome)) {
|
||
|
|
emitClearSeparator(true);
|
||
|
|
} else {
|
||
|
|
flushPendingCursorHome();
|
||
|
|
appendOutput(sequence.raw);
|
||
|
|
this.inClearCluster = false;
|
||
|
|
}
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.pendingCursorHome) {
|
||
|
|
this.appendPendingAfterCursorHome(sequence);
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const preserveClearCluster = this.inClearCluster;
|
||
|
|
flushPendingCursorHome();
|
||
|
|
appendOutput(sequence.raw);
|
||
|
|
this.inClearCluster = preserveClearCluster;
|
||
|
|
i = sequence.end;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
|
||
|
|
i += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.alternateScreenActive) {
|
||
|
|
if (data[i] === ESC && i + 1 >= data.length) {
|
||
|
|
this.setPendingEscapeInput(data.slice(i));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
// RIS (ESC c) fully resets the terminal, including leaving the
|
||
|
|
// alternate screen. Process it before the omit-scan skip so later
|
||
|
|
// shell output is not dropped from the connection log.
|
||
|
|
if (data[i] === ESC && data[i + 1] === "c") {
|
||
|
|
this.alternateScreenActive = false;
|
||
|
|
emitClearSeparator(false);
|
||
|
|
i += 2;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const nextControl = nextReplayControlCandidate(data, i + 1);
|
||
|
|
i = nextControl === -1 ? data.length : nextControl;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (data[i] === ESC) {
|
||
|
|
if (i + 1 >= data.length) {
|
||
|
|
this.setPendingEscapeInput(data.slice(i));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (data[i + 1] === "c") {
|
||
|
|
this.alternateScreenActive = false;
|
||
|
|
emitClearSeparator(false);
|
||
|
|
i += 2;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
|
||
|
|
i += 2;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
|
||
|
|
i += 2;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Plain span: no branch above can trigger until the next ESC/C1
|
||
|
|
// control. Hop there with a native scan and append it in one slice.
|
||
|
|
flushPendingCursorHome();
|
||
|
|
const nextControl = nextReplayControlCandidate(data, i + 1);
|
||
|
|
const end = nextControl === -1 ? data.length : nextControl;
|
||
|
|
appendOutput(data.slice(i, end));
|
||
|
|
this.inClearCluster = false;
|
||
|
|
i = end;
|
||
|
|
}
|
||
|
|
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
finish(): string {
|
||
|
|
this.pendingInput = "";
|
||
|
|
this.controlStringMode = null;
|
||
|
|
this.controlStringEscPending = false;
|
||
|
|
this.discardingCsi = false;
|
||
|
|
|
||
|
|
let output = "";
|
||
|
|
if (this.pendingCursorHome) {
|
||
|
|
output = this.pendingCursorHome + this.pendingAfterCursorHome;
|
||
|
|
this.hasOutput = true;
|
||
|
|
this.lastOutputChar = output[output.length - 1];
|
||
|
|
this.pendingCursorHome = "";
|
||
|
|
this.pendingAfterCursorHome = "";
|
||
|
|
this.replaySafePendingAfterCursorHome = "";
|
||
|
|
this.pendingAfterCursorHomeOverflowed = false;
|
||
|
|
}
|
||
|
|
return output;
|
||
|
|
}
|
||
|
|
|
||
|
|
private appendPendingAfterCursorHome(sequence: CsiSequence): void {
|
||
|
|
if (this.pendingAfterCursorHomeOverflowed) return;
|
||
|
|
|
||
|
|
const nextLength = this.pendingAfterCursorHome.length + sequence.raw.length;
|
||
|
|
if (nextLength > MAX_PENDING_ESCAPE_CHARS) {
|
||
|
|
this.pendingAfterCursorHome = "";
|
||
|
|
this.replaySafePendingAfterCursorHome = "";
|
||
|
|
this.pendingAfterCursorHomeOverflowed = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.pendingAfterCursorHome += sequence.raw;
|
||
|
|
if (isSafePendingAfterCursorHomeSequence(sequence)) {
|
||
|
|
this.replaySafePendingAfterCursorHome += sequence.raw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private setPendingEscapeInput(input: string): void {
|
||
|
|
if (input.length > MAX_PENDING_ESCAPE_CHARS) {
|
||
|
|
this.pendingInput = "";
|
||
|
|
this.discardingCsi = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.pendingInput = input;
|
||
|
|
}
|
||
|
|
|
||
|
|
private consumeDiscardedCsi(input: string, index: number): number {
|
||
|
|
for (let i = index; i < input.length; i += 1) {
|
||
|
|
if (isCsiFinal(input[i])) {
|
||
|
|
this.discardingCsi = false;
|
||
|
|
return i + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return input.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
private consumeControlString(input: string, index: number): number {
|
||
|
|
let i = index;
|
||
|
|
|
||
|
|
if (this.controlStringEscPending) {
|
||
|
|
if (input[i] === "\\") {
|
||
|
|
this.controlStringMode = null;
|
||
|
|
this.controlStringEscPending = false;
|
||
|
|
return i + 1;
|
||
|
|
}
|
||
|
|
this.controlStringEscPending = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (; i < input.length; i += 1) {
|
||
|
|
if (this.controlStringMode === "osc" && input[i] === BEL) {
|
||
|
|
this.controlStringMode = null;
|
||
|
|
return i + 1;
|
||
|
|
}
|
||
|
|
if (input[i] === ST) {
|
||
|
|
this.controlStringMode = null;
|
||
|
|
return i + 1;
|
||
|
|
}
|
||
|
|
if (input[i] === ESC) {
|
||
|
|
if (i + 1 >= input.length) {
|
||
|
|
this.controlStringEscPending = true;
|
||
|
|
return input.length;
|
||
|
|
}
|
||
|
|
if (input[i + 1] === "\\") {
|
||
|
|
this.controlStringMode = null;
|
||
|
|
return i + 2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return input.length;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const createReplaySafeTerminalLogSanitizer = (
|
||
|
|
options: ReplaySafeTerminalLogSanitizerOptions = {},
|
||
|
|
): ReplaySafeTerminalLogSanitizer =>
|
||
|
|
new ReplaySafeTerminalLogSanitizerImpl(options);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert terminal output into a form that can be replayed in LogView without
|
||
|
|
* allowing shell `clear` / ED2 / ED3 controls to wipe earlier log history.
|
||
|
|
*/
|
||
|
|
export function createReplaySafeTerminalLog(input: string): string {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
return sanitizer.append(input) + sanitizer.finish();
|
||
|
|
}
|