expect-pty 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bffb0687dc259c0b1e2abc3920def8a496c67b0853dda43b0f7da98d0273b8b6
4
- data.tar.gz: bd56c190bd1bb312902e2679a03c91d620bff4498551402344a46f979df1d9dd
3
+ metadata.gz: e860210612e392f800cf5839bb130625bafb17e47ffc86e49787446537c63a81
4
+ data.tar.gz: cbabed54f7dcd21c2bd5dc7ee2b63627a3ed424f94101df786812ba5e652ab00
5
5
  SHA512:
6
- metadata.gz: e018796d8f8b2e9b2efdf515bce9ef4b57fd0e5d63e2be511e97231990b0525d98f9814c9aa23a480ae9e0956b28048e70de6187ebb535e91016f5bcb21cee61
7
- data.tar.gz: 8633ebbdbee3858cb7a0756160b15357fdbc7eaf3df46ce2d8ac76f0a57eb74c3b35271c05661fd07de1c9d2f011a1e4fc0f839e4279368a7ac15a70b3deaa9e
6
+ metadata.gz: cb11a81d8cce676da6cc3fc0aaf66044eff142e7e4fb01d8537b4b3d3e77bb691974f28f7a5eebbda260b77def9efaf3b8c5b26956e41ff576cf4aae1b73c24c
7
+ data.tar.gz: f362101cc8db4d810c9a4ad8c48cf1b4000cd626d9063085421901092edd76d8347b56c2ad44681e4906b6ad77d5f5809fccc5a24bb111cd254fa7e0523266fc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.1 - 2026-09-27
6
+
7
+ - **终端清理**:`stty` 读取失败或中断后也会清理辅助管道和进程,保留原始异常,避免影响主会话。
8
+ - **脱敏性能**:减少连续与重叠秘密匹配时的内存分配和处理耗时,保留跨块、规则更新和流尾部的脱敏行为。
9
+
10
+ ## 0.5.0 - 2026-09-27
11
+
12
+ - **独立脱敏**:新增无需创建会话的 `Expect::Redactor`,支持完整文本和分块字节流过滤,以及自定义替换标记。
13
+ - **过滤规则**:支持空模式列表和复制校验后的规则更新,完整文本仅匹配完整秘密,流结束时默认隐藏未完成的秘密前缀,现有会话脱敏行为保持不变。
14
+
5
15
  ## 0.4.0 - 2026-09-27
6
16
 
7
17
  - **缓冲与生命周期**:新增只读 `buffer_discarded_bytes`,区分上限裁剪与正常消费;补齐外部回收进程、借用 IO、重复关闭和失败重试的契约回归。
data/README.md CHANGED
@@ -16,7 +16,7 @@ RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提
16
16
  项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
17
17
 
18
18
  ```ruby
19
- gem "expect-pty", "~> 0.4.0", require: "expect/pty"
19
+ gem "expect-pty", "~> 0.5.1", require: "expect/pty"
20
20
  ```
21
21
 
22
22
  也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
@@ -29,8 +29,8 @@ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "m
29
29
 
30
30
  ```sh
31
31
  mkdir -p tmp
32
- gem build expect-pty.gemspec --output tmp/expect-pty-0.4.0.gem
33
- gem install ./tmp/expect-pty-0.4.0.gem
32
+ gem build expect-pty.gemspec --output tmp/expect-pty-0.5.1.gem
33
+ gem install ./tmp/expect-pty-0.5.1.gem
34
34
  ```
35
35
 
36
36
  ```ruby
@@ -290,6 +290,23 @@ session.log_to("session.log")
290
290
  3 的缓冲快照只显示 `[FILTERED]`,避免部分消费或裁剪后剩下的秘密片段绕过过滤。日志回调的分块边界因此可能变化。GC
291
291
  兜底不会调用用户日志回调,需显式关闭会话以交付过滤器尾部。
292
292
 
293
+ 应用需要过滤自己的日志或错误文本时,可以直接使用独立的字节过滤器,无需打开 PTY 或创建会话:
294
+
295
+ ```ruby
296
+ require "expect/redactor"
297
+
298
+ safe_message = Expect::Redactor.redact(message, [password], replacement: "[REDACTED]")
299
+ filter = Expect::Redactor.new([password], replacement: "[REDACTED]")
300
+ output.write(filter.append(chunk)) # 每个输出流使用独立实例
301
+ output.write(filter.finish)
302
+ ```
303
+
304
+ `patterns=` 用由非空字符串组成的数组替换后续规则,空数组表示不注册秘密;输入数组、字符串和替换标记都会复制,
305
+ 无效更新不改变已有规则或暂存数据。已输出的内容不能撤回,更新后也会保留先前标记为隐藏的尾部区间。
306
+ `redact` 处理完整文本,只匹配完整秘密;`finish` 默认还隐藏未完成的秘密前缀。已确定输入完整的调用方可用
307
+ `finish(partial: false)`。连续或重叠的隐藏区间合并成一个替换标记。过滤不自动识别终端控制符或编码,过滤器本身不持有 IO,
308
+ 也不负责会话作用域或异常对象的安全字段选择。该公共接口从 0.5.0 开始提供。
309
+
293
310
  ```ruby
294
311
  session.interact(input: $stdin, escape: "\x1d", output: $stdout) # Ctrl-]
