expect-pty 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bffb0687dc259c0b1e2abc3920def8a496c67b0853dda43b0f7da98d0273b8b6
4
- data.tar.gz: bd56c190bd1bb312902e2679a03c91d620bff4498551402344a46f979df1d9dd
3
+ metadata.gz: 5f3f00d375bb8a90ef67974bbd7649b031554e15f9d463cf28f1388d5d3e5dc1
4
+ data.tar.gz: 9c9a556719b8c79c43553bc344fa679737abb2330f8ecdec72beabfedb3842d7
5
5
  SHA512:
6
- metadata.gz: e018796d8f8b2e9b2efdf515bce9ef4b57fd0e5d63e2be511e97231990b0525d98f9814c9aa23a480ae9e0956b28048e70de6187ebb535e91016f5bcb21cee61
7
- data.tar.gz: 8633ebbdbee3858cb7a0756160b15357fdbc7eaf3df46ce2d8ac76f0a57eb74c3b35271c05661fd07de1c9d2f011a1e4fc0f839e4279368a7ac15a70b3deaa9e
6
+ metadata.gz: 53fa1a25e62c9c16c2ecbafc3efa38ba4acd2c805afa6b1547e4b83cb95c98484bbd27f612a83397a9eb9a0715af857b7f9f4d698f52bdcaa4b7dcbd3a55a42e
7
+ data.tar.gz: 226b72621b685c9fce34097838e0c10a81f616b3ba26a62a90eb6f09c9e3a6e0a3cd5cac331ba79c9a81caf134707821622a3e1ebe85842aff01da79b1d1d554
data/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 - 2026-09-27
6
+
7
+ - **独立脱敏**:新增无需创建会话的 `Expect::Redactor`,支持完整文本和分块字节流过滤,以及自定义替换标记。
8
+ - **过滤规则**:支持空模式列表和复制校验后的规则更新,完整文本仅匹配完整秘密,流结束时默认隐藏未完成的秘密前缀,现有会话脱敏行为保持不变。
9
+
5
10
  ## 0.4.0 - 2026-09-27
6
11
 
