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
data/lib/expect/logging.rb
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "redactor"
|
|
4
|
+
|
|
3
5
|
# 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
|
|
6
|
+
# 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
|
|
4
7
|
class Expect
|
|
5
8
|
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
6
|
-
attr_reader :log_output
|
|
9
|
+
attr_reader :log_output, :diagnostic_output
|
|
10
|
+
|
|
11
|
+
# 诊断目标仅借用;nil 沿用 stderr,Logger 使用 info/debug,回调接收冻结的事件 Hash。
|
|
12
|
+
# 先校验再冲刷旧流;校验或冲刷失败时保留旧目标,避免把暂存尾部交给错误的接收方。
|
|
13
|
+
def diagnostic_output=(target)
|
|
14
|
+
unless target.nil? || (target.respond_to?(:info) && target.respond_to?(:debug)) ||
|
|
15
|
+
target.respond_to?(:write) || target.respond_to?(:call)
|
|
16
|
+
raise ArgumentError, "diagnostic output must support info/debug, write or call, or be nil"
|
|
17
|
+
end
|
|
18
|
+
return if @diagnostic_output.equal?(target)
|
|
19
|
+
|
|
20
|
+
flush_diagnostics
|
|
21
|
+
@diagnostic_output = target
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。
|
|
25
|
+
# 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
|
|
26
|
+
def redact(*secrets)
|
|
27
|
+
unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
|
|
28
|
+
raise ArgumentError, "secrets must be nonempty Strings"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
|
|
32
|
+
@log_redactor.patterns = @secrets if @log_redactor
|
|
33
|
+
@diagnostic_redactors&.each_value { |redactor| redactor.patterns = @secrets }
|
|
34
|
+
self
|
|
35
|
+
end
|
|
7
36
|
|
|
8
37
|
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
9
38
|
def log_output=(target)
|
|
@@ -22,6 +51,8 @@ class Expect
|
|
|
22
51
|
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
23
52
|
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
24
53
|
|
|
54
|
+
# 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
|
|
55
|
+
flush_log
|
|
25
56
|
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
26
57
|
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
27
58
|
else
|
|
@@ -32,11 +63,17 @@ class Expect
|
|
|
32
63
|
end
|
|
33
64
|
|
|
34
65
|
# 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
|
|
66
|
+
# 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
|
|
35
67
|
def write_log(*objects)
|
|
36
68
|
target = log_output
|
|
37
69
|
return unless target
|
|
38
70
|
|
|
39
71
|
data = objects.map { |object| object.to_s.b }.join
|
|
72
|
+
if @secrets
|
|
73
|
+
@log_redactor ||= Redactor.new(@secrets)
|
|
74
|
+
data = @log_redactor.append(data)
|
|
75
|
+
return if data.empty?
|
|
76
|
+
end
|
|
40
77
|
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
41
78
|
end
|
|
42
79
|
|
|
@@ -60,6 +97,8 @@ class Expect
|
|
|
60
97
|
end
|
|
61
98
|
|
|
62
99
|
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
100
|
+
# 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
|
|
101
|
+
# 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
|
|
63
102
|
def emit(target, data)
|
|
64
103
|
offset = 0
|
|
65
104
|
while offset < data.bytesize
|
|
@@ -73,21 +112,86 @@ class Expect
|
|
|
73
112
|
target.flush if target.respond_to?(:flush)
|
|
74
113
|
end
|
|
75
114
|
|
|
76
|
-
#
|
|
77
|
-
def
|
|
78
|
-
|
|
115
|
+
# 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
|
|
116
|
+
def trace_data(event, data, level:)
|
|
117
|
+
if @secrets
|
|
118
|
+
return trace("buffer [FILTERED]", level: level, event: event) if event == :buffer
|
|
119
|
+
|
|
120
|
+
@diagnostic_redactors ||= {}
|
|
121
|
+
redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
|
|
122
|
+
data = redactor.append(data)
|
|
123
|
+
return if data.empty?
|
|
124
|
+
end
|
|
125
|
+
trace("#{event} #{data.inspect}", level: level, event: event)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 诊断与接收字节日志分开;回调元数据不含会话对象,避免格式化时意外展开原始缓冲。
|
|
129
|
+
def trace(message, level: 1, event: :matched)
|
|
130
|
+
return unless debug_level >= level
|
|
131
|
+
|
|
132
|
+
severity = level == 1 ? :info : :debug
|
|
133
|
+
target = diagnostic_output
|
|
134
|
+
if target.respond_to?(:info) && target.respond_to?(:debug)
|
|
135
|
+
target.public_send(severity, "#{inspect}: #{message}")
|
|
136
|
+
elsif target.respond_to?(:call)
|
|
137
|
+
target.call({ event: event, level: severity, pid: pid, fd: fileno, message: message.freeze }.freeze)
|
|
138
|
+
elsif target
|
|
139
|
+
emit(target, "#{inspect}: #{message}\n")
|
|
140
|
+
else
|
|
141
|
+
warn("#{inspect}: #{message}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
|
|
146
|
+
def flush_log
|
|
147
|
+
loop do
|
|
148
|
+
return unless @log_redactor && log_output
|
|
149
|
+
|
|
150
|
+
data = @log_redactor.finish
|
|
151
|
+
return if data.empty?
|
|
152
|
+
|
|
153
|
+
log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
|
|
154
|
+
# 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
|
|
159
|
+
# GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
|
|
160
|
+
def flush_diagnostics(event = nil)
|
|
161
|
+
return unless @diagnostic_redactors
|
|
162
|
+
|
|
163
|
+
loop do
|
|
164
|
+
delivered = false
|
|
165
|
+
# 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
|
|
166
|
+
@diagnostic_redactors.to_a.each do |name, redactor|
|
|
167
|
+
next if event && name != event
|
|
168
|
+
|
|
169
|
+
data = redactor.finish
|
|
170
|
+
next if data.empty?
|
|
171
|
+
|
|
172
|
+
delivered = true
|
|
173
|
+
trace("#{name} #{data.inspect}", level: 2, event: name)
|
|
174
|
+
end
|
|
175
|
+
# 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
|
|
176
|
+
break unless delivered
|
|
177
|
+
end
|
|
79
178
|
end
|
|
80
179
|
|
|
81
180
|
# 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
|
|
82
181
|
def replace_log(target, owned: false)
|
|
83
|
-
previous = log_output
|
|
84
182
|
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
85
|
-
return target if
|
|
183
|
+
return target if log_output.equal?(target)
|
|
184
|
+
|
|
185
|
+
flush_log
|
|
186
|
+
# 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
|
|
187
|
+
return target if log_output.equal?(target)
|
|
86
188
|
|
|
87
|
-
previous
|
|
189
|
+
previous = @resources.owned_log
|
|
190
|
+
previous.close if previous && !previous.closed?
|
|
88
191
|
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
89
192
|
@resources.owned_log = owned ? target : nil
|
|
90
193
|
@log_output = target
|
|
194
|
+
@log_redactor = nil
|
|
91
195
|
target
|
|
92
196
|
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
93
197
|
target.close if owned && target && !target.closed?
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
|
|
5
|
+
# 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
|
|
5
6
|
class Matcher
|
|
6
7
|
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
7
|
-
def initialize(patterns, timeout)
|
|
8
|
+
def initialize(patterns, timeout, deadline: nil)
|
|
8
9
|
@patterns = patterns
|
|
9
10
|
@sessions = patterns.sessions
|
|
10
11
|
@timeout = Expect.duration(timeout)
|
|
12
|
+
# 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
|
|
13
|
+
@hard_deadline = deadline
|
|
11
14
|
@deadline = next_deadline
|
|
12
15
|
@handled_eof = []
|
|
13
16
|
@stalled_matches = {}
|
|
@@ -31,11 +34,11 @@ class Expect
|
|
|
31
34
|
end
|
|
32
35
|
loop do
|
|
33
36
|
# 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
|
|
34
|
-
result = if !@expired_eof_continuation && (matched = find_match)
|
|
37
|
+
result = if !@expired_eof_continuation && !hard_expired? && (matched = find_match)
|
|
35
38
|
handle_match(*matched)
|
|
36
39
|
elsif (session = unhandled_eof)
|
|
37
40
|
handle_eof(session)
|
|
38
|
-
elsif @expired_eof_continuation
|
|
41
|
+
elsif @expired_eof_continuation || hard_expired?
|
|
39
42
|
handle_timeout
|
|
40
43
|
else
|
|
41
44
|
read_next
|
|
@@ -43,6 +46,7 @@ class Expect
|
|
|
43
46
|
return result unless result == :retry
|
|
44
47
|
end
|
|
45
48
|
ensure
|
|
49
|
+
# 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
|
|
46
50
|
@relay_buffers.each do |session, buffer|
|
|
47
51
|
buffer.replace(session.clear_buffer)
|
|
48
52
|
session.__send__(:interaction_buffer=, buffer)
|
|
@@ -70,17 +74,52 @@ class Expect
|
|
|
70
74
|
stalled = nil
|
|
71
75
|
end
|
|
72
76
|
patterns.each do |pattern|
|
|
77
|
+
return nil if hard_expired?
|
|
73
78
|
next if stalled && stalled[:patterns].include?(pattern)
|
|
74
79
|
|
|
75
|
-
position = pattern.
|
|
80
|
+
position = if pattern.value.is_a?(String)
|
|
81
|
+
locate_literal(session, pattern, buffer)
|
|
82
|
+
else
|
|
83
|
+
pattern.locate(buffer, final: session.eof?)
|
|
84
|
+
end
|
|
85
|
+
# 正则本身不可由 IO 期限中断;恢复控制后也不能消费已过总期限的匹配。
|
|
86
|
+
return nil if hard_expired?
|
|
76
87
|
return [session, pattern, position] if position
|
|
77
88
|
end
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
nil
|
|
81
92
|
end
|
|
93
|
+
|
|
82
94
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
83
95
|
|
|
96
|
+
# 仅复用同一缓冲代次中的字面未命中。保留模式长度减一的重叠区,覆盖跨读取命中。
|
|
97
|
+
# 不保存整个文本,也不对正则推断扫描窗口;回调替换、消费或转接恢复会改变代次。
|
|
98
|
+
def locate_literal(session, pattern, buffer)
|
|
99
|
+
@literal_misses ||= {}.compare_by_identity
|
|
100
|
+
misses = (@literal_misses[session] ||= {}.compare_by_identity)
|
|
101
|
+
previous = misses[pattern]
|
|
102
|
+
generation = session.__send__(:buffer_generation)
|
|
103
|
+
# 缓冲代次未变意味着只有尾部追加;模式对象的 value 被替换时也不能沿用旧扫描位置。
|
|
104
|
+
offset = if previous && previous[0] == generation && previous[2].equal?(pattern.value)
|
|
105
|
+
return nil if previous[1] == buffer.bytesize
|
|
106
|
+
|
|
107
|
+
[previous[1] - pattern.value.bytesize + 1, 0].max
|
|
108
|
+
else
|
|
109
|
+
0
|
|
110
|
+
end
|
|
111
|
+
position = pattern.locate(buffer, offset: offset)
|
|
112
|
+
if position
|
|
113
|
+
misses.delete(pattern)
|
|
114
|
+
else
|
|
115
|
+
entry = (misses[pattern] ||= [])
|
|
116
|
+
entry[0] = generation
|
|
117
|
+
entry[1] = buffer.bytesize
|
|
118
|
+
entry[2] = pattern.value
|
|
119
|
+
end
|
|
120
|
+
position
|
|
121
|
+
end
|
|
122
|
+
|
|
84
123
|
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
85
124
|
def handle_match(session, pattern, position)
|
|
86
125
|
previous_buffer = session.buffer
|
|
@@ -94,7 +133,8 @@ class Expect
|
|
|
94
133
|
stalled[:patterns] << pattern
|
|
95
134
|
end
|
|
96
135
|
@deadline = next_deadline if action == CONTINUE
|
|
97
|
-
|
|
136
|
+
# 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
|
|
137
|
+
return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired? && !hard_expired?
|
|
98
138
|
|
|
99
139
|
:retry
|
|
100
140
|
end
|
|
@@ -122,7 +162,7 @@ class Expect
|
|
|
122
162
|
|
|
123
163
|
# 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
|
|
124
164
|
def read_next
|
|
125
|
-
return handle_timeout if @polled && expired?
|
|
165
|
+
return handle_timeout if hard_expired? || (@polled && expired?)
|
|
126
166
|
|
|
127
167
|
readers = active_sessions
|
|
128
168
|
begin
|
|
@@ -147,6 +187,8 @@ class Expect
|
|
|
147
187
|
sessions.each { |session| by_io[session.to_io] ||= session }
|
|
148
188
|
end
|
|
149
189
|
readable.each do |io|
|
|
190
|
+
break if hard_expired?
|
|
191
|
+
|
|
150
192
|
session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
151
193
|
begin
|
|
152
194
|
# 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
|
|
@@ -169,7 +211,14 @@ class Expect
|
|
|
169
211
|
def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
|
|
170
212
|
|
|
171
213
|
# 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
|
|
172
|
-
|
|
214
|
+
# 每次重置都重新与总期限取较早者,避免连续输入或继续回调无限推迟结束。
|
|
215
|
+
def next_deadline
|
|
216
|
+
relative = @timeout && (Expect.monotonic + @timeout)
|
|
217
|
+
return @hard_deadline unless relative
|
|
218
|
+
return relative unless @hard_deadline
|
|
219
|
+
|
|
220
|
+
[relative, @hard_deadline].min
|
|
221
|
+
end
|
|
173
222
|
|
|
174
223
|
# 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
|
|
175
224
|
def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
|
|
@@ -177,16 +226,20 @@ class Expect
|
|
|
177
226
|
# 判断有限期限是否已到达;无限等待不会触发超时。
|
|
178
227
|
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
179
228
|
|
|
229
|
+
# 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
|
|
230
|
+
def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
|
|
231
|
+
|
|
180
232
|
# select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
|
|
181
233
|
def record_error(error)
|
|
182
234
|
@sessions.map { |session| session.__send__(:record_error, error) }.first
|
|
183
235
|
end
|
|
184
236
|
|
|
185
237
|
# 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
|
|
238
|
+
# 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
|
|
186
239
|
def handle_timeout
|
|
187
240
|
results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
|
|
188
241
|
action = @patterns.timeout_pattern&.call(active_sessions)
|
|
189
|
-
return results.first unless action == CONTINUE
|
|
242
|
+
return results.first unless action == CONTINUE && !hard_expired?
|
|
190
243
|
|
|
191
244
|
@deadline = next_deadline
|
|
192
245
|
@polled = false
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -12,10 +12,12 @@ class Expect
|
|
|
12
12
|
def eof? = value == :eof
|
|
13
13
|
|
|
14
14
|
# 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
|
|
15
|
-
|
|
15
|
+
# buffer 应为二进制字符串,offset 仅用于字面扫描;正则始终看到完整窗口以保留锚点语义。
|
|
16
|
+
# final 表示不会再有新输入,此时不完整的编码尾部也必须报错,不能永远当作等待分片。
|
|
17
|
+
def locate(buffer, final: false, offset: 0)
|
|
16
18
|
case value
|
|
17
19
|
when String
|
|
18
|
-
offset = buffer.index(value)
|
|
20
|
+
offset = buffer.index(value, offset)
|
|
19
21
|
return [offset, value.bytesize, []] if offset
|
|
20
22
|
when Regexp
|
|
21
23
|
# 正则只读取输入;只有编码标记不同才复制,避免每个模式额外分配缓冲对象。
|
|
@@ -42,6 +44,7 @@ class Expect
|
|
|
42
44
|
private
|
|
43
45
|
|
|
44
46
|
# 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
|
|
47
|
+
# 转码仅用于区分“不完整尾部”和“非法字节”,结果不回写缓冲,也不做编码归一化。
|
|
45
48
|
def validate_incomplete_suffix!(text, final:)
|
|
46
49
|
if !final && text.encoding == Encoding::UTF_8
|
|
47
50
|
incomplete = begin
|
data/lib/expect/pattern_list.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
|
|
5
|
+
# 分组结构直接决定 Matcher 的“声明组 → 会话 → 模式”优先级,不能按匹配位置重排。
|
|
5
6
|
class PatternList
|
|
6
7
|
attr_reader :groups, :timeout_pattern
|
|
7
8
|
|
|
@@ -34,6 +35,7 @@ class Expect
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# 注册一次等待的唯一超时回调,重复定义直接报错,避免悄悄覆盖业务处理。
|
|
38
|
+
# 超时是整次等待的事件,回调收到全部活跃会话,不归属于某一个来源组。
|
|
37
39
|
def timeout(&block)
|
|
38
40
|
raise ArgumentError, "timeout callback already registered" if @timeout_pattern
|
|
39
41
|
|
|
@@ -42,6 +44,7 @@ class Expect
|
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# 汇总并去重读取源,同一会话出现在多个模式组时仍只读取一次。
|
|
47
|
+
# 这里只去重会话对象;不同会话包装同一 IO 时的读取归属由 Matcher 决定。
|
|
45
48
|
def sessions = groups.flat_map(&:first).uniq
|
|
46
49
|
|
|
47
50
|
# 收集指定会话的所有 EOF 处理器,保留原注册顺序。
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
|
|
5
|
+
# 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
|
|
6
|
+
# 不依赖会话或 IO;每个日志目标或诊断方向使用独立实例。
|
|
7
|
+
class Redactor
|
|
8
|
+
# 完整诊断文本只匹配完整秘密;流边界的疑似秘密前缀由 finish 的默认策略保护。
|
|
9
|
+
def self.redact(data, patterns, replacement: "[FILTERED]")
|
|
10
|
+
filter = new(patterns, replacement: replacement)
|
|
11
|
+
filter.append(data) + filter.finish(partial: false)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
|
|
15
|
+
def initialize(patterns, replacement: "[FILTERED]")
|
|
16
|
+
unless replacement.is_a?(String) && !replacement.empty?
|
|
17
|
+
raise ArgumentError, "replacement must be a nonempty String"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@replacement = replacement.b.freeze
|
|
21
|
+
self.patterns = patterns
|
|
22
|
+
@pending = "".b
|
|
23
|
+
@hidden = "".b
|
|
24
|
+
@masking = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 更新后续匹配规则并保留已有尾部与掩码;不能追溯修改已经交付给日志目标的内容。
|
|
28
|
+
def patterns=(patterns)
|
|
29
|
+
unless patterns.is_a?(Array) && patterns.all? { |pattern| pattern.is_a?(String) && !pattern.empty? }
|
|
30
|
+
raise ArgumentError, "patterns must be an Array of nonempty Strings"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@patterns = patterns.map { |pattern| pattern.b.freeze }.uniq.freeze
|
|
34
|
+
@lookbehind = [(@patterns.map(&:bytesize).max || 0) - 1, 0].max
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
|
|
38
|
+
def append(data)
|
|
39
|
+
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
40
|
+
|
|
41
|
+
@pending << data.b
|
|
42
|
+
@hidden << ("\0" * data.bytesize)
|
|
43
|
+
mark_secrets
|
|
44
|
+
release([@pending.bytesize - @lookbehind, 0].max)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
|
|
48
|
+
def finish(partial: true)
|
|
49
|
+
raise ArgumentError, "partial must be true or false" unless [true, false].include?(partial)
|
|
50
|
+
|
|
51
|
+
mark_secrets
|
|
52
|
+
mark_partial_secrets if partial
|
|
53
|
+
output = release(@pending.bytesize)
|
|
54
|
+
@masking = false
|
|
55
|
+
output
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 过滤器公开后仍不在诊断摘要中展开注册秘密或尚未交付的原始字节。
|
|
59
|
+
def inspect = "#<#{self.class}>"
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
|
|
64
|
+
def mark_partial_secrets
|
|
65
|
+
@patterns.each do |pattern|
|
|
66
|
+
[pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
|
|
67
|
+
next unless @pending.end_with?(pattern.byteslice(0, length))
|
|
68
|
+
|
|
69
|
+
@hidden[-length, length] = "\1" * length
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
|
|
76
|
+
def mark_secrets
|
|
77
|
+
@patterns.each do |pattern|
|
|
78
|
+
offset = -1
|
|
79
|
+
while (offset = @pending.index(pattern, offset + 1))
|
|
80
|
+
@hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
|
|
86
|
+
# masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次替换标记。
|
|
87
|
+
def release(length)
|
|
88
|
+
output = "".b
|
|
89
|
+
cursor = 0
|
|
90
|
+
while cursor < length
|
|
91
|
+
hidden = @hidden.getbyte(cursor) == 1
|
|
92
|
+
ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
|
|
93
|
+
if hidden
|
|
94
|
+
output << @replacement unless @masking
|
|
95
|
+
else
|
|
96
|
+
output << @pending.byteslice(cursor, ending - cursor)
|
|
97
|
+
end
|
|
98
|
+
@masking = hidden
|
|
99
|
+
cursor = ending
|
|
100
|
+
end
|
|
101
|
+
@pending = @pending.byteslice(length..)
|
|
102
|
+
@hidden = @hidden.byteslice(length..)
|
|
103
|
+
output
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/expect/relay.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
|
|
5
|
+
# 本对象只暂借未处理输入;读缓冲、发送进度与转义回调分开保存,避免重入时重复交付。
|
|
5
6
|
class Relay
|
|
7
|
+
# 将普通匹配缓冲移入本轮转接,并记住上层交互缓冲;不关闭或接管任何外部 IO。
|
|
6
8
|
def initialize(sessions, timeout)
|
|
7
9
|
@sessions = sessions.uniq
|
|
8
10
|
@active = @sessions.dup
|
|
@@ -12,6 +14,8 @@ class Expect
|
|
|
12
14
|
@previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
# 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
|
|
18
|
+
# 真实 IO 的写入由非阻塞游标推进,用户日志和回调仍同步执行,须由调用方保证及时返回。
|
|
15
19
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
|
|
16
20
|
def run
|
|
17
21
|
@sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
|
|
@@ -85,15 +89,19 @@ class Expect
|
|
|
85
89
|
end
|
|
86
90
|
end
|
|
87
91
|
ensure
|
|
92
|
+
# 未处理输入还给会话,已排队字节仍归发送游标;两者不能合并,否则恢复会重放前缀。
|
|
88
93
|
@previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
|
|
89
94
|
@buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
|
|
90
95
|
end
|
|
96
|
+
|
|
91
97
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
92
98
|
|
|
93
99
|
private
|
|
94
100
|
|
|
101
|
+
# 只收集尚未完成的目标,供共同 select 以及最早写入期限计算使用。
|
|
95
102
|
def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
|
|
96
103
|
|
|
104
|
+
# 每个目标每轮最多推进一个片段;移除已完成游标,并告知主循环是否值得立即继续轮询。
|
|
97
105
|
def advance_outputs(check_timeout: true)
|
|
98
106
|
progress = false
|
|
99
107
|
@sessions.each do |session|
|
|
@@ -104,8 +112,10 @@ class Expect
|
|
|
104
112
|
progress
|
|
105
113
|
end
|
|
106
114
|
|
|
115
|
+
# 转接总期限不因持续输入或某个目标的写入进展而重置。
|
|
107
116
|
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
108
117
|
|
|
118
|
+
# 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
|
|
109
119
|
def read_sources(pending)
|
|
110
120
|
# 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
|
|
111
121
|
targets = pending.map(&:target).grep(Expect)
|
|
@@ -113,6 +123,8 @@ class Expect
|
|
|
113
123
|
(sources + targets).uniq(&:to_io).reject(&:eof?)
|
|
114
124
|
end
|
|
115
125
|
|
|
126
|
+
# 总期限结束时将可交付尾部转为发送游标,至多尝试一轮交付;余量由源会话保存。
|
|
127
|
+
# 目标的写期限更早到达时保留 WriteTimeout 语义,不能被转接的普通超时掩盖。
|
|
116
128
|
def finish_timeout(idle)
|
|
117
129
|
# 调度延迟可能让两种期限均已到达,仍按先到的期限决定结果。
|
|
118
130
|
outputs.each { |output| output.check_timeout! if output.deadline && output.deadline < @deadline }
|
data/lib/expect/relay_writer.rb
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
|
|
5
|
+
# 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
|
|
5
6
|
class RelayWriter
|
|
6
7
|
attr_reader :target, :deadline
|
|
7
8
|
|
|
9
|
+
# data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
|
|
8
10
|
def initialize(target, data)
|
|
9
11
|
@target = target
|
|
10
12
|
@data = data
|
|
@@ -13,17 +15,21 @@ class Expect
|
|
|
13
15
|
restart_timeout
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
|
|
19
|
+
# 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
|
|
16
20
|
def restart_timeout
|
|
17
21
|
period = target.write_timeout if target.is_a?(Expect)
|
|
18
22
|
@deadline = period && (Expect.monotonic + period)
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
|
|
21
26
|
def io
|
|
22
27
|
return target.writer if target.is_a?(Expect)
|
|
23
28
|
|
|
24
29
|
target if target.is_a?(IO)
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
# 数据交付完但 flush 失败仍未完成;重试只补 flush,不重新发送已接受的数据。
|
|
27
33
|
def done? = @offset == @data.bytesize && @flushed
|
|
28
34
|
|
|
29
35
|
# 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
|
|
@@ -52,11 +58,14 @@ class Expect
|
|
|
52
58
|
end
|
|
53
59
|
true
|
|
54
60
|
rescue Errno::EINTR
|
|
61
|
+
# 未获得写入计数就不移动游标;先检查原期限,再让共同循环轮询输入并重试。
|
|
55
62
|
check_timeout! if check_timeout
|
|
56
63
|
true
|
|
57
64
|
end
|
|
65
|
+
|
|
58
66
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
59
67
|
|
|
68
|
+
# 保留本数据块已交付的进度,便于调用方区分未写入和部分写入;不清空恢复用的游标。
|
|
60
69
|
def check_timeout!
|
|
61
70
|
return unless deadline && Expect.monotonic >= deadline
|
|
62
71
|
|
data/lib/expect/result.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 保存一次等待的结果,保留 Struct 原生数组、Hash 转换和模式解构能力。
|
|
5
5
|
# number 仅在文本匹配时存在;error 为 :timeout、:eof 或原始 IO 异常,文本字段均为字节串。
|
|
6
|
+
# before/match/after 是产生结果时的快照,后续读取不追加到旧结果;captures 保留未参与捕获的 nil。
|
|
7
|
+
# 结果对象仍采用普通可变 Struct 语义;session 只引用来源,不取得 IO 或子进程所有权。
|
|
6
8
|
Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
|
|
7
9
|
# 是否命中文本模式;事件不会返回模式序号。
|
|
8
10
|
def matched? = !number.nil?
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class Expect
|
|
4
4
|
# 独立保存句柄、PID 和日志所有权,让终结器无需直接捕获会话即可清理遗弃资源。
|
|
5
|
+
# IO 是否关闭与子进程是否退出分别记录;不能仅凭句柄状态清空 PID 或伪造退出状态。
|
|
5
6
|
class SessionResources
|
|
7
|
+
# owned_log 只保存库打开的文件;借用的 IO/日志回调留在会话中,不能成为终结器的引用根。
|
|
6
8
|
attr_accessor :pid, :status, :owned_log
|
|
7
9
|
attr_reader :reader, :writer, :slave, :owner, :own
|
|
8
10
|
|
|
@@ -16,6 +18,7 @@ class Expect
|
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# 只关闭由本库拥有的 IO;借用的 reader、writer 由调用方管理。
|
|
21
|
+
# 常规关闭错误延后到所有句柄尝试完再抛出,失败句柄仍留在账本内供下一次关闭重试。
|
|
19
22
|
def close_handles
|
|
20
23
|
return unless own
|
|
21
24
|
|
|
@@ -40,6 +43,7 @@ class Expect
|
|
|
40
43
|
status
|
|
41
44
|
rescue Errno::ECHILD
|
|
42
45
|
# 子进程可能已被调用方或其他线程回收,不再保留可能被系统复用的 PID。
|
|
46
|
+
# 无法获取别人已取走的 Process::Status,保留未知状态而不是推断成功或失败。
|
|
43
47
|
@pid = nil
|
|
44
48
|
status
|
|
45
49
|
end
|
data/lib/expect/terminal.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "shellwords"
|
|
|
5
5
|
# 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
|
|
6
6
|
class Expect
|
|
7
7
|
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
8
|
+
# 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
|
|
8
9
|
def stty(*modes)
|
|
9
10
|
return "" unless tty?
|
|
10
11
|
|
|
@@ -25,7 +26,7 @@ class Expect
|
|
|
25
26
|
sink&.close unless sink&.closed?
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
# 读取终端的 [行数, 列数]
|
|
29
|
+
# 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
|
|
29
30
|
def winsize = to_io.winsize
|
|
30
31
|
|
|
31
32
|
# 更新终端尺寸,由内核通知前台进程。
|
data/lib/expect/version.rb
CHANGED