295
312
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ # 同一驱动可加载基线或候选库;每轮创建独立过滤器,不能跨样本继承流状态。
6
+ module RedactorBenchmark
7
+ def self.measure(runner, name, input, patterns, expected, chunk_size: nil, partial: false)
8
+ chunks = if chunk_size
9
+ (0...input.bytesize).step(chunk_size).map { |offset| input.byteslice(offset, chunk_size) }
10
+ else
11
+ [input]
12
+ end
13
+ runner.measure(name, bytes: input.bytesize,
14
+ inputs: { bytes: input.bytesize, secret_lengths: patterns.map(&:bytesize),
15
+ chunk_size: chunk_size, partial: partial, repeated_finish: true },
16
+ verify: ->(output) { ExpectBenchmark.check(output == expected, "incorrect #{name} output") },
17
+ iterations: 5) do
18
+ filter = Expect::Redactor.new(patterns)
19
+ output = +"".b
20
+ chunks.each { |chunk| output << filter.append(chunk) }
21
+ output << filter.finish(partial: partial) << filter.finish(partial: partial)
22
+ end
23
+ end
24
+ end
25
+
26
+ runner = ExpectBenchmark::Runner.new("redactor")
27
+ size = runner.smoke ? 512 : 65_536
28
+ plain = "x" * size
29
+ RedactorBenchmark.measure(runner, "empty_rules", plain, [], plain)
30
+ RedactorBenchmark.measure(runner, "no_match", plain, ["secret"], plain)
31
+ RedactorBenchmark.measure(runner, "no_match_many", plain, %w[secret password token credential], plain)
32
+ RedactorBenchmark.measure(runner, "sparse", "#{plain}secret!", ["secret"], "#{plain}[FILTERED]!")
33
+ RedactorBenchmark.measure(runner, "contiguous_short", "token" * (size / 5), ["token"], "[FILTERED]")
34
+
35
+ (runner.smoke ? [1, 32, 128] : [1, 32, 1024, 4096]).each do |length|
36
+ RedactorBenchmark.measure(runner, "overlap_#{length}", "a" * size, ["a" * length], "[FILTERED]")
37
+ end
38
+ count = runner.smoke ? 8 : 256
39
+ RedactorBenchmark.measure(runner, "contained_patterns", "abcde!" * count,
40
+ %w[abc bc ab bcd], "[FILTERED]e!" * count)
41
+ RedactorBenchmark.measure(runner, "binary_overlap", "\0\xffABC\x80!".b * count,
42
+ ["\xffABC".b, "ABC\x80".b], "\0[FILTERED]!".b * count, chunk_size: 3)
43
+ RedactorBenchmark.measure(runner, "marker_in_input", "[FILTERED] secret!" * count,
44
+ %w[secret FILTERED], "[[FILTERED]] [FILTERED]!" * count)
45
+
46
+ [1, 7, 4096].each do |chunk_size|
47
+ RedactorBenchmark.measure(runner, "stream_#{chunk_size}", "prefix secret!\n" * count,
48
+ ["secret"], "prefix [FILTERED]!\n" * count, chunk_size: chunk_size)
49
+ end
50
+ [true, false].each do |partial|
51
+ RedactorBenchmark.measure(runner, "partial_#{partial}", "prefix pass", ["password"],
52
+ partial ? "prefix [FILTERED]" : "prefix pass", chunk_size: 1, partial: partial)
53
+ end
54
+
55
+ runner.measure("update_pending", bytes: 10, inputs: { update: "keep old mask and match new secret" },
56
+ verify: lambda { |output|
57
+ ExpectBenchmark.check(output == "[FILTERED]!")
58
+ }, iterations: 100) do
59
+ filter = Expect::Redactor.new(%w[abc long-pattern])
60
+ output = filter.append("abcsec")
61
+ filter.patterns = ["secret"]
62
+ output << filter.append("ret!") << filter.finish << filter.finish
63
+ end
64
+ runner.finish
@@ -15,9 +15,9 @@
15
15
  | `terminal.rb` | `stty` 和窗口尺寸的会话终端接口 |
16
16
  | `interaction.rb`、`relay.rb`、`relay_writer.rb` | 人工接管、转义处理、共同调度和各目标发送游标 |
17
17
 
18
- `redactor.rb` 为日志和各方向诊断提供内部字节流过滤器,不持有 IO,也不接管协议缓冲。
18
+ `redactor.rb` 提供公共 `Expect::Redactor` 字节过滤接口,供会话日志、各方向诊断及上层库共用,不持有 IO,也不接管协议缓冲。
19
19
 
20
- 这些能力文件延续 `interaction.rb` 的类重开方式,公共入口仍是 `expect/pty`。按职责组织方法,不引入新的继承层级,也不复制会话状态。
20
+ 会话能力文件延续 `interaction.rb` 的类重开方式,入口为 `expect/pty`;独立过滤器可通过 `expect/redactor` 加载。按职责组织方法,不引入新的继承层级,也不复制会话状态。
21
21
 
22
22
  跨对象调用使用 `__send__` 访问受保护或私有方法。变更以下入口时,应同时检查调用方与失败恢复路径:
23
23
 
@@ -61,6 +61,16 @@ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子
61
61
  是否保留原始异常由当前构造、块或关闭作用域显式记录,不能直接读取调用者 rescue 中的 `$!`。`Interrupt` 和 `SystemExit`
62
62
  同样先清理再传播;`break` / `throw` 不是异常,此时清理失败仍应抛出。
63
63
 
64
+ `stty` 为辅助管道和 PID 创建独立的 SessionResources,不写入主会话的 PID/status,不关闭主会话。
65
+ 账本构造被中断时仍关闭局部变量中已经创建的管道;fork 后的副本只关闭本地管道,不向父进程的辅助 PID 发信号或 detach,等待途中也重新核对归属。
66
+ 正常路径读取输出并阻塞回收,EINTR 只重试 wait,不再次启动命令,也不增加固定等待。
67
+ 异常路径先尝试关闭两个管道,再依次等待自然退出、发送 TERM、发送 KILL;每阶段使用固定的 50ms 单调时钟预算,
68
+ 非阻塞 wait 或 signal 的 EINTR 不延长期限。ECHILD 立即释放该 PID 的归属,不向可能复用的 PID 发信号;
69
+ ESRCH 后仍尝试回收。三个阶段结束或系统调用失败后仍有 PID 时交给 `Process.detach` 异步回收,不保证返回前退出,
70
+ 也不伪造退出状态。预算约束进程轮询,不是任意同步 IO 或系统调度的硬实时保证。
71
+ 本次主异常(包括 Interrupt/SystemExit)保持对象身份;没有主异常时传播首个 IOError/SystemCallError 清理失败。
72
+ 永久失败的 close 只保证被尝试,不能报告成句柄已关闭;缺失 stty 仍为 IOError 并保留 ENOENT cause。
73
+
64
74
  ## 缓冲裁剪与字面扫描
65
75
 
66
76
  `buffer_discarded_bytes` 随会话累计,只在匹配窗口执行 `trim_buffer` 时增加。读取、`buffer=`、降低上限和下一次匹配应用上限都可能触发裁剪;匹配消费、清空、EOF 和 Relay 交接不计入,关闭后仍保留计数。它是可观察的丢弃量,不是缓冲满事件,也不提供全文归档。
@@ -77,7 +87,13 @@ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子
77
87
 
78
88
  `logging.rb` 分开处理接收日志、诊断和协议转发。`diagnostic_output` 借用 Logger、可写对象或回调,不进入资源账本;默认沿用 stderr。回调接收冻结的事件 Hash 和 message 字符串,不含会话对象。诊断失败与其他同步 IO 错误同样保留已读取的原始输入。
79
89
 
80
- `redactor.rb` 是内部的字节流过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
90
+ `redactor.rb` 是无 IO 的公共字节过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
91
+
92
+ 公共构造器及 `patterns=` 会校验并复制秘密;空模式列表直接交付字节,更新规则不清除已经建立的掩码。
93
+ 同一模式的命中按字节偏移递增枚举,相交或相邻区间合并后才写入掩码;不能通过跳过整个秘密长度省略重叠命中。
94
+ 合并仍与现有掩码取并集,保留其他模式和旧规则已标记的隐藏字节,不改变跨块连续区间只输出一个替换标记的规则。
95
+ 默认替换标记为 `[FILTERED]`,上层库可显式指定自己的标记。类方法 `redact` 使用独立流并以 `finish(partial: false)` 结束完整文本;
96
+ `append`/`finish` 的默认流策略仍隐藏末尾疑似秘密前缀。作用域所有权、终端渲染和异常字段选择由调用方负责。
81
97
 
