expect-pty 0.3.3 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -14
- data/Gemfile +1 -0
- data/README.md +208 -83
- data/benchmark/matching.rb +26 -0
- data/benchmark/scaling.rb +70 -0
- data/benchmark/support.rb +23 -3
- data/docs/COMPATIBILITY.md +68 -57
- data/docs/INTERNAL_CONTRACTS.md +95 -40
- data/docs/PERFORMANCE.md +65 -19
- data/docs/VERIFICATION.md +214 -90
- data/lib/expect/configuration.rb +3 -1
- data/lib/expect/interaction.rb +15 -0
- data/lib/expect/logging.rb +111 -7
- data/lib/expect/matcher.rb +61 -8
- data/lib/expect/pattern.rb +5 -2
- data/lib/expect/pattern_list.rb +3 -0
- data/lib/expect/redactor.rb +106 -0
- data/lib/expect/relay.rb +12 -0
- data/lib/expect/relay_writer.rb +9 -0
- data/lib/expect/result.rb +2 -0
- data/lib/expect/session_resources.rb +4 -0
- data/lib/expect/terminal.rb +2 -1
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +50 -24
- data/script/ci +34 -3
- data/test/buffer_accounting_test.rb +83 -0
- data/test/deadline_test.rb +237 -0
- data/test/diagnostics_test.rb +353 -0
- data/test/lifecycle_contract_test.rb +71 -0
- data/test/literal_scan_test.rb +97 -0
- data/test/redactor_test.rb +106 -0
- metadata +9 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
class DiagnosticsTest < ExpectTest
|
|
7
|
+
def test_logger_receives_lifecycle_and_payload_at_separate_levels
|
|
8
|
+
session, writer = pipe_session(debug_level: 2)
|
|
9
|
+
output = StringIO.new
|
|
10
|
+
logger = Logger.new(output)
|
|
11
|
+
logger.level = Logger::INFO
|
|
12
|
+
session.diagnostic_output = logger
|
|
13
|
+
writer.write("ready")
|
|
14
|
+
session.expect("ready", timeout: 1)
|
|
15
|
+
assert_includes output.string, "matched pattern 1"
|
|
16
|
+
refute_includes output.string, "received"
|
|
17
|
+
logger.level = Logger::DEBUG
|
|
18
|
+
writer.write("next")
|
|
19
|
+
session.expect("next", timeout: 1)
|
|
20
|
+
assert_includes output.string, 'received "next"'
|
|
21
|
+
session.close
|
|
22
|
+
refute output.closed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_constructor_accepts_borrowed_diagnostic_io_and_keeps_transcript_separate
|
|
26
|
+
diagnostics = StringIO.new
|
|
27
|
+
session, writer = pipe_session(debug_level: 2, diagnostic_output: diagnostics)
|
|
28
|
+
transcript = StringIO.new
|
|
29
|
+
session.log_to(transcript)
|
|
30
|
+
writer.write("ready")
|
|
31
|
+
session.expect("ready", timeout: 1)
|
|
32
|
+
assert_equal "ready", transcript.string
|
|
33
|
+
assert_includes diagnostics.string, 'received "ready"'
|
|
34
|
+
session.close
|
|
35
|
+
refute diagnostics.closed?
|
|
36
|
+
refute transcript.closed?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_callable_diagnostics_receive_immutable_metadata_without_the_session
|
|
40
|
+
events = []
|
|
41
|
+
session, writer = pipe_session(debug_level: 2, diagnostic_output: ->(event) { events << event })
|
|
42
|
+
writer.write("ready")
|
|
43
|
+
session.expect("ready", timeout: 1)
|
|
44
|
+
event = events.find { |item| item[:event] == :received }
|
|
45
|
+
assert event.frozen?
|
|
46
|
+
assert event[:message].frozen?
|
|
47
|
+
assert_equal :debug, event[:level]
|
|
48
|
+
assert_equal session.fileno, event[:fd]
|
|
49
|
+
assert_equal 'received "ready"', event[:message]
|
|
50
|
+
refute_includes event.values, session
|
|
51
|
+
assert_equal :info, events.last[:level]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_invalid_diagnostic_target_preserves_the_previous_target
|
|
55
|
+
session, = pipe_session
|
|
56
|
+
output = StringIO.new
|
|
57
|
+
session.diagnostic_output = output
|
|
58
|
+
assert_raises(ArgumentError) { session.diagnostic_output = Object.new }
|
|
59
|
+
assert_same output, session.diagnostic_output
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_diagnostic_failure_keeps_original_input_available
|
|
63
|
+
session, writer = pipe_session(debug_level: 2)
|
|
64
|
+
failure = IOError.new("diagnostic failed")
|
|
65
|
+
session.diagnostic_output = ->(_) { raise failure }
|
|
66
|
+
writer.write("ready")
|
|
67
|
+
result = session.expect_result("ready", timeout: 1)
|
|
68
|
+
assert_same failure, result.error
|
|
69
|
+
assert_equal "ready", session.buffer
|
|
70
|
+
session.debug_level = 0
|
|
71
|
+
assert_equal 1, session.expect("ready", timeout: 0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_nested_diagnostic_wait_preserves_the_outer_match_result
|
|
75
|
+
session, = pipe_session(debug_level: 1)
|
|
76
|
+
session.buffer = "ready"
|
|
77
|
+
nested = nil
|
|
78
|
+
notified = false
|
|
79
|
+
callback_result = nil
|
|
80
|
+
session.diagnostic_output = lambda do |event|
|
|
81
|
+
next unless event[:event] == :matched && !notified
|
|
82
|
+
|
|
83
|
+
notified = true
|
|
84
|
+
nested = session.expect_result("missing", timeout: 0)
|
|
85
|
+
end
|
|
86
|
+
result = session.expect_result(timeout: 0) do |patterns|
|
|
87
|
+
patterns.on("ready") { |source| callback_result = source.last_result }
|
|
88
|
+
end
|
|
89
|
+
assert nested.timeout?
|
|
90
|
+
assert result.matched?
|
|
91
|
+
assert_equal "ready", result.match
|
|
92
|
+
assert_same result, callback_result
|
|
93
|
+
assert_same result, session.last_result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_redaction_handles_every_two_chunk_split_and_overlapping_secrets
|
|
97
|
+
(0..11).each do |split|
|
|
98
|
+
session, = pipe_session
|
|
99
|
+
output = StringIO.new
|
|
100
|
+
session.log_to(output)
|
|
101
|
+
session.redact("abc", "bcd", "secret")
|
|
102
|
+
text = "xabcdsecret!"
|
|
103
|
+
session.write_log(text.byteslice(0, split))
|
|
104
|
+
session.write_log(text.byteslice(split..))
|
|
105
|
+
session.log_output = nil
|
|
106
|
+
assert_equal "x[FILTERED]!", output.string, "split #{split}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_byte_at_a_time_redaction_preserves_matching_and_listeners
|
|
111
|
+
session, writer = pipe_session(debug_level: 3)
|
|
112
|
+
events = []
|
|
113
|
+
log = StringIO.new
|
|
114
|
+
forwarded = StringIO.new
|
|
115
|
+
session.diagnostic_output = ->(event) { events << event }
|
|
116
|
+
session.log_to(log)
|
|
117
|
+
session.listeners = [forwarded]
|
|
118
|
+
session.redact("password")
|
|
119
|
+
"password!".each_char do |byte|
|
|
120
|
+
writer.write(byte)
|
|
121
|
+
session.__send__(:read_available)
|
|
122
|
+
refute_includes log.string, "password"
|
|
123
|
+
end
|
|
124
|
+
assert_equal 1, session.expect("password!", timeout: 0)
|
|
125
|
+
writer.close
|
|
126
|
+
assert session.expect_result(:eof, timeout: 1).eof?
|
|
127
|
+
assert_equal "[FILTERED]!", log.string
|
|
128
|
+
assert_equal "password!", forwarded.string
|
|
129
|
+
received = events.select { |event| event[:event] == :received }.map { |event| event[:message] }.join
|
|
130
|
+
assert_includes received, "[FILTERED]"
|
|
131
|
+
refute_includes events.inspect, "password"
|
|
132
|
+
buffers = events.select { |event| event[:event] == :buffer }
|
|
133
|
+
assert(buffers.all? { |event| event[:message] == "buffer [FILTERED]" })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_sending_diagnostics_are_filtered_across_separate_writes
|
|
137
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
138
|
+
@ios.push(client, peer)
|
|
139
|
+
output = StringIO.new
|
|
140
|
+
session = Expect.open(client, debug_level: 2, diagnostic_output: output)
|
|
141
|
+
@sessions << session
|
|
142
|
+
session.redact("password")
|
|
143
|
+
%w[pa ss wo rd].each { |part| session.write(part) }
|
|
144
|
+
assert_equal "password", peer.read(8)
|
|
145
|
+
session.close
|
|
146
|
+
refute_includes output.string, "password"
|
|
147
|
+
refute_includes output.string, '"pa"'
|
|
148
|
+
assert_includes output.string, "[FILTERED]"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_registration_copies_secrets_is_additive_and_isolated_between_sessions
|
|
152
|
+
first, = pipe_session
|
|
153
|
+
second, = pipe_session
|
|
154
|
+
first_log = StringIO.new
|
|
155
|
+
second_log = StringIO.new
|
|
156
|
+
first.log_to(first_log)
|
|
157
|
+
second.log_to(second_log)
|
|
158
|
+
secret = +"first"
|
|
159
|
+
assert_same first, first.redact(secret)
|
|
160
|
+
secret.replace("changed")
|
|
161
|
+
first.write_log("fi")
|
|
162
|
+
first.redact("second")
|
|
163
|
+
first.write_log("rst second")
|
|
164
|
+
second.write_log("first second")
|
|
165
|
+
first.close
|
|
166
|
+
assert_equal "[FILTERED] [FILTERED]", first_log.string
|
|
167
|
+
assert_equal "first second", second_log.string
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_binary_secrets_are_matched_before_diagnostic_escaping
|
|
171
|
+
session, = pipe_session(debug_level: 2)
|
|
172
|
+
log = StringIO.new
|
|
173
|
+
diagnostics = StringIO.new
|
|
174
|
+
session.log_to(log)
|
|
175
|
+
session.diagnostic_output = diagnostics
|
|
176
|
+
secret = "\xff\0\n".b
|
|
177
|
+
session.redact(secret)
|
|
178
|
+
session.write_log("before#{secret}after".b)
|
|
179
|
+
session.__send__(:trace_data, :received, secret, level: 2)
|
|
180
|
+
session.close
|
|
181
|
+
assert_equal "before[FILTERED]after", log.string
|
|
182
|
+
refute_includes diagnostics.string, '\\xFF'
|
|
183
|
+
assert_includes diagnostics.string, "[FILTERED]"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_eof_and_close_flush_benign_tails_but_hide_incomplete_secret_prefixes
|
|
187
|
+
session, writer = pipe_session
|
|
188
|
+
log = StringIO.new
|
|
189
|
+
session.log_to(log)
|
|
190
|
+
session.redact("password")
|
|
191
|
+
writer.write("hello pass")
|
|
192
|
+
writer.close
|
|
193
|
+
assert session.expect_result(:eof, timeout: 1).eof?
|
|
194
|
+
assert_equal "hello [FILTERED]", log.string
|
|
195
|
+
session.close
|
|
196
|
+
assert_equal "hello [FILTERED]", log.string
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_replacing_log_finishes_the_original_target_and_keeps_it_borrowed
|
|
200
|
+
session, = pipe_session
|
|
201
|
+
first = StringIO.new
|
|
202
|
+
second = StringIO.new
|
|
203
|
+
session.redact("secret")
|
|
204
|
+
session.log_to(first)
|
|
205
|
+
session.write_log("hello")
|
|
206
|
+
session.log_to(second)
|
|
207
|
+
session.write_log("secret!")
|
|
208
|
+
session.close
|
|
209
|
+
assert_equal "hello", first.string
|
|
210
|
+
assert_equal "[FILTERED]!", second.string
|
|
211
|
+
refute first.closed?
|
|
212
|
+
refute second.closed?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_invalid_registration_does_not_partially_add_secrets
|
|
216
|
+
session, = pipe_session
|
|
217
|
+
output = StringIO.new
|
|
218
|
+
session.log_to(output)
|
|
219
|
+
[[], [""], [nil], ["valid", 42]].each do |values|
|
|
220
|
+
assert_raises(ArgumentError) { session.redact(*values) }
|
|
221
|
+
end
|
|
222
|
+
session.write_log("valid")
|
|
223
|
+
assert_equal "valid", output.string
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_reopening_the_same_log_path_finishes_the_old_stream_before_truncating
|
|
227
|
+
session, = pipe_session
|
|
228
|
+
session.redact("password")
|
|
229
|
+
Dir.mktmpdir do |directory|
|
|
230
|
+
path = File.join(directory, "session.log")
|
|
231
|
+
previous = session.log_to(path, mode: "w")
|
|
232
|
+
session.write_log("hello")
|
|
233
|
+
assert_equal "", File.binread(path)
|
|
234
|
+
session.log_to(path, mode: "w")
|
|
235
|
+
assert previous.closed?
|
|
236
|
+
session.write_log("x")
|
|
237
|
+
session.close
|
|
238
|
+
assert_equal "x", File.binread(path)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def test_failed_old_log_flush_does_not_truncate_the_new_path
|
|
243
|
+
session, = pipe_session
|
|
244
|
+
session.redact("password")
|
|
245
|
+
failure = IOError.new("old log failed")
|
|
246
|
+
session.log_to { raise failure }
|
|
247
|
+
session.write_log("tail")
|
|
248
|
+
Dir.mktmpdir do |directory|
|
|
249
|
+
path = File.join(directory, "existing.log")
|
|
250
|
+
File.write(path, "keep")
|
|
251
|
+
assert_same failure, assert_raises(IOError) { session.log_to(path, mode: "w") }
|
|
252
|
+
assert_equal "keep", File.binread(path)
|
|
253
|
+
end
|
|
254
|
+
session.log_output = nil
|
|
255
|
+
assert_nil session.log_output
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def test_flush_failure_does_not_prevent_handle_and_child_cleanup
|
|
259
|
+
session = child('puts "ready"; sleep 60', raw_pty: true)
|
|
260
|
+
session.expect("ready", timeout: 2)
|
|
261
|
+
session.redact("password")
|
|
262
|
+
failure = IOError.new("flush failed")
|
|
263
|
+
session.log_to { raise failure }
|
|
264
|
+
session.write_log("hi") # 小于保留窗口,失败应发生在关闭时的尾部交付。
|
|
265
|
+
assert_same failure, assert_raises(IOError) { session.close }
|
|
266
|
+
assert session.closed?
|
|
267
|
+
refute session.alive?
|
|
268
|
+
session.log_output = nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def test_unexpected_diagnostic_flush_failure_still_closes_owned_log
|
|
272
|
+
session, = pipe_session(debug_level: 2)
|
|
273
|
+
session.redact("password")
|
|
274
|
+
Dir.mktmpdir do |directory|
|
|
275
|
+
log = session.log_to(File.join(directory, "session.log"))
|
|
276
|
+
failure = RuntimeError.new("diagnostic callback failed")
|
|
277
|
+
session.diagnostic_output = ->(_) { raise failure }
|
|
278
|
+
session.__send__(:trace_data, :sending, "hi", level: 2)
|
|
279
|
+
assert_same failure, assert_raises(RuntimeError) { session.close }
|
|
280
|
+
assert log.closed?
|
|
281
|
+
assert session.closed?
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def test_replacing_diagnostics_finishes_the_previous_stream
|
|
286
|
+
session, = pipe_session(debug_level: 2)
|
|
287
|
+
session.redact("password")
|
|
288
|
+
first = StringIO.new
|
|
289
|
+
second = StringIO.new
|
|
290
|
+
session.diagnostic_output = first
|
|
291
|
+
session.__send__(:trace_data, :received, "pass", level: 2)
|
|
292
|
+
session.diagnostic_output = second
|
|
293
|
+
session.__send__(:trace_data, :received, "password!", level: 2)
|
|
294
|
+
session.close
|
|
295
|
+
assert_includes first.string, "[FILTERED]"
|
|
296
|
+
refute_includes first.string, "pass"
|
|
297
|
+
assert_includes second.string, "[FILTERED]"
|
|
298
|
+
refute_includes second.string, "password"
|
|
299
|
+
refute first.closed?
|
|
300
|
+
refute second.closed?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def test_log_callback_can_rotate_the_target_during_close
|
|
304
|
+
session, = pipe_session
|
|
305
|
+
session.redact("long-secret")
|
|
306
|
+
delivered = []
|
|
307
|
+
rotated = nil
|
|
308
|
+
Dir.mktmpdir do |directory|
|
|
309
|
+
path = File.join(directory, "rotated.log")
|
|
310
|
+
session.log_to do |data|
|
|
311
|
+
delivered << data
|
|
312
|
+
rotated = session.log_to(path, mode: "w")
|
|
313
|
+
session.write_log("body")
|
|
314
|
+
end
|
|
315
|
+
session.write_log("xyz")
|
|
316
|
+
session.close
|
|
317
|
+
assert_equal ["xyz"], delivered
|
|
318
|
+
assert rotated.closed?
|
|
319
|
+
assert_equal "body", File.binread(path)
|
|
320
|
+
assert_nil session.log_output
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def test_diagnostic_flush_callback_can_create_or_revisit_the_other_direction
|
|
325
|
+
[nil, "seed"].each do |initial_send|
|
|
326
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
327
|
+
@ios.push(client, peer)
|
|
328
|
+
session = Expect.open(client, debug_level: 2)
|
|
329
|
+
@sessions << session
|
|
330
|
+
session.redact("long-secret")
|
|
331
|
+
events = []
|
|
332
|
+
session.diagnostic_output = lambda do |event|
|
|
333
|
+
events << event
|
|
334
|
+
session.write("ack") if event[:event] == :received
|
|
335
|
+
end
|
|
336
|
+
if initial_send
|
|
337
|
+
session.write(initial_send)
|
|
338
|
+
assert_equal initial_send, peer.read(initial_send.bytesize)
|
|
339
|
+
end
|
|
340
|
+
peer.write("xyz")
|
|
341
|
+
assert_equal 1, session.expect("xyz", timeout: 0)
|
|
342
|
+
replacement = StringIO.new
|
|
343
|
+
session.diagnostic_output = replacement
|
|
344
|
+
assert_equal "ack", peer.read(3)
|
|
345
|
+
expected = initial_send ? %i[matched sending received sending] : %i[matched received sending]
|
|
346
|
+
assert_equal(expected, events.map { |event| event[:event] })
|
|
347
|
+
assert_includes events.last[:message], "ack"
|
|
348
|
+
assert_equal "", replacement.string
|
|
349
|
+
session.close
|
|
350
|
+
assert_equal "", replacement.string
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class LifecycleContractTest < ExpectTest
|
|
6
|
+
def test_external_reaping_releases_pid_without_fabricating_process_status
|
|
7
|
+
session = child('puts "ready"; exit 7', raw_pty: true)
|
|
8
|
+
assert_equal 1, session.expect("ready", timeout: 2)
|
|
9
|
+
pid = session.pid
|
|
10
|
+
assert_equal(7, bounded { Process.waitpid2(pid).last.exitstatus })
|
|
11
|
+
refute session.alive?
|
|
12
|
+
assert_nil session.pid
|
|
13
|
+
assert_nil session.process_status
|
|
14
|
+
assert_nil session.wait(timeout: 0)
|
|
15
|
+
Process.stub(:kill, ->(*) { flunk "must not signal an externally reaped PID" }) do
|
|
16
|
+
2.times { session.close }
|
|
17
|
+
end
|
|
18
|
+
assert session.closed?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_external_reader_close_does_not_take_ownership_of_the_writer
|
|
22
|
+
reader, producer = IO.pipe
|
|
23
|
+
consumer, writer = IO.pipe
|
|
24
|
+
@ios.push(reader, producer, consumer, writer)
|
|
25
|
+
session = Expect.open(reader, writer: writer)
|
|
26
|
+
@sessions << session
|
|
27
|
+
session.buffer = "tail"
|
|
28
|
+
reader.close
|
|
29
|
+
assert session.closed?
|
|
30
|
+
assert session.eof?
|
|
31
|
+
refute session.alive?
|
|
32
|
+
assert_raises(IOError) { session.write("blocked") }
|
|
33
|
+
2.times { session.close }
|
|
34
|
+
refute writer.closed?
|
|
35
|
+
assert_equal "tail", session.buffer
|
|
36
|
+
writer.write("borrowed")
|
|
37
|
+
assert_equal "borrowed", consumer.read(8)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_failed_handle_close_can_be_retried_without_losing_other_owned_handles
|
|
41
|
+
reader, writer = IO.pipe
|
|
42
|
+
@ios.push(reader, writer)
|
|
43
|
+
session = Expect.open(reader, writer: writer, own: true)
|
|
44
|
+
@sessions << session
|
|
45
|
+
failure = IOError.new("close failed")
|
|
46
|
+
reader.stub(:close, -> { raise failure }) do
|
|
47
|
+
assert_same failure, assert_raises(IOError) { session.close }
|
|
48
|
+
assert writer.closed?
|
|
49
|
+
refute reader.closed?
|
|
50
|
+
assert session.closed?
|
|
51
|
+
end
|
|
52
|
+
session.close
|
|
53
|
+
assert reader.closed?
|
|
54
|
+
assert_nil session.close
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_timeout_and_eof_remain_events_and_write_errors_remain_io_errors
|
|
58
|
+
session, writer = pipe_session
|
|
59
|
+
timeout = session.expect_result("missing", timeout: 0)
|
|
60
|
+
assert timeout.timeout?
|
|
61
|
+
assert_equal :timeout, timeout.error
|
|
62
|
+
writer.close
|
|
63
|
+
eof = session.expect_result(:eof, timeout: 1)
|
|
64
|
+
assert eof.eof?
|
|
65
|
+
assert_equal :eof, eof.error
|
|
66
|
+
failure = Expect::WriteTimeout.new(bytes_written: 3)
|
|
67
|
+
assert_kind_of IOError, failure
|
|
68
|
+
assert_equal 3, failure.bytes_written
|
|
69
|
+
assert_operator Expect::SpawnError, :<, StandardError
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class LiteralScanTest < ExpectTest
|
|
6
|
+
def test_new_bytes_can_complete_a_previously_missing_literal
|
|
7
|
+
session, writer = pipe_session
|
|
8
|
+
list = Expect::PatternList.new([session], ["password:", "word:"])
|
|
9
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
10
|
+
session.buffer = "prefix pass"
|
|
11
|
+
assert_nil matcher.__send__(:find_match)
|
|
12
|
+
writer.write("word:")
|
|
13
|
+
session.__send__(:read_available)
|
|
14
|
+
result = matcher.__send__(:find_match)
|
|
15
|
+
assert_equal 1, result[1].number
|
|
16
|
+
assert_equal [7, 9, []], result[2]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_later_text_for_an_earlier_pattern_keeps_declaration_priority
|
|
20
|
+
session, writer = pipe_session
|
|
21
|
+
list = Expect::PatternList.new([session], %w[preferred ready])
|
|
22
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
23
|
+
session.buffer = "ready"
|
|
24
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
25
|
+
writer.write(" preferred")
|
|
26
|
+
session.__send__(:read_available)
|
|
27
|
+
assert_equal 1, matcher.__send__(:find_match)[1].number
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_destructive_buffer_changes_invalidate_old_misses
|
|
31
|
+
session, writer = pipe_session
|
|
32
|
+
list = Expect::PatternList.new([session], ["start", "token", /END/])
|
|
33
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
34
|
+
session.buffer = "x" * 100
|
|
35
|
+
assert_nil matcher.__send__(:find_match)
|
|
36
|
+
session.buffer = "start"
|
|
37
|
+
assert_equal 1, matcher.__send__(:find_match)[1].number
|
|
38
|
+
session.clear_buffer
|
|
39
|
+
assert_nil matcher.__send__(:find_match)
|
|
40
|
+
writer.write("token")
|
|
41
|
+
session.__send__(:read_available)
|
|
42
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
43
|
+
session.expect("token", timeout: 0)
|
|
44
|
+
session.buffer_limit = 8
|
|
45
|
+
writer.write("xxxxstart")
|
|
46
|
+
session.__send__(:read_available)
|
|
47
|
+
assert_equal [3, 5, []], matcher.__send__(:find_match)[2]
|
|
48
|
+
session.clear_buffer
|
|
49
|
+
session.__send__(:restore_relay_buffer, "token")
|
|
50
|
+
assert_equal 2, matcher.__send__(:find_match)[1].number
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_repeated_scans_equal_a_fresh_matcher_across_operation_sequences
|
|
54
|
+
session, writer = pipe_session
|
|
55
|
+
patterns = ["abc", "bc", "\0\xff".b, /END/n]
|
|
56
|
+
list = Expect::PatternList.new([session], patterns)
|
|
57
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
58
|
+
random = Random.new(40_033)
|
|
59
|
+
chunks = ["a", "b", "c", "x", "END", "\0", "\xff".b]
|
|
60
|
+
150.times do
|
|
61
|
+
case random.rand(6)
|
|
62
|
+
when 0
|
|
63
|
+
session.buffer = chunks.sample(random: random) * random.rand(1..4)
|
|
64
|
+
when 1
|
|
65
|
+
session.clear_buffer
|
|
66
|
+
when 2
|
|
67
|
+
session.buffer_limit = [nil, 2, 5, 12].sample(random: random)
|
|
68
|
+
when 3
|
|
69
|
+
session.__send__(:restore_relay_buffer, chunks.sample(random: random).b)
|
|
70
|
+
when 4
|
|
71
|
+
session.expect("x", timeout: 0)
|
|
72
|
+
else
|
|
73
|
+
writer.write(chunks.sample(random: random))
|
|
74
|
+
session.__send__(:read_available)
|
|
75
|
+
end
|
|
76
|
+
expected = Expect::Matcher.new(list, nil).__send__(:find_match)
|
|
77
|
+
actual = matcher.__send__(:find_match)
|
|
78
|
+
assert_equal signature(expected), signature(actual), "buffer=#{session.buffer.inspect}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_a_dynamically_registered_pattern_is_scanned_from_the_start
|
|
83
|
+
session, = pipe_session
|
|
84
|
+
session.buffer = "prefix ready"
|
|
85
|
+
list = Expect::PatternList.new([session], ["missing"])
|
|
86
|
+
matcher = Expect::Matcher.new(list, nil)
|
|
87
|
+
assert_nil matcher.__send__(:find_match)
|
|
88
|
+
list.on("prefix")
|
|
89
|
+
assert_equal [0, 6, []], matcher.__send__(:find_match)[2]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def signature(result)
|
|
95
|
+
result ? [result[0], result[1].number, result[2]] : []
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require_relative "../lib/expect/redactor"
|
|
7
|
+
|
|
8
|
+
class RedactorTest < Minitest::Test
|
|
9
|
+
def test_standalone_require_does_not_load_pty_or_allocate_a_session
|
|
10
|
+
program = <<~RUBY
|
|
11
|
+
require "expect/redactor"
|
|
12
|
+
abort "PTY loaded" if defined?(PTY)
|
|
13
|
+
abort "unexpected result" unless Expect::Redactor.redact("secret!", ["secret"]) == "[FILTERED]!"
|
|
14
|
+
RUBY
|
|
15
|
+
output, status = Open3.capture2e(RbConfig.ruby, "--disable-gems", "-I", File.expand_path("../lib", __dir__),
|
|
16
|
+
"-e", program)
|
|
17
|
+
assert status.success?, output
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_empty_patterns_pass_binary_input_without_buffering
|
|
21
|
+
filter = Expect::Redactor.new([])
|
|
22
|
+
assert_equal "\xff\0raw".b, filter.append("\xff\0raw".b)
|
|
23
|
+
assert_empty filter.finish
|
|
24
|
+
assert_equal "plain", Expect::Redactor.redact("plain", [])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_one_shot_redaction_does_not_treat_an_ordinary_suffix_as_a_secret_prefix
|
|
28
|
+
assert_equal "pass", Expect::Redactor.redact("pass", ["password"])
|
|
29
|
+
assert_equal "[REDACTED]!", Expect::Redactor.redact("password!", ["password"], replacement: "[REDACTED]")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_stream_finish_masks_partial_secrets_by_default
|
|
33
|
+
filter = Expect::Redactor.new(["password"])
|
|
34
|
+
assert_equal "prefix [FILTERED]", filter.append("prefix pass") + filter.finish
|
|
35
|
+
assert_empty filter.finish
|
|
36
|
+
assert_equal "[FILTERED]!", filter.append("password!") + filter.finish
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_exact_finish_is_explicit_and_validated
|
|
40
|
+
filter = Expect::Redactor.new(["password"])
|
|
41
|
+
filter.append("pass")
|
|
42
|
+
assert_raises(ArgumentError) { filter.finish(partial: nil) }
|
|
43
|
+
assert_equal "pass", filter.finish(partial: false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_custom_marker_and_overlapping_patterns_at_every_boundary
|
|
47
|
+
patterns = ["alpha[REDACTED]omega", "[REDACTED]", "REDACTED", "[", "abc", "cde"]
|
|
48
|
+
input = "alpha[REDACTED]omega | [REDACTED] | abcde!"
|
|
49
|
+
expected = "[REDACTED] | [REDACTED] | [REDACTED]!"
|
|
50
|
+
(0..input.bytesize).each do |split|
|
|
51
|
+
filter = Expect::Redactor.new(patterns, replacement: "[REDACTED]")
|
|
52
|
+
actual = filter.append(input.byteslice(0, split)) + filter.append(input.byteslice(split..)) + filter.finish
|
|
53
|
+
assert_equal expected, actual, "split #{split}"
|
|
54
|
+
end
|
|
55
|
+
filter = Expect::Redactor.new(patterns, replacement: "[REDACTED]")
|
|
56
|
+
actual = input.bytes.map { |byte| filter.append(byte.chr) }.join + filter.finish
|
|
57
|
+
assert_equal expected, actual
|
|
58
|
+
assert_equal expected, Expect::Redactor.redact(input, patterns, replacement: "[REDACTED]")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_patterns_and_replacement_are_copied
|
|
62
|
+
secret = +"secret"
|
|
63
|
+
patterns = [secret]
|
|
64
|
+
replacement = +"hidden"
|
|
65
|
+
filter = Expect::Redactor.new(patterns, replacement: replacement)
|
|
66
|
+
secret.replace("public")
|
|
67
|
+
patterns.clear
|
|
68
|
+
replacement.clear
|
|
69
|
+
assert_equal "hidden!", filter.append("secret!") + filter.finish
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_invalid_pattern_update_is_atomic_and_preserves_pending_data
|
|
73
|
+
filter = Expect::Redactor.new(["secret"])
|
|
74
|
+
filter.append("sec")
|
|
75
|
+
[nil, "secret", [""], ["valid", 1]].each do |patterns|
|
|
76
|
+
assert_raises(ArgumentError) { filter.patterns = patterns }
|
|
77
|
+
end
|
|
78
|
+
assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_pattern_update_keeps_hidden_spans_and_matches_new_secret_across_chunks
|
|
82
|
+
filter = Expect::Redactor.new(%w[abc long-pattern])
|
|
83
|
+
filter.append("abcsec")
|
|
84
|
+
filter.patterns = ["secret"]
|
|
85
|
+
assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
|
|
86
|
+
filter.patterns = []
|
|
87
|
+
assert_equal "secret", filter.append("secret")
|
|
88
|
+
assert_empty filter.finish
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_invalid_input_and_replacement_do_not_mutate_stream
|
|
92
|
+
[nil, "", 3].each do |replacement|
|
|
93
|
+
assert_raises(ArgumentError) { Expect::Redactor.new([], replacement: replacement) }
|
|
94
|
+
end
|
|
95
|
+
filter = Expect::Redactor.new(["secret"])
|
|
96
|
+
filter.append("sec")
|
|
97
|
+
assert_raises(ArgumentError) { filter.append(nil) }
|
|
98
|
+
assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_inspect_does_not_expose_registered_or_pending_bytes
|
|
102
|
+
filter = Expect::Redactor.new(["private-pattern"])
|
|
103
|
+
filter.append("raw-pending")
|
|
104
|
+
assert_equal "#<Expect::Redactor>", filter.inspect
|
|
105
|
+
end
|
|
106
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: expect-pty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- expect-pty contributors
|
|
@@ -112,6 +112,7 @@ files:
|
|
|
112
112
|
- Rakefile
|
|
113
113
|
- benchmark/matching.rb
|
|
114
114
|
- benchmark/relay.rb
|
|
115
|
+
- benchmark/scaling.rb
|
|
115
116
|
- benchmark/send_slow.rb
|
|
116
117
|
- benchmark/support.rb
|
|
117
118
|
- docs/COMPATIBILITY.md
|
|
@@ -135,6 +136,7 @@ files:
|
|
|
135
136
|
- lib/expect/pattern.rb
|
|
136
137
|
- lib/expect/pattern_list.rb
|
|
137
138
|
- lib/expect/pty.rb
|
|
139
|
+
- lib/expect/redactor.rb
|
|
138
140
|
- lib/expect/relay.rb
|
|
139
141
|
- lib/expect/relay_writer.rb
|
|
140
142
|
- lib/expect/result.rb
|
|
@@ -143,9 +145,12 @@ files:
|
|
|
143
145
|
- lib/expect/version.rb
|
|
144
146
|
- script/ci
|
|
145
147
|
- script/release.rb
|
|
148
|
+
- test/buffer_accounting_test.rb
|
|
146
149
|
- test/cleanup_test.rb
|
|
147
150
|
- test/compare_upstream.rb
|
|
148
151
|
- test/configuration_test.rb
|
|
152
|
+
- test/deadline_test.rb
|
|
153
|
+
- test/diagnostics_test.rb
|
|
149
154
|
- test/edge_case_test.rb
|
|
150
155
|
- test/fixtures/ssh_scripts/01_identity.sh
|
|
151
156
|
- test/fixtures/ssh_scripts/02_output.sh
|
|
@@ -158,10 +163,13 @@ files:
|
|
|
158
163
|
- test/interconnect_test.rb
|
|
159
164
|
- test/io_test.rb
|
|
160
165
|
- test/kibitz_test.rb
|
|
166
|
+
- test/lifecycle_contract_test.rb
|
|
167
|
+
- test/literal_scan_test.rb
|
|
161
168
|
- test/matching_test.rb
|
|
162
169
|
- test/multi_session_test.rb
|
|
163
170
|
- test/pattern_offset_test.rb
|
|
164
171
|
- test/process_test.rb
|
|
172
|
+
- test/redactor_test.rb
|
|
165
173
|
- test/relay_recovery_test.rb
|
|
166
174
|
- test/release_test.rb
|
|
167
175
|
- test/ruby_api_test.rb
|