7
12
  - **缓冲与生命周期**:新增只读 `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.0", 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.0.gem
33
+ gem install ./tmp/expect-pty-0.5.0.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
 
@@ -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
 
@@ -77,7 +77,11 @@ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子
77
77
 
78
78
  `logging.rb` 分开处理接收日志、诊断和协议转发。`diagnostic_output` 借用 Logger、可写对象或回调,不进入资源账本;默认沿用 stderr。回调接收冻结的事件 Hash 和 message 字符串,不含会话对象。诊断失败与其他同步 IO 错误同样保留已读取的原始输入。
79
79
 
80
- `redactor.rb` 是内部的字节流过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
80
+ `redactor.rb` 是无 IO 的公共字节过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
81
+
82
+ 公共构造器及 `patterns=` 会校验并复制秘密;空模式列表直接交付字节,更新规则不清除已经建立的掩码。
83
+ 默认替换标记为 `[FILTERED]`,上层库可显式指定自己的标记。类方法 `redact` 使用独立流并以 `finish(partial: false)` 结束完整文本;
84
+ `append`/`finish` 的默认流策略仍隐藏末尾疑似秘密前缀。作用域所有权、终端渲染和异常字段选择由调用方负责。
81
85
 
82
86
  EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭结束所有方向;尾部疑似秘密前缀保守隐藏。GC 只清理所属资源,不执行用户回调或过滤尾部输出。调用方需显式关闭以交付尾部;借用日志和诊断目标不关闭,库打开的日志文件仍由 SessionResources 管理。
83
87
 
@@ -99,6 +103,7 @@ EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭
99
103
  | 外部回收、借用 IO、关闭失败重试、原生错误分类 | lifecycle_contract_test |
100
104
  | 绝对期限、接收和回调重置、过期文本、EOF、EINTR、零轮询 | deadline_test |
101
105
  | Logger/IO/回调、分片与重叠秘密、二进制、流尾部、关闭失败、覆盖与重入 | diagnostics_test |
106
+ | 公共过滤器独立加载、完整文本与分块、空规则、规则更新、输入复制和安全摘要 | redactor_test |
102
107
  | 字面跨读取命中、声明优先级、缓存失效、操作序列与全量扫描对照 | literal_scan_test |
103
108
 
104
109
  ## 既有回归入口
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,13 +70,8 @@ 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
  # 每次只将命中区域标为隐藏,不清除旧掩码;偏移逐字节推进以识别相互重叠的秘密。
49
76
  def mark_secrets
50
77
  @patterns.each do |pattern|
@@ -56,7 +83,7 @@ class Expect
56
83
  end
57
84
 
58
85
  # 按连续区间输出,避免逐字节构造字符串;只保存尚可能与下一块组成秘密的后缀。
59
- # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次 [FILTERED]。
86
+ # masking 跨 append 保留,使被分成多个块的同一隐藏区间只输出一次替换标记。
60
87
  def release(length)
61
88
  output = "".b
62
89
  cursor = 0
@@ -64,7 +91,7 @@ class Expect
64
91
  hidden = @hidden.getbyte(cursor) == 1
65
92
  ending = [@hidden.index(hidden ? "\0" : "\1", cursor) || length, length].min
66
93
  if hidden
67
- output << "[FILTERED]" unless @masking
94
+ output << @replacement unless @masking
68
95
  else
69
96
  output << @pending.byteslice(cursor, ending - cursor)
70
97
  end
@@ -76,6 +103,4 @@ class Expect
76
103
  output
77
104
  end
78
105
  end
79
-
80
- private_constant :Redactor
81
106
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/script/ci CHANGED
@@ -68,7 +68,11 @@ Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_st
68
68
  abort "installed gem child did not exit successfully" unless status&.success?
69
69
  end
70
70
 
71
- # 使用已安装包验证内部过滤器随包分发,且结构化诊断无需加载可选的 Logger。
71
+ # 使用已安装包验证公共过滤器随包分发,且结构化诊断无需加载可选的 Logger。
72
+ filter = Expect::Redactor.new(["secret"], replacement: "[REDACTED]")
73
+ filtered = filter.append("sec") + filter.append("ret!") + filter.finish
74
+ abort "installed public redactor failed" unless filtered == "[REDACTED]!"
75
+ abort "installed complete-text redactor failed" unless Expect::Redactor.redact("sec", ["secret"]) == "sec"
72
76
  reader, producer = IO.pipe
73
77
  events = []
74
78
  transcript = StringIO.new
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require_relative "../lib/expect/redactor"
7
+
8
+ class RedactorTest < Minitest::Test
9
+ def test_standalone_require_does_not_load_pty_or_allocate_a_session
10
+ program = <<~RUBY
11
+ require "expect/redactor"
12
+ abort "PTY loaded" if defined?(PTY)
13
+ abort "unexpected result" unless Expect::Redactor.redact("secret!", ["secret"]) == "[FILTERED]!"
14
+ RUBY
15
+ output, status = Open3.capture2e(RbConfig.ruby, "--disable-gems", "-I", File.expand_path("../lib", __dir__),
16
+ "-e", program)
17
+ assert status.success?, output
18
+ end
19
+
20
+ def test_empty_patterns_pass_binary_input_without_buffering
21
+ filter = Expect::Redactor.new([])
22
+ assert_equal "\xff\0raw".b, filter.append("\xff\0raw".b)
23
+ assert_empty filter.finish
24
+ assert_equal "plain", Expect::Redactor.redact("plain", [])
25
+ end
26
+
27
+ def test_one_shot_redaction_does_not_treat_an_ordinary_suffix_as_a_secret_prefix
28
+ assert_equal "pass", Expect::Redactor.redact("pass", ["password"])
29
+ assert_equal "[REDACTED]!", Expect::Redactor.redact("password!", ["password"], replacement: "[REDACTED]")
30
+ end
31
+
32
+ def test_stream_finish_masks_partial_secrets_by_default
33
+ filter = Expect::Redactor.new(["password"])
34
+ assert_equal "prefix [FILTERED]", filter.append("prefix pass") + filter.finish
35
+ assert_empty filter.finish
36
+ assert_equal "[FILTERED]!", filter.append("password!") + filter.finish
37
+ end
38
+
39
+ def test_exact_finish_is_explicit_and_validated
40
+ filter = Expect::Redactor.new(["password"])
41
+ filter.append("pass")
42
+ assert_raises(ArgumentError) { filter.finish(partial: nil) }
43
+ assert_equal "pass", filter.finish(partial: false)
44
+ end
45
+
46
+ def test_custom_marker_and_overlapping_patterns_at_every_boundary
47
+ patterns = ["alpha[REDACTED]omega", "[REDACTED]", "REDACTED", "[", "abc", "cde"]
48
+ input = "alpha[REDACTED]omega | [REDACTED] | abcde!"
49
+ expected = "[REDACTED] | [REDACTED] | [REDACTED]!"
50
+ (0..input.bytesize).each do |split|
51
+ filter = Expect::Redactor.new(patterns, replacement: "[REDACTED]")
52
+ actual = filter.append(input.byteslice(0, split)) + filter.append(input.byteslice(split..)) + filter.finish
53
+ assert_equal expected, actual, "split #{split}"
54
+ end
55
+ filter = Expect::Redactor.new(patterns, replacement: "[REDACTED]")
56
+ actual = input.bytes.map { |byte| filter.append(byte.chr) }.join + filter.finish
57
+ assert_equal expected, actual
58
+ assert_equal expected, Expect::Redactor.redact(input, patterns, replacement: "[REDACTED]")
59
+ end
60
+
61
+ def test_patterns_and_replacement_are_copied
62
+ secret = +"secret"
63
+ patterns = [secret]
64
+ replacement = +"hidden"
65
+ filter = Expect::Redactor.new(patterns, replacement: replacement)
66
+ secret.replace("public")
67
+ patterns.clear
68
+ replacement.clear
69
+ assert_equal "hidden!", filter.append("secret!") + filter.finish
70
+ end
71
+
72
+ def test_invalid_pattern_update_is_atomic_and_preserves_pending_data
73
+ filter = Expect::Redactor.new(["secret"])
74
+ filter.append("sec")
75
+ [nil, "secret", [""], ["valid", 1]].each do |patterns|
76
+ assert_raises(ArgumentError) { filter.patterns = patterns }
77
+ end
78
+ assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
79
+ end
80
+
81
+ def test_pattern_update_keeps_hidden_spans_and_matches_new_secret_across_chunks
82
+ filter = Expect::Redactor.new(%w[abc long-pattern])
83
+ filter.append("abcsec")
84
+ filter.patterns = ["secret"]
85
+ assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
86
+ filter.patterns = []
87
+ assert_equal "secret", filter.append("secret")
88
+ assert_empty filter.finish
89
+ end
90
+
91
+ def test_invalid_input_and_replacement_do_not_mutate_stream
92
+ [nil, "", 3].each do |replacement|
93
+ assert_raises(ArgumentError) { Expect::Redactor.new([], replacement: replacement) }
94
+ end
95
+ filter = Expect::Redactor.new(["secret"])
96
+ filter.append("sec")
97
+ assert_raises(ArgumentError) { filter.append(nil) }
98
+ assert_equal "[FILTERED]!", filter.append("ret!") + filter.finish
99
+ end
100
+
101
+ def test_inspect_does_not_expose_registered_or_pending_bytes
102
+ filter = Expect::Redactor.new(["private-pattern"])
103
+ filter.append("raw-pending")
104
+ assert_equal "#<Expect::Redactor>", filter.inspect
105
+ end
106
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expect-pty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - expect-pty contributors
@@ -169,6 +169,7 @@ files:
169
169
  - test/multi_session_test.rb
170
170
  - test/pattern_offset_test.rb
171
171
  - test/process_test.rb
172
+ - test/redactor_test.rb
172
173
  - test/relay_recovery_test.rb
173
174
  - test/release_test.rb
174
175
  - test/ruby_api_test.rb