tui-td 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07ef26981840d9171eb2082a6c638c6f43869c05ffdb11c7fff857b058ce87e7
4
- data.tar.gz: 47d8dd453c1a393ad2f838b67226f92d50b97d5647fb775e36e95f9e381105e9
3
+ metadata.gz: 98d74d9d7481eabaca77e302ae6e86e1c0467222ebeabb26320fc4668c96af72
4
+ data.tar.gz: a666b663abf34a08b08c31e57b0348ae00020f304a6296012a027a26fbbb69a9
5
5
  SHA512:
6
- metadata.gz: 94ce617f9370428126bf1b565ca67787a455f6d64cd6273fc69c2cecc83ca293b07272cf3f227787ba4e56ae4bf914447752689115979d1d19863fd5d1e17545
7
- data.tar.gz: fdc72eaff712400c026469730cce5fe88daf65b2cca7fa25a071d79a1a2d53b648f5cd80dc8f2b11fe952ce1c9b4aeed9dde5e372715c22d877487d1db25cb52
6
+ metadata.gz: 22485763c32cac98cb98f8a2b4f2aaf8656c4955c97a28ff7cfdc3a3c2f82252c0b079c75630bb6d0e1ebd21a5c65d5f042be4c20e566502b6d747b88304a95d
7
+ data.tar.gz: e3d209ca2706c701efaf6e98af5d46af21ce7f1772aa0fe1a2ddbe9858442380f866883ac5c8948bb66d447843987e7ba7bfb77a6cc2aa08a6363d09deaa0320
data/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # CHANGELOG
2
2
 
3
- # CHANGELOG
3
+ ## 0.2.12
4
+
5
+ ### Security
6
+
7
+ - Command injection prevention: use Shellwords.shellsplit + array form of PTY.spawn
8
+ - Environment variable sanitization: block dangerous vars (PATH, LD_PRELOAD, etc.)
9
+ - Path traversal prevention: validate output paths for screenshot/HTML
10
+ - ReDoS prevention: add regex timeout in find_text
11
+
12
+ ### Fixed
13
+
14
+ - ANSI erase operations (ED/EL) now reset all cell attributes (fg, bg, bold, italic,
15
+ underline), not just the character — colors and styles no longer leak across lines
16
+
17
+ ### Architecture
18
+
19
+ - Extract ANSIParser, ANSIUtils, and State into standalone tans-parser gem (v0.1.0)
20
+ - Add tans-parser as a runtime dependency (~>0.1)
21
+ - Replace extracted unit tests with forwarder integration smoke tests
4
22
 
5
23
  ## 0.2.11
6
24
 