82
98
  EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭结束所有方向;尾部疑似秘密前缀保守隐藏。GC 只清理所属资源,不执行用户回调或过滤尾部输出。调用方需显式关闭以交付尾部;借用日志和诊断目标不关闭,库打开的日志文件仍由 SessionResources 管理。
83
99
 
@@ -95,10 +111,12 @@ EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭
95
111
 
96
112
  | 不变量 | 测试 |
97
113
  | --- | --- |
114
+ | stty 主错误优先、辅助进程回收、EINTR 预算、ECHILD/ESRCH、detach 和主会话隔离 | terminal_cleanup_test |
98
115
  | 裁剪计数、正常消费、转接交接与日志错误 | buffer_accounting_test |
99
116
  | 外部回收、借用 IO、关闭失败重试、原生错误分类 | lifecycle_contract_test |
100
117
  | 绝对期限、接收和回调重置、过期文本、EOF、EINTR、零轮询 | deadline_test |
101
118
  | Logger/IO/回调、分片与重叠秘密、二进制、流尾部、关闭失败、覆盖与重入 | diagnostics_test |
119
+ | 公共过滤器独立加载、完整文本与分块、空规则、规则更新、输入复制、安全摘要及固定种子字节区间差分 | redactor_test |
102
120
  | 字面跨读取命中、声明优先级、缓存失效、操作序列与全量扫描对照 | literal_scan_test |
103
121
 
104
122
  ## 既有回归入口
data/docs/PERFORMANCE.md CHANGED
@@ -7,9 +7,10 @@ bundle exec ruby benchmark/matching.rb
7
7
  bundle exec ruby benchmark/relay.rb
8
8
  bundle exec ruby benchmark/send_slow.rb
9
9
  bundle exec ruby benchmark/scaling.rb
10
+ bundle exec ruby benchmark/redactor.rb
10
11
  ```
11
12
 
12
- 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`
13
+ 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`、`scaling.json`、`redactor.json`
13
14
  ,该目录不提交。每个场景先核对非空输入的预期结果,再计时;每轮计时后再次核对最后一次执行的结果。输出包括
14
15
  Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输入规模、迭代次数、处理字节数、墙钟耗时、分配对象数和 GC 次数。
15
16
 
@@ -20,6 +21,7 @@ Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输
20
21
  | matching | 4 KiB、64 KiB、1 MiB;1、8、32 个正则;首个命中、末个命中、全未命中;UTF-8 前缀和可选捕获;1、8、32 个会话、多组重复来源和同时就绪的真实管道 |
21
22
  | relay | 无转义、字面转义、16 个正则转义;正常目标与每次只接受 17 字节的目标共同接收相同数据 |
22
23
  | send_slow | 真实本地 socket;无回显、持续回显;零延迟和每字符 1ms 延迟 |
24
+ | redactor | 空规则、无命中/稀疏/连续/高重叠;包含关系、多秘密、二进制、原文替换标记、1/7/4096 字节分块、partial finish、重复 finish 及 pending 中更新规则 |
23
25
 
24
26
  `matching` 的扫描用内部 `find_match` 单独度量缓冲扫描,排除 PTY 启动和回调开销;就绪场景包含管道写入、选择和读取。`relay`
25
27
  的前三项单独度量转义扫描,混合目标项运行完整转接循环。短写模拟目标吞吐受限,不等同于真实慢网络。`send_slow` 包含
@@ -34,6 +36,10 @@ socket、接收线程和完整回显校验的成本;无回显时计时止于
34
36
 
35
37
  每个样本记录计时区间前后的 RSS 和打开描述符数,以及进程的描述符上限;采样自身不进入计时或分配计数。RSS 是端点快照,受 Ruby 堆和分配器保留影响,不代表峰值或存活对象大小。平台不支持某项采样时记录 `null`。描述符快照也不是长期泄漏证明,应同时检查重复样本和关闭流程。
36
38
 
39
+ `redactor` 每次操作创建独立过滤器,分块数据在计时前准备,计时包含过滤器构造、append 和两次 finish。
40
+ 主要输入规模为 64 KiB,高重叠秘密长度为 1/32/1024/4096 字节;smoke 缩小为 512 字节及 1/32/128 字节秘密。
41
+ 公共 Runner 会加载完整 Expect(含 PTY);基准本身不启动 PTY 会话,独立 `require "expect/redactor"` 的无 PTY 契约由测试验证。
42
+
37
43
  ## 同环境对照
38
44
 
39
45
  对照目录必须包含已知提交的完整源码。两个版本使用同一套基准脚本、同一 Ruby 和依赖,在机器空闲时交替运行,比较原始样本的中位数与分配量:
@@ -72,6 +78,7 @@ bundle exec ruby benchmark/matching.rb --smoke
72
78
  bundle exec ruby benchmark/relay.rb --smoke
73
79
  bundle exec ruby benchmark/send_slow.rb --smoke
74
80
  bundle exec ruby benchmark/scaling.rb --smoke
81
+ bundle exec ruby benchmark/redactor.rb --smoke
75
82
  ```
76
83
 
77
84
  `script/ci` 运行这些小规模正确性检查,不设置墙钟性能阈值。热点优化必须有实际收益证据;减少对象分配不代表所有输入都会变快,也不能替代完整测试和安装验证。
@@ -104,3 +111,55 @@ bundle exec ruby benchmark/scaling.rb --samples 3 --iterations 10
104
111
  同一工作树的三次容量样本中,1、16、64 来源各执行十轮的耗时中位数分别为 0.179、1.372、8.285 ms;1,000 来源执行三轮为 401.878 ms。1,000 来源样本的描述符数均为 2,007 → 2,007,RSS 端点从约 35.8 MB 增至 37.9 MB,不能解释为稳定内存上限。阻塞目标场景十轮共 0.186 ms,其他来源仍可推进,描述符数均为 13 → 13。
105
112
 
106
113
  原始记录为 `tmp/core-improvements/scaling-final.json` 和 `scaling-1000-final.json`,库源码摘要与上述工作树匹配记录一致。当前证据支持保留既有 select 调度,先量化真实负载,再决定是否需要替换后端。
