expect-pty 0.3.2 → 0.4.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.
@@ -5,11 +5,13 @@ require_relative "relay"
5
5
 
6
6
  # 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
7
7
  class Expect
8
+ # 正则没有“潜在部分匹配”接口,只保留有限历史;已转发的历史字节不能撤回。
8
9
  REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
9
10
  private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
10
11
 
11
- # Presents remote text on a raw local terminal without changing existing CRLF sequences.
12
+ # 在 raw 本地终端显示远端文本,补齐 LF 所需的 CR,同时保留已有 CRLF。
12
13
  class InteractOutput
14
+ # 包装器借用目标,不复制或关闭其描述符;换行状态属于这个目标的连续显示流。
13
15
  def initialize(target)
14
16
  @target = target
15
17
  @previous_carriage_return = false
@@ -17,6 +19,7 @@ class Expect
17
19
 
18
20
  attr_reader :target
19
21
 
22
+ # 只改变显示字节,不改写匹配输入;记住上一块的 CR,避免分块的 CRLF 被扩成 CRCRLF。
20
23
  def render(data)
21
24
  bytes = data.to_s.b
22
25
  rendered = bytes.gsub(/(?<!\r)\n/n, "\r\n")
@@ -25,6 +28,7 @@ class Expect
25
28
  rendered
26
29
  end
27
30
 
31
+ # 提供普通可写对象接口;Relay 会先 render,再按转换后字节数维护独立发送游标。
28
32
  def write(data)
29
33
  @target.write(render(data))
30
34
  end
@@ -169,10 +173,12 @@ class Expect
169
173
  return block_given? && count.positive? ? :pending : true
170
174
  end
171
175
  end
176
+
172
177
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
173
178
 
174
179
  private_class_method :relay_buffer
175
180
 
181
+ # 同一输入 IO 重用一个借用会话,以保留上次接管预读的尾部并避免不断积累包装器。
176
182
  def interact_source(input)
177
183
  return input if input.is_a?(Expect)
178
184
 
@@ -181,6 +187,8 @@ class Expect
181
187
  @interact_inputs[input] ||= Expect.open(input)
182
188
  end
183
189
 
190
+ # 为当前数据块冻结目标选择并各建一个发送游标;此后修改 listeners 只影响后续数据。
191
+ # 调用方须先排空旧游标;显示转换也只做一次,短写重试时不能重复转换 CRLF。
184
192
  def queue_output(data)
185
193
  targets = []
186
194
  targets << $stdout if log_stdout?
@@ -196,7 +204,7 @@ class Expect
196
204
 
197
205
  private :interact_source, :queue_output
198
206
 
199
- # Only interact knows which stream is the local keyboard. Generic interconnect leaves terminals alone.
207
+ # 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
200
208
  def prepare_interact_terminal(source)
201
209
  return unless source.raw_terminal? && source.tty?
202
210
 
@@ -204,11 +212,12 @@ class Expect
204
212
  state = [io, io.console_mode]
205
213
  io.raw!
206
214
  state
207
- rescue Exception # rubocop:disable Lint/RescueException -- Restore a partially changed terminal on interrupts.
215
+ rescue Exception # rubocop:disable Lint/RescueException -- 中断时也恢复可能已部分修改的终端。
208
216
  restore_interact_terminal(state)
209
217
  raise
210
218
  end
211
219
 
220
+ # 还原 prepare 保存的完整终端模式;没有切换过或句柄已关闭时无需恢复。
212
221
  def restore_interact_terminal(state)
213
222
  return unless state
214
223
 
@@ -216,6 +225,7 @@ class Expect
216
225
  io.console_mode = mode unless io.closed?
217
226
  end
218
227
 
228
+ # 仅为已切为 raw 的同一个本地终端补齐换行,文件、管道和其他终端保留原字节。
219
229
  def interact_display(source, output, terminal_state)
220
230
  @interact_output = nil unless terminal_state && @interact_output&.target.equal?(output)