@@ -1,731 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module TUITD
4
- # Parses raw terminal output (ANSI escape sequences + text) into a
5
- # structured state representation.
6
- #
7
- # Handles:
8
- # - SGR (Select Graphic Rendition) — colors, bold, italic, underline
9
- # - Cursor movement (CUU, CUD, CUF, CUB, CUP)
10
- # - Erase (ED, EL)
11
- # - Line feed, carriage return, backspace, tab
12
- #
13
- # Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
14
- #
15
- # rubocop:disable Metrics/ModuleLength
16
- module ANSIParser
17
- SGR_COLORS = {
18
- 0 => :reset,
19
- 1 => :bold,
20
- 3 => :italic,
21
- 4 => :underline,
22
- 5 => :blink,
23
- 7 => :reverse,
24
- 22 => :normal,
25
- 23 => :no_italic,
26
- 24 => :no_underline,
27
- 30 => :black,
28
- 31 => :red,
29
- 32 => :green,
30
- 33 => :yellow,
31
- 34 => :blue,
32
- 35 => :magenta,
33
- 36 => :cyan,
34
- 37 => :white,
35
- 38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
36
- 39 => :default_fg,
37
- 40 => :bg_black,
38
- 41 => :bg_red,
39
- 42 => :bg_green,
40
- 43 => :bg_yellow,
41
- 44 => :bg_blue,
42
- 45 => :bg_magenta,
43
- 46 => :bg_cyan,
44
- 47 => :bg_white,
45
- 48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
46
- 49 => :default_bg,
47
- 90 => :bright_black,
48
- 91 => :bright_red,
49
- 92 => :bright_green,
50
- 93 => :bright_yellow,
51
- 94 => :bright_blue,
52
- 95 => :bright_magenta,
53
- 96 => :bright_cyan,
54
- 97 => :bright_white,
55
- 100 => :bg_bright_black,
56
- 101 => :bg_bright_red,
57
- 102 => :bg_bright_green,
58
- 103 => :bg_bright_yellow,
59
- 104 => :bg_bright_blue,
60
- 105 => :bg_bright_magenta,
61
- 106 => :bg_bright_cyan,
62
- 107 => :bg_bright_white,
63
- }.freeze
64
-
65
- SGR_16_TO_NAME = {
66
- 0 => "black",
67
- 1 => "red",
68
- 2 => "green",
69
- 3 => "yellow",
70
- 4 => "blue",
71
- 5 => "magenta",
72
- 6 => "cyan",
73
- 7 => "white",
74
- 8 => "bright_black",
75
- 9 => "bright_red",
76
- 10 => "bright_green",
77
- 11 => "bright_yellow",
78
- 12 => "bright_blue",
79
- 13 => "bright_magenta",
80
- 14 => "bright_cyan",
81
- 15 => "bright_white",
82
- }.freeze
83
-
84
- DEC_MAP = {
85
- "`" => "◆",
86
- "a" => "▒",
87
- "b" => "\u2409",
88
- "c" => "\u240C",
89
- "d" => "\u240D",
90
- "e" => "\u240A",
91
- "f" => "°",
92
- "g" => "±",
93
- "h" => "\u2424",
94
- "i" => "\u240B",
95
- "j" => "┘",
96
- "k" => "┐",
97
- "l" => "┌",
98
- "m" => "└",
99
- "n" => "┼",
100
- "o" => "⎺",
101
- "p" => "⎻",
102
- "q" => "─",
103
- "r" => "⎼",
104
- "s" => "⎽",
105
- "t" => "├",
106
- "u" => "┤",
107
- "v" => "┴",
108
- "w" => "┬",
109
- "x" => "│",
110
- "y" => "≤",
111
- "z" => "≥",
112
- "{" => "π",
113
- "|" => "≠",
114
- "}" => "£",
115
- "~" => "·",
116
- }.freeze
117
-
118
- # Parse raw terminal output into a structured state Hash
119
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
120
- def self.parse(raw, rows = 40, cols = 120)
121
- grid = Array.new(rows) do
122
- Array.new(cols) { default_cell.dup }
123
- end
124
-
125
- cursor = { row: 0, col: 0 }
126
- attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
127
- saved_cursor = nil
128
- scroll_region = { top: 0, bottom: rows - 1 }
129
- pending_dsr = false
130
-
131
- normal_grid = grid
132
- alt_grid = nil
133
-
134
- normal_cursor = cursor
135
- alt_cursor = { row: 0, col: 0 }
136
-
137
- normal_saved_cursor = nil
138
- alt_saved_cursor = nil
139
-
140
- use_alt_screen = false
141
-
142
- cursor_visible = true
143
- cursor_style = 1 # 1 = blinking block (default)
144
-
145
- g0_charset = :ascii
146
- g1_charset = :dec
147
- active_charset = :g0
148
-
149
- mouse_mode = :none
150
- mouse_format = :normal
151
-
152
- # Strip everything before the last full clear (if any)
153
- # to avoid accumulated garbage
154
- processed = raw
155
-
156
- i = 0
157
- while i < processed.length
158
- if processed[i] == "\e" && processed[i + 1] == "["
159
- # Find end of CSI sequence
160
- j = i + 2
161
- j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsuq]/)
162
- seq = processed[i..j]
163
-
164
- dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
165
- pending_dsr ||= dsr
166
-
167
- if new_saved
168
- if use_alt_screen
169
- alt_saved_cursor = new_saved
170
- saved_cursor = alt_saved_cursor
171
- else
172
- normal_saved_cursor = new_saved
173
- saved_cursor = normal_saved_cursor
174
- end
175
- end
176
-
177
- if action.key?(:alt_screen)
178
- new_alt = action[:alt_screen]
179
- code = action[:alt_screen_code]
180
- if new_alt != use_alt_screen
181
- if new_alt
182
- # Switch to Alternate Screen
183
- # Save normal cursor
184
- normal_cursor = { row: cursor[:row], col: cursor[:col] }
185
-
186
- # Lazy initialize alt grid
187
- alt_grid ||= Array.new(rows) do
188
- Array.new(cols) { default_cell.dup }
189
- end
190
-
191
- # For \e[?1049h, clear alternate screen and reset cursor to 0,0
192
- if code == 1049
193
- alt_grid = Array.new(rows) do
194
- Array.new(cols) { default_cell.dup }
195
- end
196
- alt_cursor = { row: 0, col: 0 }
197
- end
198
-
199
- grid = alt_grid
200
- cursor = alt_cursor
201
- saved_cursor = alt_saved_cursor
202
- use_alt_screen = true
203
- else
204
- # Switch to Normal Screen
205
- # Save alt cursor
206
- alt_cursor = { row: cursor[:row], col: cursor[:col] }
207
-
208
- grid = normal_grid
209
- cursor = normal_cursor
210
- saved_cursor = normal_saved_cursor
211
- use_alt_screen = false
212
- end
213
- end
214
- end
215
-
216
- cursor_visible = action[:cursor_visible] if action.key?(:cursor_visible)
217
-
218
- cursor_style = action[:cursor_style] if action.key?(:cursor_style)
219
-
220
- mouse_mode = action[:mouse_mode] if action.key?(:mouse_mode)
221
-
222
- mouse_format = action[:mouse_format] if action.key?(:mouse_format)
223
-
224
- i = j + 1
225
- elsif ["\n", "\r\n"].include?(processed[i])
226
- cursor[:row] += 1
227
- cursor[:col] = 0
228
- i += processed[i..(i + 1)] == "\r\n" ? 2 : 1
229
- elsif processed[i] == "\r"
230
- cursor[:col] = 0
231
- i += 1
232
- elsif processed[i] == "\t"
233
- cursor[:col] = ((cursor[:col] / 8) + 1) * 8
234
- cursor[:col] = cols - 1 if cursor[:col] >= cols
235
- i += 1
236
- elsif processed[i] == "\b"
237
- cursor[:col] -= 1 if cursor[:col].positive?
238
- i += 1
239
- elsif processed[i] == "\a"
240
- # Bell — ignore
241
- i += 1
242
- elsif processed[i] == "\x0e"
243
- active_charset = :g1
244
- i += 1
245
- elsif processed[i] == "\x0f"
246
- active_charset = :g0
247
- i += 1
248
- elsif processed[i] == "\e"
249
- # Handle non-CSI escape sequences
250
- if processed[i + 1] == "7"
251
- # DECSC — Save Cursor
252
- if use_alt_screen
253
- alt_saved_cursor = { row: cursor[:row], col: cursor[:col] }
254
- saved_cursor = alt_saved_cursor
255
- else
256
- normal_saved_cursor = { row: cursor[:row], col: cursor[:col] }
257
- saved_cursor = normal_saved_cursor
258
- end
259
- i += 2
260
- elsif processed[i + 1] == "8"
261
- # DECRC — Restore Cursor
262
- if saved_cursor
263
- cursor[:row] = saved_cursor[:row]
264
- cursor[:col] = saved_cursor[:col]
265
- end
266
- i += 2
267
- elsif processed[i + 1] == "(" && %w[0 B].include?(processed[i + 2])
268
- g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
269
- i += 3
270
- elsif processed[i + 1] == ")" && %w[0 B].include?(processed[i + 2])
271
- g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
272
- i += 3
273
- elsif processed[i + 1]&.match?(%r{[()*+\-./]})
274
- # Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
275
- i += 3
276
- else
277
- i += 1
278
- end
279
- elsif (char, char_len = _utf8_char_at(processed, i))
280
- # Printable character (including multi-byte UTF-8)
281
- if cursor[:row] < rows && cursor[:col] < cols
282
- cell = grid[cursor[:row]][cursor[:col]]
283
- current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
284
- mapped_char = char
285
- mapped_char = DEC_MAP[char] if current_charset == :dec && DEC_MAP.key?(char)
286
- cell[:char] = mapped_char
287
- cell.merge!(attrs)
288
- cursor[:col] += 1
289
- cursor[:col] = cols - 1 if cursor[:col] >= cols
290
- end
291
- i += char_len
292
- else # rubocop:disable Lint/DuplicateBranch
293
- i += 1
294
- end
295
-
296
- # Handle scrolling within the defined scroll region
297
- region_top = scroll_region[:top]
298
- region_bottom = scroll_region[:bottom]
299
-
300
- next unless cursor[:row] > region_bottom
301
-
302
- scroll_lines = [cursor[:row] - region_bottom, rows].min
303
- # Shift lines within the scroll region up
304
- (region_top..(region_bottom - scroll_lines)).each do |ri|
305
- src = ri + scroll_lines
306
- grid[ri] = src <= region_bottom ? grid[src] : Array.new(cols) { default_cell.dup }
307
- end
308
- # Fill bottom of scroll region with blank lines
309
- ((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
310
- grid[ri] = Array.new(cols) { default_cell.dup }
311
- end
312
- cursor[:row] = region_bottom
313
- end
314
-
315
- {
316
- size: { rows: rows, cols: cols },
317
- cursor: {
318
- row: cursor[:row],
319
- col: cursor[:col],
320
- visible: cursor_visible,
321
- style: cursor_style,
322
- },
323
- rows: grid,
324
- pending_dsr: pending_dsr,
325
- cursor_visible: cursor_visible,
326
- cursor_style: cursor_style,
327
- mouse_mode: mouse_mode,
328
- mouse_format: mouse_format,
329
- }
330
- end
331
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
332
-
333
- # Rebuild ANSI output from a state hash (for rendering/screenshot)
334
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
335
- def self.build_frame(state)
336
- rows = state.dig(:size, :rows) || state["size"]["rows"]
337
- state.dig(:size, :cols) || state["size"]["cols"]
338
- grid = state[:rows] || state["rows"]
339
- cursor = state[:cursor] || state["cursor"]
340
- mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
341
- mouse_format = state[:mouse_format] || state["mouse_format"] || :normal
342
-
343
- out = +""
344
- out << "\e[0m"
345
- out << "\e[2J\e[H"
346
-
347
- grid.each_with_index do |row, ri|
348
- row.each_with_index do |cell, _ci|
349
- char = cell[:char] || cell["char"] || " "
350
- fg = cell[:fg] || cell["fg"] || "default"
351
- bg = cell[:bg] || cell["bg"] || "default"
352
- bold = cell[:bold] || cell["bold"] || false
353
- italic = cell[:italic] || cell["italic"] || false
354
- underline = cell[:underline] || cell["underline"] || false
355
- blink = cell[:blink] || cell["blink"] || false
356
-
357
- codes = []
358
- codes << "1" if bold
359
- codes << "3" if italic
360
- codes << "4" if underline
361
- codes << "5" if blink
3
+ require "tans-parser"
362
4
 
363
- fg_code = _color_code(fg, "38")
364
- bg_code = _color_code(bg, "48")
365
-
366
- codes << fg_code if fg_code
367
- codes << bg_code if bg_code
368
-
369
- out << "\e[#{codes.join(";")}m" unless codes.empty?
370
- out << char
371
- end
372
- out << "\n" if ri < rows - 1
373
- end
374
-
375
- # Reconstruct cursor visibility
376
- cursor_vis = true
377
- cursor_vis = cursor[:visible] != false && cursor["visible"] != false if cursor.is_a?(Hash)
378
- out << (cursor_vis ? "\e[?25h" : "\e[?25l")
379
-
380
- # Reconstruct cursor style
381
- if cursor.is_a?(Hash)
382
- style = cursor[:style] || cursor["style"]
383
- out << "\e[#{style} q" if style
384
- end
385
-
386
- # Reconstruct mouse mode and format
387
- out << case mouse_mode
388
- when :normal
389
- "\e[?1000h"
390
- when :drag
391
- "\e[?1002h"
392
- when :all
393
- "\e[?1003h"
394
- else
395
- "\e[?1000l\e[?1002l\e[?1003l"
396
- end
397
-
398
- out << if mouse_format == :sgr
399
- "\e[?1006h"
400
- else
401
- "\e[?1006l"
402
- end
403
-
404
- out << "\e[0m"
405
- out
406
- end
407
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
408
-
409
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
410
- def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
411
- # Strip leading escape char if present
412
- cleaned = seq.sub(/^\e/, "")
413
- match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@`fhlmnRrsuq])$/)
414
- return [false, nil, {}] unless match
415
-
416
- is_private = (match[1] == "?")
417
- params = match[2].split(";").map(&:to_i)
418
- space = match[3]
419
- command = match[4]
420
-
421
- new_saved = nil
422
- action = {}
423
-
424
- case command
425
- when "m"
426
- _apply_sgr(params, attrs)
427
- when "A" # CUU — Cursor Up
428
- n = params[0] || 1
429
- n = 1 if n.zero?
430
- cursor[:row] = [cursor[:row] - n, 0].max
431
- when "B" # CUD — Cursor Down
432
- n = params[0] || 1
433
- n = 1 if n.zero?
434
- cursor[:row] = [cursor[:row] + n, rows - 1].min
435
- when "C" # CUF — Cursor Forward
436
- n = params[0] || 1
437
- n = 1 if n.zero?
438
- cursor[:col] = [cursor[:col] + n, cols - 1].min
439
- when "D" # CUB — Cursor Back
440
- n = params[0] || 1
441
- n = 1 if n.zero?
442
- cursor[:col] = [cursor[:col] - n, 0].max
443
- when "H", "f" # CUP — Cursor Position
444
- r = (params[0] || 1) - 1
445
- c = (params[1] || 1) - 1
446
- cursor[:row] = r.clamp(0, rows - 1)
447
- cursor[:col] = c.clamp(0, cols - 1)
448
- when "J" # ED — Erase in Display
449
- case params[0]
450
- when nil, 0
451
- _erase_down(cursor, grid, rows, cols)
452
- when 1
453
- _erase_up(cursor, grid, cols)
454
- when 2, 3
455
- _erase_all(grid, rows, cols)
456
- cursor[:row] = 0
457
- cursor[:col] = 0
458
- end
459
- when "K" # EL — Erase in Line
460
- case params[0]
461
- when nil, 0
462
- _erase_line_right(cursor, grid, cols)
463
- when 1
464
- _erase_line_left(cursor, grid, cols)
465
- when 2
466
- _erase_line(cursor, grid, cols)
467
- end
468
- when "X" # Erase Characters
469
- n = params[0] || 1
470
- n.times do |i|
471
- next unless cursor[:row] < rows && cursor[:col] + i < cols
472
-
473
- grid[cursor[:row]][cursor[:col] + i][:char] = " "
474
- end
475
- when "s" # DECSC — Save Cursor (CSI variant)
476
- new_saved = { row: cursor[:row], col: cursor[:col] }
477
- when "u" # DECRC — Restore Cursor (CSI variant)
478
- if saved_cursor
479
- cursor[:row] = saved_cursor[:row]
480
- cursor[:col] = saved_cursor[:col]
481
- end
482
- when "r" # DECSTBM — Set Scroll Region
483
- top = (params[0] || 1) - 1
484
- bottom = (params[1] || rows) - 1
485
- top = top.clamp(0, rows - 1)
486
- bottom = bottom.clamp(0, rows - 1)
487
- if top < bottom
488
- scroll_region[:top] = top
489
- scroll_region[:bottom] = bottom
490
- else
491
- scroll_region[:top] = 0
492
- scroll_region[:bottom] = rows - 1
493
- end
494
- cursor[:row] = 0
495
- cursor[:col] = 0
496
- when "h"
497
- if is_private
498
- params.each do |p|
499
- case p
500
- when 47, 1047, 1049
501
- action[:alt_screen] = true
502
- action[:alt_screen_code] = p
503
- when 25
504
- action[:cursor_visible] = true
505
- when 1000
506
- action[:mouse_mode] = :normal
507
- when 1002
508
- action[:mouse_mode] = :drag
509
- when 1003
510
- action[:mouse_mode] = :all
511
- when 1006
512
- action[:mouse_format] = :sgr
513
- end
514
- end
515
- end
516
- when "l"
517
- if is_private
518
- params.each do |p|
519
- case p
520
- when 47, 1047, 1049
521
- action[:alt_screen] = false
522
- action[:alt_screen_code] = p
523
- when 25
524
- action[:cursor_visible] = false
525
- when 1000, 1002, 1003
526
- action[:mouse_mode] = :none
527
- when 1006
528
- action[:mouse_format] = :normal
529
- end
530
- end
531
- end
532
- when "q"
533
- if space == " "
534
- style_val = params[0] || 0
535
- action[:cursor_style] = style_val
536
- end
537
- when "n" # DSR — Device Status Report request
538
- return [params[0] == 6, nil, {}]
539
- when "R" # DSR response (from terminal side) or CPR — ignore
540
- nil
541
- end
542
-
543
- [false, new_saved, action]
544
- end
545
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
546
-
547
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
548
- def self._apply_sgr(params, attrs)
549
- if params.empty? || params == [0]
550
- return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false,
551
- blink: false,)
552
- end
553
-
554
- i = 0
555
- while i < params.length
556
- p = params[i]
557
- case p
558
- when 0
559
- attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
560
- when 1
561
- attrs[:bold] = true
562
- when 3
563
- attrs[:italic] = true
564
- when 4
565
- attrs[:underline] = true
566
- when 5, 6
567
- attrs[:blink] = true
568
- when 22
569
- attrs[:bold] = false
570
- when 23
571
- attrs[:italic] = false
572
- when 24
573
- attrs[:underline] = false
574
- when 25
575
- attrs[:blink] = false
576
- when 7, 27
577
- # Reverse — swap fg and bg
578
- attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
579
- when 30..37
580
- attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
581
- when 38
582
- # Extended foreground
583
- if params[i + 1] == 5
584
- color = params[i + 2]
585
- attrs[:fg] = "color#{color}"
586
- i += 2
587
- elsif params[i + 1] == 2
588
- r = params[i + 2]
589
- g = params[i + 3]
590
- b = params[i + 4]
591
- attrs[:fg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
592
- i += 4
593
- end
594
- when 39
595
- attrs[:fg] = "default"
596
- when 40..47
597
- attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
598
- when 48
599
- # Extended background
600
- if params[i + 1] == 5
601
- color = params[i + 2]
602
- attrs[:bg] = "color#{color}"
603
- i += 2
604
- elsif params[i + 1] == 2
605
- r = params[i + 2]
606
- g = params[i + 3]
607
- b = params[i + 4]
608
- attrs[:bg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
609
- i += 4
610
- end
611
- when 49
612
- attrs[:bg] = "default"
613
- when 90..97
614
- attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
615
- when 100..107
616
- attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
617
- end
618
- i += 1
619
- end
620
- end
621
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
622
-
623
- def self._erase_down(cursor, grid, rows, cols)
624
- r = cursor[:row]
625
- c = cursor[:col]
626
-
627
- # Erase from cursor to end of line
628
- (c...cols).each { |ci| grid[r][ci][:char] = " " if r < rows }
629
-
630
- # Erase remaining lines
631
- ((r + 1)...rows).each do |ri|
632
- cols.times { |ci| grid[ri][ci][:char] = " " }
633
- end
634
- end
635
-
636
- def self._erase_up(cursor, grid, cols)
637
- r = cursor[:row]
638
- c = cursor[:col]
639
-
640
- # Erase lines above cursor
641
- (0...r).each do |ri|
642
- cols.times { |ci| grid[ri][ci][:char] = " " }
643
- end
644
-
645
- # Erase from start of line to cursor
646
- (0..c).each { |ci| grid[r][ci][:char] = " " }
647
- end
648
-
649
- def self._erase_all(grid, rows, cols)
650
- rows.times do |ri|
651
- cols.times { |ci| grid[ri][ci][:char] = " " }
652
- end
653
- end
654
-
655
- def self._erase_line_right(cursor, grid, cols)
656
- r = cursor[:row]
657
- c = cursor[:col]
658
- (c...cols).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
659
- end
660
-
661
- def self._erase_line_left(cursor, grid, _cols)
662
- r = cursor[:row]
663
- c = cursor[:col]
664
- (0..c).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
665
- end
666
-
667
- def self._erase_line(cursor, grid, cols)
668
- r = cursor[:row]
669
- cols.times { |ci| grid[r][ci][:char] = " " if r < grid.length }
670
- end
671
-
672
- # rubocop:disable Metrics/CyclomaticComplexity
673
- def self._color_code(name, prefix)
674
- case name
675
- when "default" then nil
676
- when /^#([0-9a-fA-F]{6})$/
677
- r = ::Regexp.last_match(1)[0..1].to_i(16)
678
- g = ::Regexp.last_match(1)[2..3].to_i(16)
679
- b = ::Regexp.last_match(1)[4..5].to_i(16)
680
- "#{prefix};2;#{r};#{g};#{b}"
681
- when /^(bright_)?(.+)$/
682
- base_name = ::Regexp.last_match(2)
683
- index = SGR_16_TO_NAME.key(base_name)
684
- index += 8 if ::Regexp.last_match(1) && index && index < 8
685
- index ? "#{prefix};5;#{index}" : nil
686
- end
687
- end
688
- # rubocop:enable Metrics/CyclomaticComplexity
689
-
690
- def self.default_cell
691
- { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
692
- end
693
-
694
- # Extract a single UTF-8 character at position i in a binary string.
695
- # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
696
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
697
- def self._utf8_char_at(str, i)
698
- byte = str.getbyte(i)
699
- return nil unless byte
700
-
701
- if byte < 0x80
702
- # Single-byte ASCII
703
- return nil unless byte >= 0x20 # only printable, skip control chars
704
-
705
- return [byte.chr, 1]
706
- end
707
-
708
- # Multi-byte UTF-8
709
- len = if byte & 0xE0 == 0xC0
710
- 2
711
- elsif byte & 0xF0 == 0xE0
712
- 3
713
- elsif byte & 0xF8 == 0xF0
714
- 4
715
- else
716
- return nil # continuation byte or invalid — let main loop advance
717
- end
718
- return nil if i + len > str.bytesize
719
-
720
- bytes = str.byteslice(i, len)
721
- char = bytes.dup.force_encoding("UTF-8")
722
- return nil unless char.valid_encoding?
723
-
724
- [char, len]
725
- rescue StandardError
726
- nil
727
- end
728
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
729
- end
730
- # rubocop:enable Metrics/ModuleLength
5
+ module TUITD
6
+ ANSIParser = TansParser::ANSIParser
731
7
  end
@@ -1,77 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module TUITD
4
- # Shared ANSI color constants and helpers.
5
- # Used by Screenshot, HtmlRenderer, and other color-aware renderers.
6
- module ANSIUtils
7
- ANSI_RGB = {
8
- "black" => [0x00, 0x00, 0x00],
9
- "red" => [0xAA, 0x00, 0x00],
10
- "green" => [0x00, 0xAA, 0x00],
11
- "yellow" => [0xAA, 0x55, 0x00],
12
- "blue" => [0x00, 0x00, 0xAA],
13
- "magenta" => [0xAA, 0x00, 0xAA],
14
- "cyan" => [0x00, 0xAA, 0xAA],
15
- "white" => [0xAA, 0xAA, 0xAA],
16
- "bright_black" => [0x55, 0x55, 0x55],
17
- "bright_red" => [0xFF, 0x55, 0x55],
18
- "bright_green" => [0x55, 0xFF, 0x55],
19
- "bright_yellow" => [0xFF, 0xFF, 0x55],
20
- "bright_blue" => [0x55, 0x55, 0xFF],
21
- "bright_magenta" => [0xFF, 0x55, 0xFF],
22
- "bright_cyan" => [0x55, 0xFF, 0xFF],
23
- "bright_white" => [0xFF, 0xFF, 0xFF],
24
- }.freeze
25
-
26
- CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
27
-
28
- ANSI_INDEX = %w[
29
- black red green yellow blue magenta cyan white
30
- bright_black bright_red bright_green bright_yellow
31
- bright_blue bright_magenta bright_cyan bright_white
32
- ].freeze
33
-
34
- DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
35
- DEFAULT_BG = [0x00, 0x00, 0x00].freeze
3
+ require "tans-parser"
36
4
 
37
- def resolve_color(name, fallback)
38
- case name
39
- when "default"
40
- fallback
41
- when /^#([0-9a-fA-F]{6})$/
42
- [::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
43
- ::Regexp.last_match(1)[4..5].to_i(16),]
44
- when /\Acolor(\d+)\z/
45
- xterm_256(::Regexp.last_match(1).to_i)
46
- when /\Abright_(.+)\z/
47
- ANSI_RGB[name] || fallback
48
- else # rubocop:disable Lint/DuplicateBranch
49
- ANSI_RGB[name] || fallback
50
- end
51
- end
52
-
53
- def xterm_256(index) # rubocop:disable Naming/VariableNumber
54
- if index < 16
55
- name = ANSI_INDEX[index]
56
- ANSI_RGB[name] || DEFAULT_FG
57
- elsif index < 232
58
- r = CUBE[((index - 16) / 36) % 6]
59
- g = CUBE[((index - 16) / 6) % 6]
60
- b = CUBE[(index - 16) % 6]
61
- [r, g, b]
62
- else
63
- v = 8 + ((index - 232) * 10)
64
- [v, v, v]
65
- end
66
- end
67
-
68
- def _dig(hash, *keys)
69
- keys.each do |k|
70
- return nil unless hash
71
-
72
- hash = hash[k] || hash[k.to_s]
73
- end
74
- hash
75
- end
76
- end
5
+ module TUITD
6
+ ANSIUtils = TansParser::ANSIUtils
77
7
  end
data/lib/tui_td/driver.rb CHANGED
@@ -6,6 +6,7 @@
6
6
  require "pty"
7
7
  require "io/console"
8
8
  require "json"
9
+ require "shellwords"
9
10
 
10
11
  module TUITD
11
12
  # Drives a TUI application in a pseudo-terminal (PTY).
@@ -20,6 +21,9 @@ module TUITD
20
21
  # driver.close
21
22
  #
22
23
  class Driver
24
+ FORBIDDEN_ENV = %w[PATH LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES
25
+ DYLD_FRAMEWORK_PATH RUBYOPT HOME RUBYLIB GEM_HOME GEM_PATH].freeze
26
+
23
27
  attr_reader :command, :state
24
28
 
25
29
  def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
@@ -28,7 +32,7 @@ module TUITD
28
32
  @cols = cols
29
33
  @timeout = timeout
30
34
  @chdir = chdir
31
- @env = env
35
+ @env = sanitize_env(env)
32
36
  @state = nil
33
37
  @stdin = nil
34
38
  @stdout = nil
@@ -46,7 +50,8 @@ module TUITD
46
50
  spawn_opts = {}
47
51
  spawn_opts[:chdir] = @chdir if @chdir
48
52
 
49
- @stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
53
+ cmd_args = Shellwords.shellsplit(@command)
54
+ @stdout, @stdin, @pid = PTY.spawn(env, *cmd_args, spawn_opts)
50
55
  @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
51
56
  @wait_thr = Process.detach(@pid)
52
57
 
@@ -246,6 +251,10 @@ module TUITD
246
251
  @reader_thread = nil
247
252
  end
248
253
 
254
+ def sanitize_env(env)
255
+ env.reject { |k, _| FORBIDDEN_ENV.include?(k.to_s.upcase) }
256
+ end
257
+
249
258
  def ensure_running!
250
259
  raise Error, "Driver not started. Call #start first." if @stdin.nil?
251
260
  raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
@@ -60,6 +60,8 @@ module TUITD
60
60
  @driver&.close
61
61
  end
62
62
 
63
+ ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
64
+
63
65
  private
64
66
 
65
67
  def handle_request(request)
@@ -433,7 +435,7 @@ module TUITD
433
435
 
434
436
  def call_tui_screenshot(args)
435
437
  ensure_driver!
436
- path = args["path"] || "/tmp/tui_td_#{Time.now.to_i}.png"
438
+ path = safe_path(args["path"], ext: "png")
437
439
  result = @driver.screenshot(path)
438
440
  "OK: Screenshot saved to #{result}"
439
441
  end
@@ -444,8 +446,9 @@ module TUITD
444
446
  renderer = HtmlRenderer.new(@driver.state_data)
445
447
 
446
448
  if path
447
- renderer.render(path)
448
- "OK: HTML saved to #{path}"
449
+ safe = safe_path(path, ext: "html")
450
+ renderer.render(safe)
451
+ "OK: HTML saved to #{safe}"
449
452
  else
450
453
  renderer.to_html
451
454
  end
@@ -493,6 +496,17 @@ module TUITD
493
496
 
494
497
  # --- Helpers ---
495
498
 
499
+ def safe_path(user_path, ext:)
500
+ default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
501
+ resolved = File.expand_path(user_path || default)
502
+
503
+ unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
504
+ raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
505
+ end
506
+
507
+ resolved
508
+ end
509
+
496
510
  def ensure_driver!
497
511
  raise Error, "No TUI session active. Call tui_start first." if @driver.nil?
498
512
  end
data/lib/tui_td/state.rb CHANGED
@@ -1,128 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
3
+ require "tans-parser"
4
4
 
5
5
  module TUITD
6
- # Represents the parsed state of a terminal screen.
7
- # Provides high-level query methods for AI consumption.
8
- class State
9
- attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
10
-
11
- def initialize(data)
12
- raise ArgumentError, "State data must include :size key" unless data[:size]
13
- raise ArgumentError, "State data must include :rows key" unless data[:rows]
14
-
15
- @rows = data[:size][:rows]
16
- @cols = data[:size][:cols]
17
- @grid = data[:rows]
18
- @cursor = data[:cursor]
19
-
20
- cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
21
- @cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
22
- @cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
23
-
24
- @mouse_mode = data[:mouse_mode] || :none
25
- @mouse_format = data[:mouse_format] || :normal
26
- end
27
-
28
- # Get plain text of the entire terminal (no ANSI)
29
- def plain_text
30
- @grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
31
- end
32
-
33
- # Get text at a specific position
34
- def text_at(row, col, length = @cols - col)
35
- return "" if row >= @rows || col >= @cols
36
-
37
- @grid[row][col, length].map { |c| c[:char] }.join
38
- end
39
-
40
- # Search for text across the entire terminal
41
- def find_text(pattern)
42
- results = []
43
- @grid.each_with_index do |row, ri|
44
- text = row.map { |c| c[:char] }.join
45
- pos = 0
46
- while (match = text.index(pattern, pos))
47
- results << { row: ri, col: match, text: pattern, full_line: text }
48
- pos = match + 1
49
- end
50
- end
51
- results
52
- end
53
-
54
- # Get the color at a specific cell
55
- def foreground_at(row, col)
56
- return nil if row >= @rows || col >= @cols
57
-
58
- @grid[row][col][:fg]
59
- end
60
-
61
- def background_at(row, col)
62
- return nil if row >= @rows || col >= @cols
63
-
64
- @grid[row][col][:bg]
65
- end
66
-
67
- def style_at(row, col)
68
- return nil if row >= @rows || col >= @cols
69
-
70
- cell = @grid[row][col]
71
- { bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
72
- end
73
-
74
- def to_ai_json
75
- h = extract_highlights
76
- cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
77
- r = cursor_info[:row] || cursor_info["row"] || 0
78
- c = cursor_info[:col] || cursor_info["col"] || 0
79
- styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
80
-
81
- summary = "Cursor at [#{r},#{c}]. "
82
- summary << "#{styled_count} styled row#{"s" unless styled_count == 1}"
83
- fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
84
- bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
85
- summary << ", colors: fg=#{fgs.sort.join(",")}" unless fgs.empty?
86
- summary << ", bg=#{bgs.sort.join(",")}" unless bgs.empty?
87
- summary << "."
88
-
89
- {
90
- size: { rows: @rows, cols: @cols },
91
- cursor: cursor_info,
92
- text: plain_text,
93
- highlights: h,
94
- summary: summary,
95
- }
96
- end
97
-
98
- private
99
-
100
- def extract_highlights
101
- highlights = []
102
- @grid.each_with_index do |row, ri|
103
- row_text = row.map { |c| c[:char] }.join
104
- next if row_text.strip.empty?
105
-
106
- fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
107
- .uniq.reject { |c| c == "default" }
108
- bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
109
- .uniq.reject { |c| c == "default" }
110
- bold = row.any? { |c| c[:bold] || c["bold"] }
111
- italic = row.any? { |c| c[:italic] || c["italic"] }
112
- underline = row.any? { |c| c[:underline] || c["underline"] }
113
-
114
- next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
115
-
116
- h = { row: ri, text: row_text }
117
- h[:bold] = true if bold
118
- h[:italic] = true if italic
119
- h[:underline] = true if underline
120
- h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
121
- h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
122
- highlights << h
123
- end
124
- highlights
125
- end
126
- end
6
+ State = TansParser::State
127
7
  end
128
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
4
4
 
5
5
  require "json"
6
+ require "shellwords"
6
7
 
7
8
  module TUITD
8
9
  # Executes TUI tests defined in JSON format.
@@ -124,13 +125,13 @@ module TUITD
124
125
 
125
126
  when "screenshot"
126
127
  ensure_driver!(driver)
127
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.png"
128
+ path = safe_output_path(value, "png")
128
129
  driver.screenshot(path)
129
130
  Result.new(step: action, passed: true, message: "Saved: #{path}")
130
131
 
131
132
  when "html"
132
133
  ensure_driver!(driver)
133
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.html"
134
+ path = safe_output_path(value, "html")
134
135
  HtmlRenderer.new(driver.state_data).render(path)
135
136
  Result.new(step: action, passed: true, message: "Saved: #{path}")
136
137
 
@@ -194,8 +195,21 @@ module TUITD
194
195
  }
195
196
  end
196
197
 
198
+ ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
199
+
197
200
  private
198
201
 
202
+ def safe_output_path(value, ext)
203
+ default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
204
+ resolved = File.expand_path(value.is_a?(String) ? value : default)
205
+
206
+ unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
207
+ raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
208
+ end
209
+
210
+ resolved
211
+ end
212
+
199
213
  def ensure_driver!(driver)
200
214
  raise Error, "No session. Add a 'start' step first." if driver.nil?
201
215
  end
@@ -265,4 +279,4 @@ module TUITD
265
279
  end
266
280
  end
267
281
  end
268
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
282
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.11"
4
+ VERSION = "0.2.12"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tui-td
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.11
4
+ version: 0.2.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tans-parser
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: bundler-audit
70
84
  requirement: !ruby/object:Gem::Requirement