114
+
115
+ ## Redactor 重叠区间合并验证(2026-09-27)
116
+
117
+ 基线为 `v0.5.0` / `b7cd25e736acbfcb43e412ec4d233f9b2602cb03` 的源码归档。使用同一份
118
+ `benchmark/redactor.rb`、macOS arm64、Ruby 4.0.6、Bundler 4.0.17 和相同依赖,按“基线 → 候选”交替运行两轮,
119
+ 每轮 `--samples 5 --iterations 20`。下表是最后一轮五个样本的中位数,耗时和分配均为 **20 次操作总量**。
120
+ 每项输出在预热及各样本结束后校验,性能采样期间没有并行运行项目测试。
121
+
122
+ | 工作负载 | 基线 ms | 候选 ms | 基线分配对象 | 候选分配对象 |
123
+ | --- | ---: | ---: | ---: | ---: |
124
+ | 空规则 | 0.438 | 0.416 | 541 | 541 |
125
+ | 单秘密无命中 | 0.774 | 0.973 | 561 | 561 |
126
+ | 多秘密无命中 | 2.305 | 1.447 | 641 | 641 |
127
+ | 稀疏命中 | 1.145 | 1.175 | 581 | 581 |
128
+ | 连续短秘密 | 26.159 | 9.345 | 262,661 | 541 |
129
+ | 重叠 / 1 字节秘密 | 113.787 | 42.799 | 1,311,241 | 541 |
130
+ | 重叠 / 32 字节秘密 | 189.706 | 97.756 | 1,310,661 | 581 |
131
+ | 重叠 / 1024 字节秘密 | 642.376 | 429.316 | 1,290,821 | 581 |
132
+ | 重叠 / 4096 字节秘密 | 1698.130 | 1361.663 | 1,229,381 | 581 |
133
+ | 包含关系 | 2.881 | 2.915 | 26,201 | 26,201 |
134
+ | 二进制跨块重叠 | 7.967 | 8.299 | 101,221 | 101,221 |
135
+ | 原文包含替换标记 | 2.758 | 2.837 | 21,081 | 21,081 |
136
+ | 1 字节分块 | 34.612 | 34.151 | 589,161 | 589,161 |
137
+ | 7 字节分块 | 6.746 | 6.716 | 93,361 | 93,361 |
138
+ | 4096 字节分块 | 1.434 | 1.456 | 10,781 | 10,781 |
139
+ | partial finish | 0.128 | 0.129 | 2,141 | 2,141 |
140
+ | exact finish | 0.117 | 0.113 | 2,021 | 2,021 |
141
+ | pending 中更新规则 | 0.075 | 0.076 | 961 | 961 |
142
+
143
+ 两轮中连续短秘密耗时减少 63.6%–64.3%;重叠秘密按长度分别减少 62.4%–62.8%、48.5%–48.9%、
144
+ 33.1%–33.2%、19.7%–19.8%。重叠查找仍逐字节进行,只减少重复掩码分配/覆盖,没有跳过重叠命中或改变脱敏范围。
145
+
146
+ 短样本中的无命中结果波动明显,因此又通过本地 `tmp/guide-review/redactor-common.rb` 筛选同一驱动的常见负载,
147
+ 每轮改为 `--samples 5 --iterations 500`,再交替运行两轮。该包装器只跳过上述五项密集命中,不改变任何输入、过滤或校验逻辑。
148
+ 第二轮中单秘密无命中为 23.883 → 24.009 ms,多秘密为 56.148 → 56.081 ms,1 字节分块为 873.397 → 870.462 ms。
149
+ 存在小幅代价:稀疏命中两轮慢 2.3%/4.5%,4096 字节分块慢 2.2%/2.5%,规则更新慢 1.0%/6.0%;
150
+ 后者第二轮 500 次操作为 1.562 → 1.655 ms。其余常见负载约在 -2.8% 至 +3.6% 之间,分配量不变。
151
+ 保留此优化的取舍是以少量区间记录计算换取密集/重叠场景的显著分配下降,不声称所有负载都更快,也不设置 CI 墙钟阈值。
152
+
153
+ 复现全负载:
154
+
155
+ ```sh
156
+ bundle exec ruby benchmark/redactor.rb --library /path/to/v0.5.0/lib --samples 5 --iterations 20 --output tmp/benchmark/redactor-before.json
157
+ bundle exec ruby benchmark/redactor.rb --samples 5 --iterations 20 --output tmp/benchmark/redactor-after.json
158
+ ```
159
+
160
+ 原始样本:`tmp/guide-review/redactor-before-{4,5}.json`、`redactor-after-{4,5}.json`;
161
+ 延长采样:`redactor-common-before-{1,2}.json`、`redactor-common-after-{1,2}.json`。均在忽略目录,不随 Gem 分发。
162
+ 基线库 SHA-256 为 `d2a55eb1aa1d00658195f21f32adf37cf3ab8a24bb6a5bcf9564f6e9a6047b28`,
163
+ 性能采样时的候选库(版本号仍为 `0.5.0`)为 `753d403cf4801900cb53be5c34b04da0ffa313f25b4d2fb23a6d25ffce36e75a`,
164
+ 驱动文件为 `f99f6c8f1ec21d4d97c984dbfd51fd3e17b64fa7968095323e16d68d4a0d4cc0`。
165
+ 这些是合成字节过滤负载,不代表完整设备会话吞吐;RSS 端点不用于声称峰值内存降低。
data/docs/VERIFICATION.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 验证记录
2
2
 
3
+ ## 0.5.0 公共字节过滤器发布前复核(2026-09-27)
4
+
5
+ 基线为已发布的 `5a219f7`(0.4.0)。本轮公开 `Expect::Redactor`,新增 11 项回归,覆盖独立加载不引入 PTY、完整文本与流尾部策略、跨分片与重叠秘密、自定义替换标记、空规则、输入复制、无效更新原子性及安全摘要;现有会话日志与诊断继续复用该过滤器。
6
+
7
+ 从基线建立独立检出,仅带入本轮改动。macOS arm64、Ruby 4.0.6、Bundler 4.0.17 下执行 `ruby script/release.rb --dry-run`,330 项测试、1,894 条断言全部通过,无失败、错误或跳过;RuboCop 检查 59 个文件无违规。四组基准 smoke、Gem 构建、普通 RubyGems 与最小 Bundler 应用的隔离安装均通过,安装后实际调用公共完整文本和分块过滤接口。
8
+
9
+ 此处记录本地发布预演;远端 Linux/macOS 与 Ruby 3.2/3.3/3.4/4.0 矩阵、标签及下载包校验,以 0.5.0 发布提交对应的 GitHub Actions 与 Release 为准。
10
+
3
11
  ## 0.4.0 发布前复核(2026-09-27)
4
12
 
5
13
  发布前新增七项回归:总期限在文本回调后到达时,已知 EOF 先于剩余活跃来源的超时派发;覆盖日志路径前交付旧尾部,冲刷失败不能提前截断新文件;嵌套诊断等待保留外层匹配结果;日志回调轮换目标时正确交接所有权;诊断回调新建或再次写入另一方向时,尾部仍交给旧目标。各项均先复现失败,再验证修复,纯相对超时行为保持不变。
@@ -3,10 +3,21 @@
3
3
  class Expect
4
4
  # 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
5
5
  # 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