221
231
  return output unless terminal_state
@@ -238,7 +248,11 @@ class Expect
238
248
  attr_accessor :sequences
239
249
  # 让同步写入的背压读取遵守当前转接的数据所有权,退出后恢复普通匹配缓冲。
240
250
  attr_accessor :interaction_buffer
251
+ # 只有裁剪、替换和消费才改变代次;同一代次只会追加,供字面扫描复用已排除的前缀。
252
+ attr_reader :buffer_generation
253
+ # 待交付游标随源会话保存,Relay 的超时或异常退出不会丢失各目标已经写出的进度。
241
254
  attr_reader :relay_outputs
255
+ # 数组包装区分“没有待执行回调”与“已识别无处理器的停止转义”,前缀交付后只派发一次。
242
256
  attr_accessor :relay_callback
243
257
 
244
258
  # 历史属于产生它的转义规则;同规则重入继续匹配,换规则不能重放已转发输入。
@@ -252,5 +266,6 @@ class Expect
252
266
  # 转接尚未处理的输入不能被匹配窗口上限裁掉;下次 expect 会重新应用该上限。
253
267
  def restore_relay_buffer(buffer)
254
268
  @buffer = buffer + @buffer
269
+ @buffer_generation += 1
255
270
  end
256
271
  end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redactor"
4
+
5
+ # 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
6
+ # 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
7
+ class Expect
8
+ # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
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
36
+
37
+ # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
38
+ def log_output=(target)
39
+ unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
40
+ raise ArgumentError, "log output must support write or call, or be nil"
41
+ end
42
+
43
+ replace_log(target)
44
+ end
45
+
46
+ # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
47
+ def log_to(target = nil, mode: "a", &block)
48
+ raise ArgumentError, "provide a log target or a block, not both" if block && target
49
+
50
+ target = block if block
51
+ if target.respond_to?(:to_path) || target.is_a?(String)
52
+ raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
53
+
54
+ # 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
55
+ flush_log
56
+ # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
57
+ replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
58
+ else
59
+ raise ArgumentError, "provide a log target or a block" unless target
60
+
61
+ self.log_output = target
62
+ end
63
+ end
64
+
65
+ # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
66
+ # 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
67
+ def write_log(*objects)
68
+ target = log_output
69
+ return unless target
70
+
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
77
+ target.respond_to?(:call) ? target.call(data) : emit(target, data)
78
+ end
79
+
80
+ # 返回监听器列表副本,避免外部原地修改转发关系。
81
+ def listeners = @listeners.dup
82
+
83
+ # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
84
+ def listeners=(outputs)
85
+ outputs = Array(outputs)
86
+ raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
87
+
88
+ @listeners = outputs.dup
89
+ end
90
+
91
+ private
92
+
93
+ # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
94
+ def propagate(data)
95
+ emit($stdout, data) if log_stdout?
96
+ @listeners.each { |listener| emit(listener, data) } if log_listeners?
97
+ end
98
+
99
+ # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
100
+ # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
101
+ # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
102
+ def emit(target, data)
103
+ offset = 0
104
+ while offset < data.bytesize
105
+ count = target.write(data.byteslice(offset..))
106
+ unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
107
+ raise IOError, "write must return the number of accepted bytes"
108
+ end
109
+
110
+ offset += count
111
+ end
112
+ target.flush if target.respond_to?(:flush)
113
+ end
114
+
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
178
+ end
179
+
180
+ # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
181
+ def replace_log(target, owned: false)
182
+ # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
183
+ return target if log_output.equal?(target)
184
+
185
+ flush_log
186
+ # 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
187
+ return target if log_output.equal?(target)
188
+
189
+ previous = @resources.owned_log
190
+ previous.close if previous && !previous.closed?
191
+ # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
192
+ @resources.owned_log = owned ? target : nil
193
+ @log_output = target
194
+ @log_redactor = nil
195
+ target
196
+ rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
197
+ target.close if owned && target && !target.closed?
198
+ raise
199
+ end
200
+ end
@@ -2,16 +2,20 @@
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 = {}
14
17
  @polled = false
