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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +20 -3
- data/benchmark/redactor.rb +64 -0
- data/docs/INTERNAL_CONTRACTS.md +21 -3
- data/docs/PERFORMANCE.md +60 -1
- data/docs/VERIFICATION.md +8 -0
- data/lib/expect/redactor.rb +54 -18
- data/lib/expect/terminal.rb +71 -5
- data/lib/expect/version.rb +1 -1
- data/script/ci +10 -2
- data/test/redactor_test.rb +183 -0
- data/test/terminal_cleanup_test.rb +349 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e860210612e392f800cf5839bb130625bafb17e47ffc86e49787446537c63a81
|
|
4
|
+
data.tar.gz: cbabed54f7dcd21c2bd5dc7ee2b63627a3ed424f94101df786812ba5e652ab00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
33
|
-
gem install ./tmp/expect-pty-0.
|
|
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
|
data/docs/INTERNAL_CONTRACTS.md
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
| `terminal.rb` | `stty` 和窗口尺寸的会话终端接口 |
|
|
16
16
|
| `interaction.rb`、`relay.rb`、`relay_writer.rb` | 人工接管、转义处理、共同调度和各目标发送游标 |
|
|
17
17
|
|
|
18
|
-
`redactor.rb`
|
|
18
|
+
`redactor.rb` 提供公共 `Expect::Redactor` 字节过滤接口,供会话日志、各方向诊断及上层库共用,不持有 IO,也不接管协议缓冲。
|
|
19
19
|
|
|
20
|
-
|
|
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`
|
|
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 先于剩余活跃来源的超时派发;覆盖日志路径前交付旧尾部,冲刷失败不能提前截断新文件;嵌套诊断等待保留外层匹配结果;日志回调轮换目标时正确交接所有权;诊断回调新建或再次写入另一方向时,尾部仍交给旧目标。各项均先复现失败,再验证修复,纯相对超时行为保持不变。
|
data/lib/expect/redactor.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
# 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
|
|
75
|
+
# 仍逐字节推进重叠命中,但同一模式的相交/相邻区间只写一次掩码。
|
|
76
|
+
# 只向旧掩码取并集,不能清除其他模式或旧规则已经隐藏的 pending 字节。
|
|
49
77
|
def mark_secrets
|
|
50
78
|
@patterns.each do |pattern|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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 <<
|
|
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
|
data/lib/expect/terminal.rb
CHANGED
|
@@ -5,25 +5,30 @@ require "shellwords"
|
|
|
5
5
|
# 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
|
|
6
6
|
class Expect
|
|
7
7
|
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/expect/version.rb
CHANGED
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
|
-
#
|
|
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
|
+
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
|