6
- # 上层负责传入已复制的非空二进制秘密,每个日志目标或诊断方向使用独立实例。
6
+ # 不依赖会话或 IO;每个日志目标或诊断方向使用独立实例。
7
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
+
8
14
  # pending 保存尚不能安全输出的原字节,hidden 的对应字节用 0/1 表示是否需要遮盖。
9
- def initialize(patterns)
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
10
21
  self.patterns = patterns
11
22
  @pending = "".b
12
23
  @hidden = "".b
@@ -15,21 +26,42 @@ class Expect
15
26
 
16
27
  # 更新后续匹配规则并保留已有尾部与掩码;不能追溯修改已经交付给日志目标的内容。
17
28
  def patterns=(patterns)
18
- @patterns = patterns
19
- @lookbehind = patterns.map(&:bytesize).max - 1
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
20
35
  end
21
36
 
22
37
  # 追加一个原始字节块,返回已经可以确定的安全前缀;新秘密可能跨越此前保留的尾部。
23
38
  def append(data)
24
- @pending << data
39
+ raise ArgumentError, "data must be a String" unless data.is_a?(String)
40
+
41
+ @pending << data.b
25
42
  @hidden << ("\0" * data.bytesize)
26
43
  mark_secrets
27
44
  release([@pending.bytesize - @lookbehind, 0].max)
28
45
  end
29
46
 
30
47
  # EOF、日志目标替换及关闭是流边界;尾部疑似秘密前缀也遮盖,不能因 flush 泄露片段。
31
- def finish
48
+ def finish(partial: true)
49
+ raise ArgumentError, "partial must be true or false" unless [true, false].include?(partial)
50
+
32
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
33
65
  @patterns.each do |pattern|