18
+ @expired_eof_continuation = false
15
19
  end
16
20
 
17
21
  # 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
@@ -30,16 +34,19 @@ class Expect
30
34
  end
31
35
  loop do
32
36
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
33
- result = if (matched = find_match)
37
+ result = if !@expired_eof_continuation && !hard_expired? && (matched = find_match)
34
38
  handle_match(*matched)
35
39
  elsif (session = unhandled_eof)
36
40
  handle_eof(session)
41
+ elsif @expired_eof_continuation || hard_expired?
42
+ handle_timeout
37
43
  else
38
44
  read_next
39
45
  end
40
46
  return result unless result == :retry
41
47
  end
42
48
  ensure
49
+ # 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
43
50
  @relay_buffers.each do |session, buffer|
44
51
  buffer.replace(session.clear_buffer)
45
52
  session.__send__(:interaction_buffer=, buffer)
@@ -67,17 +74,52 @@ class Expect
67
74
  stalled = nil
68
75
  end
69
76
  patterns.each do |pattern|
77
+ return nil if hard_expired?
70
78
  next if stalled && stalled[:patterns].include?(pattern)
71
79
 
72
- position = pattern.locate(buffer, final: session.eof?)
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?
73
87
  return [session, pattern, position] if position
74
88
  end
75
89
  end
76
90
  end
77
91
  nil
78
92
  end
93
+
79
94
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
80
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
+
81
123
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
82
124
  def handle_match(session, pattern, position)
83
125
  previous_buffer = session.buffer
@@ -91,7 +133,8 @@ class Expect
91
133
  stalled[:patterns] << pattern
92
134
  end
93
135
  @deadline = next_deadline if action == CONTINUE
94
- return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired?
136
+ # 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
137
+ return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired? && !hard_expired?
95
138
 
96
139
  :retry
97
140
  end
@@ -109,12 +152,17 @@ class Expect
109
152
  return result unless actions.any? { |action| continuing?(action) }
110
153
 
111
154
  @deadline = next_deadline if actions.include?(CONTINUE)
112
- @sessions.all? { |candidate| @handled_eof.include?(candidate) } ? result : :retry
155
+ return result if @sessions.all? { |candidate| @handled_eof.include?(candidate) }
156
+
157
+ # 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
158
+ @expired_eof_continuation = !actions.include?(CONTINUE) && expired?
159
+
160
+ :retry
113
161
  end
114
162
 
115
163
  # 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
116
164
  def read_next
117
- return handle_timeout if @polled && expired?
165
+ return handle_timeout if hard_expired? || (@polled && expired?)
118
166
 
119
167
  readers = active_sessions
120
168
  begin
@@ -139,6 +187,8 @@ class Expect
139
187
  sessions.each { |session| by_io[session.to_io] ||= session }
140
188
  end
141
189
  readable.each do |io|
190
+ break if hard_expired?
191
+
142
192
  session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
143
193
  begin
144
194
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
@@ -161,7 +211,14 @@ class Expect
161
211
  def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
162
212
 
163
213
  # 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
164
- def next_deadline = @timeout && (Expect.monotonic + @timeout)
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
165
222
 
166
223
  # 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
167
224
  def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
@@ -169,19 +226,24 @@ class Expect
169
226
  # 判断有限期限是否已到达;无限等待不会触发超时。
170
227
  def expired? = @deadline && Expect.monotonic >= @deadline
171
228
 
229
+ # 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
230
+ def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
231
+
172
232
  # select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
173
233
  def record_error(error)
174
234
  @sessions.map { |session| session.__send__(:record_error, error) }.first
175
235
  end
176
236
 
177
237
  # 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
238
+ # 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
178
239
  def handle_timeout
179
240
  results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
180
241
  action = @patterns.timeout_pattern&.call(active_sessions)
