rspec-capturing-formatter 1.0.0

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.
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ class CapturingFormatter
5
+ # Incrementally decodes and sanitizes captured bytes into safe UTF-8 report text.
6
+ class Sanitizer
7
+ MAX_ESCAPE_BYTES = 128
8
+ UTF8_LEADERS = {
9
+ (0xC2..0xDF) => 2,
10
+ (0xE0..0xEF) => 3,
11
+ (0xF0..0xF4) => 4
12
+ }.freeze
13
+
14
+ def initialize
15
+ @source_encoding = nil
16
+ @bom_encoding = nil
17
+ @converter = nil
18
+ @converter_encoding = nil
19
+ @encoding_carry = +""
20
+ @escape_carry = +""
21
+ @pending_cr = false
22
+ end
23
+
24
+ def process(value, encoding = nil)
25
+ source = resolve_encoding(value, encoding)
26
+ source_changed = @source_encoding && @source_encoding != source
27
+ prefix = +""
28
+ if source_changed
29
+ # Parser carry precedes decoder carry because it came from earlier, complete source bytes.
30
+ prefix << parse("", final: true) if !@escape_carry.empty?
31
+ prefix << flush_encoding_carry
32
+ prefix << finish_converter
33
+ end
34
+ @bom_encoding = nil if source_changed
35
+ @source_encoding = source
36
+
37
+ bytes = @encoding_carry + value.to_s.b
38
+ @encoding_carry = +""
39
+ converted, @encoding_carry = decode(bytes, source)
40
+
41
+ parse(prefix + converted)
42
+ end
43
+
44
+ def finish
45
+ result = parse("", final: true)
46
+ result << flush_encoding_carry
47
+ result << finish_converter
48
+ @escape_carry = +""
49
+ @pending_cr = false
50
+ @source_encoding = nil
51
+ @bom_encoding = nil
52
+ @converter = nil
53
+ @converter_encoding = nil
54
+ result
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_encoding(value, encoding)
60
+ return Encoding.find(encoding.to_s) if encoding
61
+
62
+ value.respond_to?(:encoding) ? value.encoding : Encoding::UTF_8
63
+ rescue ArgumentError
64
+ Encoding::UTF_8
65
+ end
66
+
67
+ def flush_encoding_carry
68
+ return +"" if @encoding_carry.empty?
69
+
70
+ carry = @encoding_carry
71
+ @encoding_carry = +""
72
+ escape_bytes(carry)
73
+ end
74
+
75
+ def decode(bytes, source)
76
+ return [binary_text(bytes), +""] if source == Encoding::BINARY
77
+ return decode_utf8(bytes) if source == Encoding::UTF_8
78
+ return decode_utf16(bytes) if source == Encoding::UTF_16
79
+ return decode_utf8_variant(bytes, source) if source.name.start_with?("UTF8-")
80
+
81
+ decode_with_converter(bytes, source)
82
+ end
83
+
84
+ def decode_utf8_variant(bytes, source)
85
+ encoded = bytes.dup.force_encoding(source)
86
+ return [encoded.encode(Encoding::UTF_8), +""] if encoded.valid_encoding?
87
+
88
+ decode_utf8(bytes)
89
+ rescue EncodingError
90
+ decode_utf8(bytes)
91
+ end
92
+
93
+ def decode_utf16(bytes)
94
+ unless @bom_encoding
95
+ return ["", bytes] if bytes.bytesize < 2
96
+
97
+ @bom_encoding = case bytes.byteslice(0, 2).bytes
98
+ when [0xFE, 0xFF] then Encoding::UTF_16BE
99
+ when [0xFF, 0xFE] then Encoding::UTF_16LE
100
+ end
101
+ return decode_with_converter(bytes, Encoding::UTF_16) unless @bom_encoding
102
+
103
+ bytes = bytes.byteslice(2..).to_s.b
104
+ end
105
+
106
+ decode_with_converter(bytes, @bom_encoding)
107
+ end
108
+
109
+ def decode_utf8(bytes)
110
+ encoded = bytes.dup.force_encoding(Encoding::UTF_8)
111
+ return [encoded, +""] if encoded.valid_encoding?
112
+
113
+ suffix_length = incomplete_utf8_suffix_length(bytes)
114
+ if suffix_length.positive?
115
+ prefix = bytes.byteslice(0, bytes.bytesize - suffix_length).to_s.force_encoding(Encoding::UTF_8)
116
+ prefix = prefix.scrub { |invalid| escape_bytes(invalid.b) }
117
+ return [prefix, bytes.byteslice(-suffix_length, suffix_length)]
118
+ end
119
+
120
+ [encoded.scrub { |invalid| escape_bytes(invalid.b) }, +""]
121
+ end
122
+
123
+ def incomplete_utf8_suffix_length(bytes)
124
+ raw = bytes.bytes
125
+
126
+ [3, raw.length].min.downto(1) do |length|
127
+ suffix = raw.last(length)
128
+ next unless utf8_incomplete_prefix?(suffix)
129
+
130
+ return length
131
+ end
132
+
133
+ 0
134
+ end
135
+
136
+ def utf8_incomplete_prefix?(bytes)
137
+ leader = bytes.first
138
+ expected = UTF8_LEADERS.find { |range, _| range.include?(leader) }&.last
139
+ return false unless expected && bytes.length < expected
140
+ return false unless bytes.drop(1).all? { |byte| byte.between?(0x80, 0xBF) }
141
+
142
+ # These bounds reject overlong forms, surrogates, and code points above U+10FFFF.
143
+ case leader
144
+ when 0xE0
145
+ bytes[1].nil? || bytes[1] >= 0xA0
146
+ when 0xED
147
+ bytes[1].nil? || bytes[1] <= 0x9F
148
+ when 0xF0
149
+ bytes[1].nil? || bytes[1] >= 0x90
150
+ when 0xF4
151
+ bytes[1].nil? || bytes[1] <= 0x8F
152
+ else
153
+ true
154
+ end
155
+ end
156
+
157
+ # Preserve converter shift state across writes and retry read-ahead bytes after malformed input.
158
+ def decode_with_converter(bytes, source)
159
+ remaining = bytes.dup
160
+ output = +"".b
161
+ converter_for(source)
162
+
163
+ loop do
164
+ destination = +"".b
165
+ result = @converter.primitive_convert(
166
+ remaining, destination, nil, nil, Encoding::Converter::PARTIAL_INPUT
167
+ )
168
+ output << safe_utf8(destination)
169
+
170
+ case result
171
+ when :finished, :source_buffer_empty
172
+ return [output.force_encoding(Encoding::UTF_8), +""]
173
+ when :incomplete_input
174
+ carry = @converter.primitive_errinfo[3].to_s.b
175
+ return [output.force_encoding(Encoding::UTF_8), carry]
176
+ when :invalid_byte_sequence
177
+ error = @converter.primitive_errinfo
178
+ invalid = error[3].to_s.b
179
+ retry_bytes = error[4].to_s.b + remaining
180
+ if invalid.empty?
181
+ invalid = retry_bytes.byteslice(0, 1).to_s.b
182
+ retry_bytes = retry_bytes.byteslice(1..).to_s.b
183
+ end
184
+ output << escape_bytes(invalid)
185
+ return [output.force_encoding(Encoding::UTF_8), +""] if retry_bytes.empty?
186
+
187
+ reset_converter
188
+ converter_for(source)
189
+ remaining = retry_bytes
190
+ when :undefined_conversion
191
+ error = @converter.primitive_errinfo
192
+ invalid = error[3].to_s.b
193
+ retry_bytes = error[4].to_s.b + remaining
194
+ if invalid.empty?
195
+ invalid = retry_bytes.byteslice(0, 1).to_s.b
196
+ retry_bytes = retry_bytes.byteslice(1..).to_s.b
197
+ end
198
+ output << escape_bytes(invalid)
199
+ return [output.force_encoding(Encoding::UTF_8), +""] if retry_bytes.empty?
200
+
201
+ reset_converter
202
+ converter_for(source)
203
+ remaining = retry_bytes
204
+ else
205
+ return [output.force_encoding(Encoding::UTF_8), +""]
206
+ end
207
+ end
208
+ rescue EncodingError
209
+ [escape_bytes(bytes), +""]
210
+ end
211
+
212
+ def converter_for(source)
213
+ return if @converter && @converter_encoding == source
214
+
215
+ @converter = Encoding::Converter.new(source, Encoding::UTF_8)
216
+ @converter_encoding = source
217
+ end
218
+
219
+ def reset_converter
220
+ @converter = nil
221
+ @converter_encoding = nil
222
+ end
223
+
224
+ def finish_converter
225
+ return +"" unless @converter
226
+
227
+ destination = +"".b
228
+ empty = +"".b
229
+ result = @converter.primitive_convert(empty, destination, nil, nil, 0)
230
+ output = safe_utf8(destination)
231
+ if result == :incomplete_input || result == :invalid_byte_sequence
232
+ output << escape_bytes(@converter.primitive_errinfo[3].to_s.b)
233
+ end
234
+ reset_converter
235
+ output
236
+ rescue EncodingError
237
+ reset_converter
238
+ escape_bytes(@encoding_carry)
239
+ end
240
+
241
+ def safe_utf8(bytes)
242
+ value = bytes.dup.force_encoding(Encoding::UTF_8)
243
+ return value if value.valid_encoding?
244
+
245
+ value.scrub { |invalid| escape_bytes(invalid.b) }
246
+ end
247
+
248
+ def binary_text(bytes)
249
+ bytes.bytes.map do |byte|
250
+ if byte == 9 || byte == 10 || byte == 13 || byte.between?(0x20, 0x7E)
251
+ byte.chr
252
+ else
253
+ format("\\x%02X", byte)
254
+ end
255
+ end.join
256
+ end
257
+
258
+ def escape_bytes(bytes)
259
+ bytes.bytes.map { |byte| format("\\x%02X", byte) }.join
260
+ end
261
+
262
+ def parse(text, final: false)
263
+ # Input is valid UTF-8 here; byte offsets are used only for ASCII terminal-control syntax.
264
+ input = @escape_carry + text.to_s
265
+ @escape_carry = +""
266
+ output = +""
267
+ index = 0
268
+
269
+ while index < input.bytesize
270
+ if @pending_cr
271
+ @pending_cr = false if input.getbyte(index) != 10
272
+ if input.getbyte(index) == 10
273
+ index += 1
274
+ next
275
+ end
276
+ end
277
+
278
+ if input.getbyte(index) == 0x1B
279
+ sequence, consumed, complete = terminal_sequence(input, index)
280
+ unless complete
281
+ if !final && input.bytesize - index <= MAX_ESCAPE_BYTES
282
+ @escape_carry = input.byteslice(index..)
283
+ break
284
+ end
285
+
286
+ output << visible_escape(input.byteslice(index..))
287
+ break
288
+ end
289
+
290
+ output << if sgr_sequence?(sequence)
291
+ sequence
292
+ else
293
+ visible_escape(sequence)
294
+ end
295
+ index += consumed
296
+ next
297
+ end
298
+
299
+ character = input.byteslice(index..).each_char.first
300
+ character_bytes = character.bytesize
301
+ case character
302
+ when "\r"
303
+ output << "\n"
304
+ @pending_cr = true
305
+ when "\n", "\t"
306
+ output << character
307
+ else
308
+ codepoint = character.ord
309
+ output << if codepoint < 0x20 || codepoint == 0x7F || codepoint.between?(0x80, 0x9F)
310
+ format("\\x%02X", codepoint)
311
+ else
312
+ character
313
+ end
314
+ end
315
+ index += character_bytes
316
+ end
317
+
318
+ output
319
+ end
320
+
321
+ def terminal_sequence(input, start)
322
+ # CSI ends at an ANSI final byte; OSC ends at BEL or the String Terminator (ESC backslash).
323
+ return ["\e", 1, false] if start + 1 >= input.bytesize
324
+
325
+ second_byte = input.getbyte(start + 1)
326
+ return ["\e", 1, true] if second_byte >= 0x80
327
+
328
+ second = second_byte.chr
329
+ if second == "["
330
+ index = start + 2
331
+ while index < input.bytesize && index - start <= MAX_ESCAPE_BYTES
332
+ byte = input.getbyte(index)
333
+ return [input.byteslice(start..index), index - start + 1, true] if byte.between?(0x40, 0x7E)
334
+ if byte < 0x20 || byte == 0x7F || byte.between?(0x80, 0x9F)
335
+ return [input.byteslice(start..index), index - start + 1, true]
336
+ end
337
+ index += 1
338
+ end
339
+ return [input.byteslice(start..), input.bytesize - start, false]
340
+ end
341
+
342
+ if second == "]"
343
+ index = start + 2
344
+ while index < input.bytesize && index - start <= MAX_ESCAPE_BYTES
345
+ return [input.byteslice(start..index), index - start + 1, true] if input.getbyte(index) == 7
346
+ if input.getbyte(index) == 0x1B && input.getbyte(index + 1) == 92
347
+ return [input.byteslice(start..index + 1), index - start + 2, true]
348
+ end
349
+ index += 1
350
+ end
351
+ return [input.byteslice(start..), input.bytesize - start, false]
352
+ end
353
+
354
+ [input.byteslice(start, 2), 2, true]
355
+ end
356
+
357
+ def sgr_sequence?(sequence)
358
+ # Preserve only empty or numeric SGR parameters separated by semicolons or colons.
359
+ bytes = sequence.bytes
360
+ return false unless bytes.first(2) == [0x1B, 0x5B] && bytes.last == 0x6D
361
+
362
+ bytes[2...-1].all? do |byte|
363
+ byte.between?(0x30, 0x39) || byte == 0x3A || byte == 0x3B
364
+ end
365
+ end
366
+
367
+ def visible_escape(sequence)
368
+ sequence.to_s.bytes.map do |byte|
369
+ case byte
370
+ when 0x1B then "\\e"
371
+ when 0x20..0x7E then byte.chr
372
+ else format("\\x%02X", byte)
373
+ end
374
+ end.join
375
+ end
376
+ end
377
+ end
378
+ end