34
66
  [pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
35
67
  next unless @pending.end_with?(pattern.byteslice(0, length))
@@ -38,25 +70,31 @@ class Expect
38
70
  break
39
71
  end
40
72
  end
41
- output = release(@pending.bytesize)
42
- @masking = false
43
- output
44
73
  end
45
74
 
46
- private
47
-
48
- # 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
75
+ # 仍逐字节推进重叠命中,但同一模式的相交/相邻区间只写一次掩码。
76
+ # 只向旧掩码取并集,不能清除其他模式或旧规则已经隐藏的 pending 字节。
49
77
  def mark_secrets
50
78
  @patterns.each do |pattern|
51
- offset = -1
79
+ starting = @pending.index(pattern)
80
+ next unless starting
81
+
82
+ length = pattern.bytesize
83
+ ending = starting + length
84
+ offset = starting
52
85
  while (offset = @pending.index(pattern, offset + 1))
53
- @hidden[offset, pattern.bytesize] = "\1" * pattern.bytesize
86
+ if offset > ending
87
+ @hidden[starting, ending - starting] = "\1" * (ending - starting)
88
+ starting = offset
89
+ end
90
+ ending = offset + length
54
91
  end
92
+ @hidden[starting, ending - starting] = "\1" * (ending - starting)
55
93
  end
56
94
  end
57
95
 
58
96
  # 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
59
- # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次 [FILTERED]。
97
+ # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次替换标记。
60
98
  def release(length)
61
99
  output = "".b
62
100
  cursor = 0
@@ -64,7 +102,7 @@ class Expect
64
102
  hidden = @hidden.getbyte(cursor) == 1
65
103
  ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
66
104
  if hidden
67
- output << "[FILTERED]" unless @masking
105
+ output << @replacement unless @masking
68
106
  else
69
107
  output << @pending.byteslice(cursor, ending - cursor)
70
108
  end
@@ -76,6 +114,4 @@ class Expect
76
114
  output
77
115
  end
78
116
  end
79
-
80
- private_constant :Redactor
81
117
  end
@@ -5,25 +5,30 @@ require "shellwords"
5
5
  # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
6
  class Expect
7
7
  # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
- # 非终端返回空字符串;命令不存在或执行失败抛出 IOError,管道在所有退出路径关闭。
8
+ # 辅助进程与管道独立记账,不能覆盖主会话 PID;失败时保留原异常并有界回收。
9
9
  def stty(*modes)
10
10
  return "" unless tty?
11
11
 
12
12
  modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
13
13
  modes = ["-g"] if modes.empty?
14
14
  reader, sink = IO.pipe
15
- child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
15
+ resources = SessionResources.new(reader, writer: sink, own: true)
16
+ resources.pid = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
16
17
  sink.close
17
18
  output = reader.read
18
- _, status = Process.waitpid2(child)
19
+ status = wait_stty(resources.pid)
20
+ resources.pid = nil
19
21
  raise IOError, "stty failed: #{output.strip}" unless status.success?
20
22
 
21
23
  output.strip
22
24
  rescue Errno::ENOENT
25
+ failed = true
23
26
  raise IOError, "stty executable not found in PATH; install the system terminal utilities"
27
+ rescue Exception # rubocop:disable Lint/RescueException -- 中断同样先回收辅助进程,再原样传播;不读取调用者的旧 $!。
28
+ failed = true
29
+ raise
24
30
  ensure
25
- reader&.close unless reader&.closed?
26
- sink&.close unless sink&.closed?
31
+ cleanup_preserving_failure(failed) { cleanup_stty(resources, reader, sink) }
27
32
  end
28
33
 
29
34
  # 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
@@ -33,4 +38,65 @@ class Expect
33
38
  def winsize=(size)
34
39
  to_io.winsize = size
35
40
  end
41
+
42
+ private
43
+
44
+ # 正常路径阻塞取得真实状态,不额外轮询或固定等待;EINTR 不重新执行 stty。
45
+ def wait_stty(pid)
46
+ Process.waitpid2(pid).last
47
+ rescue Errno::EINTR
48
+ retry
49
+ end
50
+
51
+ # 即使账本初始化被中断,局部变量中的管道仍须关闭;单端失败不跳过其他清理。
52
+ # 无主异常时由调用方传播首个常规清理错误。
53
+ def cleanup_stty(resources, reader, sink)
54
+ failure = nil
55
+ [reader, sink].compact.each do |io|
56
+ io.close unless io.closed?
57
+ rescue IOError, SystemCallError => error
58
+ failure ||= error
59
+ end
60
+ begin
61
+ reap_stty(resources) if resources
62
+ rescue IOError, SystemCallError => error
63
+ failure ||= error
64
+ end
65
+ raise failure if failure
66
+ end
67
+
68
+ # 每阶段共享固定的 50ms 单调时钟预算:自然退出、TERM、KILL;中断不续期。
69
+ # 仍无法同步回收或系统调用失败时交给 detach 的后台 wait,不伪造退出状态。
70
+ def reap_stty(resources)
71
+ return unless resources.owner == Process.pid
72
+
73
+ [nil, "TERM", "KILL"].each do |signal|
74
+ deadline = Expect.monotonic + 0.05
75
+ loop do
76
+ begin
77
+ resources.reap
78
+ return resources.status unless resources.pid && resources.owner == Process.pid
79
+
80
+ if signal
81
+ begin
82
+ Process.kill(signal, resources.pid)
83
+ rescue Errno::ESRCH
84
+ # 退出可发生在非阻塞 wait 与信号之间,仍需继续 wait。
85
+ nil
86
+ end
87
+ signal = nil
88
+ end
89
+ rescue Errno::EINTR
90
+ # 重试沿用本阶段期限,连续 EINTR 也必须交还控制权。
91
+ nil
92
+ end
93
+ remaining = deadline - Expect.monotonic
94
+ break unless remaining.positive?
95
+
96
+ sleep [remaining, 0.005].min
97
+ end
98
+ end
99
+ ensure
100
+ Process.detach(resources.pid) if resources.pid && resources.owner == Process.pid
101
+ end
36
102
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
data/script/ci CHANGED
@@ -7,7 +7,7 @@ bundle exec rake
7
7
  bundle exec ruby examples/dialogue.rb
8
8
 
9
9
  # 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
10
- for benchmark in matching relay send_slow scaling; do
10
+ for benchmark in matching relay send_slow scaling redactor; do
11
11
  bundle exec ruby "benchmark/${benchmark}.rb" --smoke
12
12
  done
13
13
 
@@ -60,7 +60,11 @@ child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
60
60
  Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
61
61
  session.winsize = [30, 100]
62
62
  abort "io-console terminal size failed" unless session.winsize == [30, 100]
63
+ # BSD stty 设置模式会清空待读输出,先收取提示,使子进程停在等待输入的位置。
63
64
  abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
65
+ mode = session.stty
66
+ abort "installed stty query failed" if mode.empty?
67
+ session.stty(mode)
64
68
 
65
69
  session.puts("expect-ruby")
66
70
  abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
@@ -68,7 +72,11 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_st
68
72
  abort "installed gem child did not exit successfully" unless status&.success?
69
73
  end
70
74
 
71
- # 使用已安装包验证内部过滤器随包分发,且结构化诊断无需加载可选的 Logger。
75
+ # 使用已安装包验证公共过滤器随包分发,且结构化诊断无需加载可选的 Logger。
76
+ filter = Expect::Redactor.new(["secret"], replacement: "[REDACTED]")
77
+ filtered = filter.append("sec") + filter.append("ret!") + filter.finish
78
+ abort "installed public redactor failed" unless filtered == "[REDACTED]!"
79
+ abort "installed complete-text redactor failed" unless Expect::Redactor.redact("sec", ["secret"]) == "sec"
72
80
  reader, producer = IO.pipe
73
81
  events = []
74
82
  transcript = StringIO.new
@@ -0,0 +1,183 @@
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
+
107
+ def test_seeded_binary_chunks_match_a_naive_union_of_full_and_partial_spans
108
+ random = Random.new(5000)
109
+ 250.times do |index|
110
+ alphabet = index.even? ? [97, 98] : [0, 97, 98, 99, 128, 255]
111
+ input = Array.new(random.rand(0..120)) { alphabet.sample(random: random) }.pack("C*")
112
+ patterns = Array.new(random.rand(0..6)) do
113
+ Array.new(random.rand(1..8)) { alphabet.sample(random: random) }.pack("C*")
114
+ end
115
+ chunks = []
116
+ offset = 0
117
+ while offset < input.bytesize
118
+ chunks << input.byteslice(offset, random.rand(1..15))
119
+ offset += chunks.last.bytesize
120
+ end
121
+ [false, true].each do |partial|
122
+ expected = reference_redact(input, patterns, partial: partial)
123
+ [[input], input.bytes.map(&:chr), chunks].each_with_index do |partition, kind|
124
+ filter = Expect::Redactor.new(patterns)
125
+ actual = partition.map { |chunk| filter.append(chunk) }.join.b + filter.finish(partial: partial)
126
+ assert_equal expected, actual, "seed=5000 case=#{index} partition=#{kind} partial=#{partial}"
127
+ assert_empty filter.finish(partial: partial)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def test_long_overlaps_merge_across_chunks_without_repeating_markers
134
+ filter = Expect::Redactor.new(["a" * 1024, "a" * 32, "ab"])
135
+ output = +""
136
+ 256.times { output << filter.append("a" * 17) }
137
+ output << filter.append("b!") << filter.finish
138
+ assert_equal "[FILTERED]!", output
139
+ end
140
+
141
+ def test_rule_removal_keeps_hidden_pending_bytes_and_partial_finish_is_separate
142
+ filter = Expect::Redactor.new(%w[aaaa long-pattern])
143
+ assert_empty filter.append("aaaaa")
144
+ filter.patterns = ["abc"]
145
+ assert_equal "[FILTERED]", filter.append("ab")
146
+ assert_empty filter.finish # 新的疑似前缀紧邻已隐藏区域,不能重复输出替换标记。
147
+
148
+ filter = Expect::Redactor.new(%w[aaaa long-pattern])
149
+ assert_empty filter.append("aaaaa")
150
+ filter.patterns = []
151
+ assert_equal "[FILTERED]!", filter.append("!") + filter.finish
152
+ end
153
+
154
+ private
155
+
156
+ # 独立参考模型:逐偏移比较完整秘密,再合并布尔掩码;不复用生产代码的扫描/输出实现。
157
+ def reference_redact(input, patterns, partial:)
158
+ hidden = Array.new(input.bytesize, false)
159
+ patterns.each do |pattern|
160
+ input.bytesize.times do |offset|
161
+ next unless input.byteslice(offset, pattern.bytesize) == pattern
162
+
163
+ pattern.bytesize.times { |length| hidden[offset + length] = true }
164
+ end
165
+ next unless partial
166
+
167
+ (1...pattern.bytesize).each do |length|
168
+ next unless input.end_with?(pattern.byteslice(0, length))
169
+
170
+ ((input.bytesize - length)...input.bytesize).each { |offset| hidden[offset] = true }
171
+ end
172
+ end
173
+ output = +"".b
174
+ input.bytes.each_with_index do |byte, offset|
175
+ if hidden[offset]
176
+ output << "[FILTERED]" if offset.zero? || !hidden[offset - 1]
177
+ else
178
+ output << byte
179
+ end
180
+ end
181
+ output
182
+ end
183
+ end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TerminalCleanupTest < ExpectTest
6
+ def test_read_failures_preserve_identity_reap_helper_and_leave_main_session_usable
7
+ [IOError.new("read failed"), Interrupt.new("interrupted"), SystemExit.new(17)].each do |failure|
8
+ session = terminal_session
9
+ main_pid = session.pid
10
+ with_stty_pipe do |reader, sink, spawned|
11
+ reader.stub(:read, -> { raise failure }) do
12
+ assert_same failure, assert_raises(failure.class) { bounded { session.stty } }
13
+ end
14
+ assert reader.closed?
15
+ assert sink.closed?
16
+ assert_reaped spawned.fetch(:pid)
17
+ end
18
+ assert_main_session_usable session, main_pid
19
+ end
20
+ end
21
+
22
+ def test_read_error_wins_over_close_error_and_helper_is_still_reaped
23
+ session = terminal_session
24
+ failure = IOError.new("read failed")
25
+ attempts = 0
26
+ with_stty_pipe do |reader, sink, spawned|
27
+ reader.stub(:read, -> { raise failure }) do
28
+ reader.stub(:close, lambda {
29
+ attempts += 1
30
+ raise IOError, "close failed"
31
+ }) do
32
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
33
+ refute reader.closed?, "permanently failing close must not be reported as closed"
34
+ assert sink.closed?
35
+ assert_reaped spawned.fetch(:pid)
36
+ end
37
+ end
38
+ end
39
+ assert_equal 1, attempts
40
+ end
41
+
42
+ def test_cleanup_error_after_success_is_visible_even_inside_caller_rescue
43
+ session = terminal_session
44
+ failure = IOError.new("close failed")
45
+ begin
46
+ raise ArgumentError, "already handled"
47
+ rescue ArgumentError
48
+ with_stty_pipe do |reader, sink, spawned|
49
+ reader.stub(:close, -> { raise failure }) do
50
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
51
+ assert sink.closed?
52
+ assert_reaped spawned.fetch(:pid)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def test_spawn_failure_attempts_both_closes_and_keeps_missing_command_cause
59
+ session = terminal_session
60
+ reader, sink = IO.pipe
61
+ @ios.push(reader, sink)
62
+ failure = Errno::ENOENT.new("missing stty")
63
+ IO.stub(:pipe, [reader, sink]) do
64
+ Process.stub(:spawn, ->(*) { raise failure }) do
65
+ Process.stub(:kill, ->(*) { flunk "no helper was spawned" }) do
66
+ reader.stub(:close, -> { raise IOError, "reader close failed" }) do
67
+ error = assert_raises(IOError) { session.stty }
68
+ assert_match "stty executable not found", error.message
69
+ assert_same failure, error.cause
70
+ assert sink.closed?
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def test_externally_reaped_helper_is_not_signalled_and_does_not_change_main_status
78
+ session = terminal_session
79
+ main_pid = session.pid
80
+ failure = IOError.new("read failed after external wait")
81
+ with_stty_pipe do |reader, _sink, spawned|
82
+ reader.stub(:read, lambda {
83
+ bounded { Process.waitpid2(spawned.fetch(:pid)) }
84
+ raise failure
85
+ }) do
86
+ Process.stub(:kill, ->(*) { flunk "must not signal an externally reaped PID" }) do
87
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
88
+ end
89
+ end
90
+ assert_reaped spawned.fetch(:pid)
91
+ end
92
+ assert_main_session_usable session, main_pid
93
+ end
94
+
95
+ def test_uncooperative_helper_is_killed_and_reaped_after_read_failure
96
+ session = terminal_session
97
+ failure = IOError.new("read failed")
98
+ command = [RbConfig.ruby, "--disable-gems", "-e",
99
+ 'Signal.trap("TERM", "IGNORE"); STDOUT.sync = true; puts "ready"; sleep 60']
100
+ signals = []
101
+ kill = Process.method(:kill)
102
+ with_stty_pipe(command: command) do |reader, _sink, spawned|
103
+ read = reader.method(:gets)
104
+ reader.stub(:read, lambda {
105
+ assert_equal("ready\n", bounded { read.call })
106
+ raise failure
107
+ }) do
108
+ Process.stub(:kill, lambda { |signal, pid|
109
+ signals << [signal, pid]
110
+ kill.call(signal, pid)
111
+ }) do
112
+ assert_same failure, assert_raises(IOError) { bounded(2) { session.stty } }
113
+ end
114
+ end
115
+ assert_equal [["TERM", spawned.fetch(:pid)], ["KILL", spawned.fetch(:pid)]], signals
116
+ assert_reaped spawned.fetch(:pid)
117
+ end
118
+ end
119
+
120
+ def test_normal_wait_retries_eintr_without_signalling_or_sleeping
121
+ session = terminal_session
122
+ wait = Process.method(:waitpid2)
123
+ attempts = 0
124
+ with_stty_pipe do |_reader, _sink, spawned|
125
+ Process.stub(:waitpid2, lambda { |pid, *flags|
126
+ assert_equal spawned.fetch(:pid), pid
127
+ attempts += 1
128
+ raise Errno::EINTR if attempts == 1
129
+
130
+ wait.call(pid, *flags)
131
+ }) do
132
+ session.stub(:sleep, ->(*) { flunk "successful stty must not add a fixed delay" }) do
133
+ Process.stub(:kill, ->(*) { flunk "successful helper must not be signalled" }) do
134
+ refute_empty session.stty
135
+ end
136
+ end
137
+ end
138
+ assert_equal 2, attempts
139
+ assert_reaped spawned.fetch(:pid)
140
+ end
141
+ end
142
+
143
+ def test_continuous_cleanup_eintr_has_a_fixed_budget_and_detaches_real_helper
144
+ session = terminal_session
145
+ failure = IOError.new("read failed")
146
+ clock = 0.0
147
+ waiter = nil
148
+ detach = Process.method(:detach)
149
+ attempts = 0
150
+ with_stty_pipe do |reader, _sink, spawned|
151
+ reader.stub(:read, -> { raise failure }) do
152
+ Expect.stub(:monotonic, -> { clock }) do
153
+ session.stub(:sleep, ->(duration) { clock += duration }) do
154
+ Process.stub(:waitpid2, lambda { |pid, flags|
155
+ assert_equal spawned.fetch(:pid), pid
156
+ assert_equal Process::WNOHANG, flags
157
+ attempts += 1
158
+ raise Errno::EINTR
159
+ }) do
160
+ Process.stub(:kill, ->(*) { flunk "ownership has not been checked successfully" }) do
161
+ Process.stub(:detach, lambda { |pid|
162
+ assert_equal spawned.fetch(:pid), pid
163
+ waiter = detach.call(pid)
164
+ }) do
165
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ assert_operator attempts, :>, 3
173
+ assert_in_delta 0.15, clock, 0.000001
174
+ refute_nil waiter
175
+ assert_instance_of(Process::Status, bounded { waiter.value })
176
+ assert_reaped spawned.fetch(:pid)
177
+ end
178
+ end
179
+
180
+ def test_esrch_between_poll_and_signal_still_reaps_without_fabricating_status
181
+ session = terminal_session
182
+ main_pid = session.pid
183
+ failure = IOError.new("read failed")
184
+ clock = 0.0
185
+ signalled = false
186
+ wait = Process.method(:waitpid2)
187
+ with_stty_pipe do |reader, _sink, spawned|
188
+ reader.stub(:read, -> { raise failure }) do
189
+ Expect.stub(:monotonic, -> { clock }) do
190
+ session.stub(:sleep, ->(duration) { clock += duration }) do
191
+ Process.stub(:waitpid2, lambda { |pid, flags|
192
+ assert_equal Process::WNOHANG, flags
193
+ signalled ? wait.call(pid, flags) : nil
194
+ }) do
195
+ Process.stub(:kill, lambda { |signal, pid|
196
+ assert_equal "TERM", signal
197
+ assert_equal spawned.fetch(:pid), pid
198
+ bounded { wait.call(pid) }
199
+ signalled = true
200
+ raise Errno::ESRCH
201
+ }) do
202
+ Process.stub(:detach, ->(*) { flunk "ECHILD already released ownership" }) do
203
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ assert signalled
211
+ assert_reaped spawned.fetch(:pid)
212
+ end
213
+ assert_main_session_usable session, main_pid
214
+ end
215
+
216
+ def test_sink_close_failure_still_attempts_reader_close_and_reaps_helper
217
+ session = terminal_session
218
+ failure = IOError.new("sink close failed")
219
+ attempts = 0
220
+ with_stty_pipe do |reader, sink, spawned|
221
+ sink.stub(:close, lambda {
222
+ attempts += 1
223
+ raise failure
224
+ }) do
225
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
226
+ assert reader.closed?
227
+ refute sink.closed?
228
+ assert_reaped spawned.fetch(:pid)
229
+ end
230
+ end
231
+ assert_equal 2, attempts
232
+ end
233
+
234
+ def test_forked_copy_closes_local_pipes_without_reaping_or_signalling_parent_helper
235
+ session = terminal_session
236
+ current_pid = Process.pid
237
+ failure = IOError.new("unwind in forked copy")
238
+ with_stty_pipe do |reader, sink, _spawned|
239
+ Process.stub(:pid, -> { current_pid }) do
240
+ reader.stub(:read, lambda {
241
+ current_pid = -1 # 在资源登记后模拟 fork 子进程的身份,父进程仍拥有辅助 PID。
242
+ raise failure
243
+ }) do
244
+ Process.stub(:waitpid2, ->(*) { flunk "non-owner must not wait for the parent's helper" }) do
245
+ Process.stub(:kill, ->(*) { flunk "non-owner must not signal the parent's helper" }) do
246
+ Process.stub(:detach, ->(*) { flunk "non-owner must not detach the parent's helper" }) do
247
+ assert_same failure, assert_raises(IOError) { session.stty }
248
+ assert reader.closed?
249
+ assert sink.closed?
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ def test_ledger_initialization_failure_still_closes_both_pipes
259
+ session = terminal_session
260
+ [IOError.new("ledger failed"), Interrupt.new("interrupted registration")].each do |failure|
261
+ with_stty_pipe do |reader, sink, spawned|
262
+ Expect::SessionResources.stub(:new, ->(*) { raise failure }) do
263
+ assert_same failure, assert_raises(failure.class) { session.stty }
264
+ assert reader.closed?
265
+ assert sink.closed?
266
+ assert_empty spawned
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ def test_fork_during_cleanup_wait_stops_process_cleanup
273
+ session = terminal_session
274
+ current_pid = Process.pid
275
+ failure = IOError.new("read failed")
276
+ with_stty_pipe do |reader, sink, _spawned|
277
+ Process.stub(:pid, -> { current_pid }) do
278
+ reader.stub(:read, -> { raise failure }) do
279
+ session.stub(:sleep, ->(*) { current_pid = -1 }) do
280
+ Process.stub(:waitpid2, ->(*) {}) do
281
+ Process.stub(:kill, ->(*) { flunk "fork during wait must release inherited process ownership" }) do
282
+ Process.stub(:detach, ->(*) { flunk "non-owner must not detach" }) do
283
+ assert_same failure, assert_raises(IOError) { bounded { session.stty } }
284
+ assert reader.closed?
285
+ assert sink.closed?
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ private
296
+
297
+ def terminal_session
298
+ session = child(<<~'RUBY', raw_pty: true)
299
+ puts "ready"
300
+ while (line = STDIN.gets)
301
+ puts "reply:#{line.strip}"
302
+ end
303
+ RUBY
304
+ assert_equal 1, session.expect("ready", timeout: 2)
305
+ session
306
+ end
307
+
308
+ def assert_main_session_usable(session, main_pid)
309
+ assert_equal main_pid, session.pid
310
+ assert_nil session.process_status
311
+ refute session.closed?
312
+ session.puts "still usable"
313
+ assert_equal 1, session.expect("reply:still usable", timeout: 2)
314
+ end
315
+
316
+ def assert_reaped(pid)
317
+ assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) }
318
+ end
319
+
320
+ # 只替换本次 stty 的管道;即使断言失败,恢复全局 stub 后仍由探针回收自己的真实子进程。
321
+ def with_stty_pipe(command: nil)
322
+ reader, sink = IO.pipe
323
+ @ios.push(reader, sink)
324
+ spawned = {}
325
+ spawn = Process.method(:spawn)
326
+ IO.stub(:pipe, [reader, sink]) do
327
+ Process.stub(:spawn, lambda { |*arguments, **options|
328
+ spawned[:pid] = spawn.call(*(command || arguments), **options)
329
+ }) do
330
+ yield reader, sink, spawned
331
+ end
332
+ end
333
+ ensure
334
+ reap_probe_child(spawned[:pid]) if spawned && spawned[:pid]
335
+ end
336
+
337
+ def reap_probe_child(pid)
338
+ return if Process.waitpid(pid, Process::WNOHANG)
339
+
340
+ begin
341
+ Process.kill("KILL", pid)
342
+ rescue Errno::ESRCH
343
+ nil
344
+ end
345
+ bounded { Process.waitpid(pid) }
346
+ rescue Errno::ECHILD
347
+ nil
348
+ end
349
+ 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.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -111,6 +111,7 @@ files:
111
111
  - README.md
112
112
  - Rakefile
113
113
  - benchmark/matching.rb
114
+ - benchmark/redactor.rb
114
115
  - benchmark/relay.rb
115
116
  - benchmark/scaling.rb
116
117
  - benchmark/send_slow.rb
@@ -169,6 +170,7 @@ files:
169
170
  - test/multi_session_test.rb
170
171
  - test/pattern_offset_test.rb
171
172
  - test/process_test.rb
173
+ - test/redactor_test.rb
172
174
  - test/relay_recovery_test.rb
173
175
  - test/release_test.rb
174
176
  - test/ruby_api_test.rb
@@ -177,6 +179,7 @@ files:
177
179
  - test/support/interact_probe.rb
178
180
  - test/support/kibitz_probe.rb
179
181
  - test/support/script_probe.rb
182
+ - test/terminal_cleanup_test.rb
180
183
  - test/test_helper.rb
181
184
  - test/timeout_test.rb
182
185
  homepage: https://github.com/gatework/expect-ruby