181
- return results.first unless action == CONTINUE
242
+ return results.first unless action == CONTINUE && !hard_expired?
182
243
 
183
244
  @deadline = next_deadline
184
245
  @polled = false
246
+ @expired_eof_continuation = false
185
247
  :retry
186
248
  end
187
249
  end
@@ -12,14 +12,20 @@ class Expect
12
12
  def eof? = value == :eof
13
13
 
14
14
  # 在缓冲中定位字符串或正则,返回 [字节偏移, 字节长度, 捕获组];事件或未匹配返回 nil。
15
- def locate(buffer, final: false)
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
- text = buffer.dup
22
- text.force_encoding(value.encoding) if value.fixed_encoding?
23
+ # 正则只读取输入;只有编码标记不同才复制,避免每个模式额外分配缓冲对象。
24
+ text = if value.fixed_encoding? && value.encoding != buffer.encoding
25
+ buffer.dup.force_encoding(value.encoding)
26
+ else
27
+ buffer
28
+ end
23
29
  unless text.valid_encoding?
24
30
  # 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
25
31
  validate_incomplete_suffix!(text, final: final)
@@ -38,6 +44,7 @@ class Expect
38
44
  private
39
45
 
40
46
  # 仅容忍末尾尚未收全的 UTF-8 字符,其他非法编码直接报错,不静默替换接收字节。
47
+ # 转码仅用于区分“不完整尾部”和“非法字节”,结果不回写缓冲,也不做编码归一化。
41
48
  def validate_incomplete_suffix!(text, final:)
42
49
  if !final && text.encoding == Encoding::UTF_8
43
50
  incomplete = begin
@@ -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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Expect
4
+ # 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
5
+ # 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
6
+ # 上层负责传入已复制的非空二进制秘密,每个日志目标或诊断方向使用独立实例。
7
+ class Redactor
8
+ # pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
9
+ def initialize(patterns)
10
+ self.patterns = patterns
11
+ @pending = "".b
12
+ @hidden = "".b
13
+ @masking = false
14
+ end
15
+
16
+ # 更新后续匹配规则并保留已有尾部与掩码;不能追溯修改已经交付给日志目标的内容。
17
+ def patterns=(patterns)
18
+ @patterns = patterns
19
+ @lookbehind = patterns.map(&:bytesize).max - 1
20
+ end
21
+
22
+ # 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
23
+ def append(data)
24
+ @pending << data
25
+ @hidden << ("\0" * data.bytesize)
26
+ mark_secrets
27
+ release([@pending.bytesize - @lookbehind, 0].max)
28
+ end
29
+
30
+ # EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
31
+ def finish
32
+ mark_secrets
33
+ @patterns.each do |pattern|
34
+ [pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
35
+ next unless @pending.end_with?(pattern.byteslice(0, length))
36
+
37
+ @hidden[-length, length] = "\1" * length
38
+ break
39
+ end
40
+ end
41
+ output = release(@pending.bytesize)
42
+ @masking = false
43
+ output
44
+ end
45
+
46
+ private
47
+
48
+ # 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
49
+ def mark_secrets
50
+ @patterns.each do |pattern|
51
+ offset = -1
52
+ while (offset = @pending.index(pattern, offset + 1))
53
+ @hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
54
+ end
55
+ end
56
+ end
57
+
58
+ # 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
59
+ # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次 [FILTERED]。
60
+ def release(length)
61
+ output = "".b
62
+ cursor = 0
63
+ while cursor < length
64
+ hidden = @hidden.getbyte(cursor) == 1
65
+ ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
66
+ if hidden
67
+ output << "[FILTERED]" unless @masking
68
+ else
69
+ output << @pending.byteslice(cursor, ending - cursor)
70
+ end
71
+ @masking = hidden
72
+ cursor = ending
73
+ end
74
+ @pending = @pending.byteslice(length..)
75
+ @hidden = @hidden.byteslice(length..)
76
+ output
77
+ end
78
+ end
79
+
80
+ private_constant :Redactor
81
+ 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 }