sanitize.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /** Streaming terminal-control sanitizer for the line-oriented first release. */
  2. import { Buffer } from 'node:buffer'
  3. /** OSC marker emitted by the controlled bash before each prompt. */
  4. export const PROMPT_MARKER_PREFIX = '133;D;'
  5. /** One sanitized chunk plus whether it contained the owned prompt marker. */
  6. export interface SanitizedChunk {
  7. text: string
  8. prompt: boolean
  9. /** Present when printable text followed the latest owned prompt marker. */
  10. promptText?: true
  11. }
  12. /**
  13. * Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
  14. * Full terminal emulation is deliberately deferred; ordinary line output and
  15. * the private prompt marker are the supported contract.
  16. */
  17. export class TerminalSanitizer {
  18. private pending = ''
  19. private discardMode: 'osc' | 'csi' | undefined
  20. private discardOscEscape = false
  21. private trailingCarriageReturn = false
  22. private awaitingPromptText = false
  23. constructor(private readonly maxPendingBytes: number) {}
  24. /**
  25. * Consume one decoded `node-pty` data chunk.
  26. * @param chunk - decoded terminal data.
  27. * @returns Printable text and whether the private prompt marker completed.
  28. */
  29. push(chunk: string): SanitizedChunk {
  30. this.pending += this.discardPrefix(chunk)
  31. let text = ''
  32. let prompt = false
  33. let promptText = false
  34. let index = 0
  35. const appendText = (value: string): boolean => {
  36. text += value
  37. if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
  38. this.awaitingPromptText = false
  39. return true
  40. }
  41. return false
  42. }
  43. while (index < this.pending.length) {
  44. const escape = this.pending.indexOf('\x1b', index)
  45. if (escape < 0) {
  46. promptText = appendText(this.pending.slice(index)) || promptText
  47. index = this.pending.length
  48. break
  49. }
  50. promptText = appendText(this.pending.slice(index, escape)) || promptText
  51. if (escape + 1 >= this.pending.length) {
  52. index = escape
  53. break
  54. }
  55. const kind = this.pending[escape + 1]
  56. if (kind === ']') {
  57. const bel = this.pending.indexOf('\x07', escape + 2)
  58. const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
  59. let end = -1
  60. if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
  61. else if (bel >= 0) end = bel + 1
  62. else if (stringTerminator >= 0) end = stringTerminator + 2
  63. if (end < 0) {
  64. index = escape
  65. break
  66. }
  67. const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
  68. const content = this.pending.slice(escape + 2, end - terminatorBytes)
  69. if (content.startsWith(PROMPT_MARKER_PREFIX)) {
  70. prompt = true
  71. promptText = false
  72. this.awaitingPromptText = true
  73. }
  74. index = end
  75. continue
  76. }
  77. if (kind === '[') {
  78. let end = escape + 2
  79. while (end < this.pending.length) {
  80. const code = this.pending.charCodeAt(end)
  81. if (code >= 0x40 && code <= 0x7e) break
  82. end += 1
  83. }
  84. if (end >= this.pending.length) {
  85. index = escape
  86. break
  87. }
  88. index = end + 1
  89. continue
  90. }
  91. // Two-byte escape family (save/restore cursor and similar).
  92. index = escape + 2
  93. }
  94. this.pending = this.pending.slice(index)
  95. this.enforcePendingBound()
  96. return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
  97. }
  98. /**
  99. * Flush a trailing printable fragment when the PTY exits.
  100. * @returns Remaining printable text; incomplete escapes are discarded.
  101. */
  102. flush(): string {
  103. const text = this.pending.startsWith('\x1b') ? '' : this.pending
  104. this.pending = ''
  105. this.discardMode = undefined
  106. this.discardOscEscape = false
  107. this.awaitingPromptText = false
  108. const normalized = this.normalizeText(text)
  109. if (!this.trailingCarriageReturn) return normalized
  110. this.trailingCarriageReturn = false
  111. return `${normalized}\n`
  112. }
  113. private normalizeText(text: string): string {
  114. let complete = this.trailingCarriageReturn ? `\r${text}` : text
  115. this.trailingCarriageReturn = false
  116. if (complete.endsWith('\r')) {
  117. complete = complete.slice(0, -1)
  118. this.trailingCarriageReturn = true
  119. }
  120. return normalizeTerminalText(complete)
  121. }
  122. private enforcePendingBound(): void {
  123. if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
  124. this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
  125. this.pending = ''
  126. }
  127. private discardPrefix(chunk: string): string {
  128. if (this.discardMode === undefined) return chunk
  129. if (this.discardMode === 'csi') {
  130. for (let index = 0; index < chunk.length; index += 1) {
  131. const code = chunk.charCodeAt(index)
  132. if (code >= 0x40 && code <= 0x7e) {
  133. this.discardMode = undefined
  134. return chunk.slice(index + 1)
  135. }
  136. }
  137. return ''
  138. }
  139. let index = 0
  140. if (this.discardOscEscape) {
  141. this.discardOscEscape = false
  142. if (chunk.startsWith('\\')) {
  143. this.discardMode = undefined
  144. return chunk.slice(1)
  145. }
  146. }
  147. while (index < chunk.length) {
  148. if (chunk[index] === '\x07') {
  149. this.discardMode = undefined
  150. return chunk.slice(index + 1)
  151. }
  152. if (chunk[index] === '\x1b') {
  153. if (chunk[index + 1] === '\\') {
  154. this.discardMode = undefined
  155. return chunk.slice(index + 2)
  156. }
  157. if (index + 1 === chunk.length) this.discardOscEscape = true
  158. }
  159. index += 1
  160. }
  161. return ''
  162. }
  163. }
  164. /**
  165. * Normalize CRLF and standalone carriage returns for line-oriented rendering.
  166. * @param text - sanitized terminal text.
  167. * @returns Line-normalized text with BEL removed.
  168. */
  169. export function normalizeTerminalText(text: string): string {
  170. return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
  171. }