net-connector 0.10.2 → 0.11.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/docs/architecture.md +2 -0
  4. data/docs/netdisco.md +4 -0
  5. data/docs/netdisco.zh-CN.md +4 -0
  6. data/docs/usage.md +2 -0
  7. data/docs/usage.zh-CN.md +2 -0
  8. data/lib/net/connector/background_task.rb +1 -1
  9. data/lib/net/connector/device/base.rb +1 -1
  10. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  11. data/lib/net/connector/device/running_config.rb +1 -1
  12. data/lib/net/connector/device/tftp/strategy.rb +2 -2
  13. data/lib/net/connector/device/topology/immediate_strategy.rb +1 -1
  14. data/lib/net/connector/device_session.rb +1 -1
  15. data/lib/net/connector/engine/command.rb +2 -2
  16. data/lib/net/connector/engine/dialogue.rb +24 -6
  17. data/lib/net/connector/engine/known_hosts.rb +4 -4
  18. data/lib/net/connector/engine/log/stream.rb +1 -1
  19. data/lib/net/connector/engine/log.rb +2 -2
  20. data/lib/net/connector/engine/response_reader.rb +7 -7
  21. data/lib/net/connector/engine/result.rb +16 -1
  22. data/lib/net/connector/engine/session.rb +60 -31
  23. data/lib/net/connector/engine/terminal_renderer.rb +3 -2
  24. data/lib/net/connector/engine/transport.rb +11 -4
  25. data/lib/net/connector/execution_pool.rb +7 -2
  26. data/lib/net/connector/netdisco/backup_run.rb +1 -1
  27. data/lib/net/connector/netdisco/cli/options.rb +1 -1
  28. data/lib/net/connector/netdisco/connection.rb +1 -1
  29. data/lib/net/connector/netdisco/progress.rb +1 -1
  30. data/lib/net/connector/netdisco/rules.rb +17 -7
  31. data/lib/net/connector/netdisco/settings.rb +32 -8
  32. data/lib/net/connector/vendor/h3c/topology.rb +1 -1
  33. data/lib/net/connector/vendor/hillstone/topology.rb +1 -1
  34. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  35. data/lib/net/connector/vendor/palo_alto/topology.rb +1 -1
  36. data/lib/net/connector/version.rb +1 -1
  37. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f994834e561576d4fafde11f003653a865dd4d35da5a5fd8a7a4c2a016cc2d5
4
- data.tar.gz: f7585fe57b094fd687b2de70e34b6b41eb0e46c220d5fc9bfe4357a25c31f6f9
3
+ metadata.gz: 3ff14cb75482326d5580fefe4341ab39d727c1bc0d35daa90ebf34f534dbf1d1
4
+ data.tar.gz: eb6da5eca7eed4764f9dfa12e67708625d32cb597f206a2af1711047bcd1582c
5
5
  SHA512:
6
- metadata.gz: c862e76e800b3ef6174e8aeadaedd4724dafbd08858954874e6955c0c71a4f21c1f9fd124dfe60e59e5389380330193740cbed6e2c0369ee6f9eaddd0b0d56ce
7
- data.tar.gz: 6d62272a65c854987a1d9d73e1cb6aeba177743df828e37ec20595b31e9c42ada27dd787989a9cf1ecc17a3ae7fa1fe46f3c6db992e736ffcb2337c42b081feb
6
+ metadata.gz: 57c9e407fab11396ce830e35a54eddf0f5266864e76840407c7df691fa0ddf4e3f894c0e708a62f79c4e8ee2ba2236c04d3c503206e64d4d0892a1d17b74eb16
7
+ data.tar.gz: bfeb19f27232509e79a96b1156342365ece7ecec96a66bd56207da48dcaf3db334940aab8d8966fb0028403aa50762b7d989da3618b892b952aa49bc43293825
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.11.0 - 2026-10-17
4
+
5
+ - 固定协议行分隔符,不再依赖宿主的 `$/`:并发 SSH 主机密钥冲突仍会拒绝,脚本、PAN-OS 配置、TFTP/保存证据及日志按设备行解析;标准输入凭据和隐藏密码只消费当前一行。
6
+ - 传输事件在构造时保存不可变的二进制快照,自定义传输复用缓冲区不再覆盖已接收分片或超时证据;PTY 将字节规范化集中到事件边界,避免重复转换。自定义传输应为每次读取创建新事件,不能再修改已构造事件的字段。
7
+ - 结果汇总在拼接前统一为原始字节,混合 UTF-8、二进制及其他编码的已完成步骤不再触发编码冲突;单条结果省去中间数组与拼接,返回值仍是独立、可修改的二进制字符串。
8
+ - 命令回执在构造时校验命令、输出、提示及有限非负耗时,避免非法对象进入已完成步骤后才在汇总或诊断时失败。
9
+ - 自定义对话提前校验错误匹配与交互列表,复制并冻结自身规则,不再冻结调用方数组;无效规则在会话开始前报告。
10
+ - 字节、诊断、SSH 信任文件及终端进度拼接使用显式空分隔符,避免宿主设置 Ruby 的 `$,` 后将额外字符插入响应、错误证据或文件。
11
+
12
+ ## 0.10.3 - 2026-10-16
13
+
14
+ - 保护会话锁取得、登记和释放的异步取消边界,完整操作、单次执行和关闭共用持锁作用域;业务等待仍可取消,非局部退出仍释放锁。
15
+ - 工作线程提前退出或被终止时,线程池报告未完成任务并回收其余线程,避免返回貌似成功的空结果;跨作用域重新交付异常保留原始原因链,不附加调用方已处理的无关异常。
16
+ - 修复 UTF-8 命令诊断混用字符与字节偏移导致截断或异常的问题,保留二进制输出及终端重绘前的错误证据。
17
+ - 固定策略快照的相对路径基准,后续目录变化和策略覆盖不再改变同批目录与信任文件;活设置及设备凭据仍动态读取。
18
+ - 拒绝规范化后指向不同连接器的地址或厂商覆盖,以及规范化为空的必需厂商标签;IP 地址校验异常不再携带原始输入。
19
+ - 终端逐行输出改用 Ruby 原生行尾裁剪,减少正则扫描和临时对象,保留控制序列、UTF-8 校验及行长度限额。
20
+
3
21
  ## 0.10.2 - 2026-10-16
4
22
 
5
23
  - 修复空清单缓存文件阻断查询的问题,空文件按缓存未命中处理,远端查询失败仍正常报告。
data/docs/architecture.md CHANGED
@@ -139,6 +139,8 @@ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态
139
139
 
140
140
  ## 迁移与尚未启用的能力
141
141
 
142
+ 自定义字节传输每次 `read` 返回新的 `Net::Connector::Event`。事件及其 `before`、`match` 字节串不可变;构造时复制可变输入并统一为二进制,调用方可继续复用源缓冲区。已有事件不能再通过 Struct 的字段赋值器修改,错误对象的身份保留。设备协议、脚本、SSH 信任文件和标准输入凭据使用显式换行符,不继承宿主的 `$/` 或 `$,` 设置。
143
+
142
144
  | 变化 | 调用方需要保留的约定 |
143
145
  | --- | --- |
144
146
  | 配置采集默认输出敏感 | 所有日志模式及错误正文隐藏配置;Result.config/steps 和备份仍保留原数据。自定义敏感业务需声明 output_sensitive,不以注册登录密码代替正文保护 |
data/docs/netdisco.md CHANGED
@@ -181,8 +181,12 @@ HTTP counts actual chunks and closes on excess. Injected `requester:` callbacks
181
181
 
182
182
  `Settings#snapshot(mode:)` freezes public policy before side effects. CLI planning and execution share it; later calls can read new settings. Credentials remain dynamic per task. `Settings.new.for_run(mode: :backup)` shares policy across API calls; non-sensitive overrides preserve an existing snapshot. A custom `credentials:` resolver can supply per-device connection options, taking precedence over defaults. Injected clients remain caller-owned.
183
183
 
184
+ Snapshot backup, cache, log, TFTP verification, SSH known-hosts and explicit PostgreSQL CA certificate paths resolve against the working directory captured when the snapshot is created, including default paths and later relative overrides. Paths beginning with `~/` and the path `~` use the home directory captured by that snapshot. PostgreSQL CA paths retain libpq's literal path semantics without tilde expansion; `sslrootcert: system` keeps its system trust-store meaning, while `./system` names a file. Live settings continue to resolve paths against the current working directory and home directory.
185
+
184
186
  Explicit host mappings, device rules, and vendor overrides take precedence over automatic detection. Comware plus a branded H3C model can correct stale vendor labels; H3C WX/AC models select the wireless connector.
185
187
 
188
+ Host and vendor overrides reject normalized aliases that select different connectors; aliases selecting the same connector can coexist. Required vendor labels must remain nonempty after normalization. Invalid rules fail before device connections begin.
189
+
186
190
  ## TFTP history and verification
187
191
 
188
192
  Direct upload receipts only establish `device_reported`. Fleet can verify a local/mounted TFTP directory via `verification_root:`; the CLI accepts `--tftp-root DIR` or YAML `tftp.verification_root`. A verified receipt requires a current, nonempty regular file matching the actual receipt path, with SHA-256 evidence. Missing evidence prevents `verified` success. A mismatched receipt path is retained as device evidence, but cannot be verified or archived without a baseline for that locked target.
@@ -179,6 +179,10 @@ ssh:
179
179
 
180
180
  每台任务开始时仍读取设备凭据,`Settings.from_file` 也保留轮换能力。纯策略快照不保存密码、API key 或凭据解析器。需要让多次 API 调用共用策略时,可传 `Settings.new.for_run(mode: :backup)`。自定义 `credentials:` 解析器仍可逐设备返回连接设置,它显式给出的选项优先于批次默认值,由调用方负责一致性;注入的 `client:` 生命周期也由调用方管理。传入 `plan:` 的执行不会重新拉取或筛选已批准清单。`fleet.devices` 可在不连接设备时检查映射。
181
181
 
182
+ 快照中的备份、缓存、日志、TFTP 核验目录、SSH known-hosts 路径及显式 PostgreSQL CA 证书路径,以创建快照时的工作目录为基准,默认路径和后续相对路径覆盖也沿用这一基准。`~/` 开头的路径及 `~` 使用快照创建时的主目录。PostgreSQL CA 路径保留 libpq 的字面路径语义,不展开波浪号;`sslrootcert: system` 仍表示系统信任库,`./system` 则表示文件。未固定的活设置仍按调用时的当前工作目录和主目录解析路径。
183
+
184
+ 地址和厂商覆盖规则规范化后,同一键不能指向不同连接器;指向同一连接器的别名可以并存。必需的厂商标签规范化后也不能为空。非法规则在连接设备前拒绝。
185
+
182
186
  ## 批量示例与进度
183
187
 
184
188
  `examples/backup.rb` 和 `examples/backup_tftp.rb` 默认全量备份符合筛选条件的设备,并在终端原地刷新两行进度(登录、采集、排队、成功和未完成数,以及耗时、平均吞吐、预计剩余时间);失败单独输出,结束后显示汇总和报告路径。重定向时每 25 台输出一次进度,避免刷屏。`--verbose` 显示逐条登录和命令事件,`--json` 才在 STDOUT 输出 JSON;完整计划和结果始终保存在批次目录。`--no-progress` 关闭人类进度。百分比表示任务完成比例,不是成功率。
data/docs/usage.md CHANGED
@@ -122,6 +122,8 @@ warn "#{result.error.code}: #{result.steps.size} completed commands" if result.f
122
122
 
123
123
  `execute_command` runs one command; `execute_script` accepts a `Script` or command array. `Script.load(path)` reads a file. Commands are validated before I/O; completed steps survive later failures. `save_config` explicitly sends save commands; ordinary scripts do not save automatically.
124
124
 
125
+ `result.output` concatenates completed steps as raw bytes without transcoding. Each call returns a mutable binary string; changing it does not change the frozen step outputs. Use `result.config` for the cleaned configuration, or `result.value!` to raise on failure before reading the successful value.
126
+
125
127
  Set `max_script_output_bytes:` to bound cumulative raw responses, including additional queries. Default is `nil`. Exceeding it returns `ScriptOutputLimitExceeded` (`:script_output_limit_exceeded`) and stops subsequent commands; the current command may already have executed. `max_output_bytes` separately limits each response. Neither setting is a process-memory limit.
126
128
 
127
129
  Per-command prompts use `interactions: [Net::Connector::Interaction.new(/Token:\z/, ->(_) { "value\n" }, sensitive: true)]`. Mark commands containing secrets `sensitive: true`. For safe command text with sensitive output, use `output_sensitive: true`; `running_config` enables this automatically.
data/docs/usage.zh-CN.md CHANGED
@@ -169,6 +169,8 @@ end
169
169
 
170
170
  `execute_command` 执行一条命令;`execute_script` 接收 `Script` 或命令数组;`Script.load(path)` 读取脚本文件。所有命令在设备 I/O 前校验,后续步骤失败时仍保留已完成结果,库不会自动重放命令。`save_config` 显式执行厂商保存命令,普通脚本不会自动保存。
171
171
 
172
+ `result.output` 按顺序拼接已完成步骤的原始字节,不执行字符编码转换。每次调用返回可修改的二进制字符串,修改它不会改变已冻结的步骤输出。清理后的配置使用 `result.config`;需要失败时先抛出错误,再读取成功值时使用 `result.value!`。
173
+
172
174
  创建连接器时可设置 `max_script_output_bytes: 8 * 1024 * 1024`,限制每个脚本及其追加查询累计收到的原始响应字节;默认 `nil` 保持原有完整输出行为。达到上限后不再发送下一命令,当前响应超过上限时保留刚完成的步骤并返回 `ScriptOutputLimitExceeded`(`code: :script_output_limit_exceeded`)。命令可能已经执行,不会自动重试。原有 `max_output_bytes` 继续限制单次读取响应;累计预算不是进程内存上限,详细计数规则见架构文档。
173
175
 
174
176
  厂商档案处理分页和常见确认提示。特定命令可给 `execute_command` 传入 `interactions: [Net::Connector::Interaction.new(/Token:\z/, ->(_) { "value\n" }, sensitive: true)]`。敏感命令及交互会暂停回显日志并在错误中脱敏;若普通命令文本包含秘密,必须显式标记 `sensitive: true`。
@@ -32,7 +32,7 @@ module Net
32
32
  end
33
33
  # break 和非局部 return 也会展开作用域;异常交付必须在 ensure 内,不能绕过。
34
34
  failure ||= completion.pop unless completion.empty?
35
- raise failure if failure
35
+ raise failure, cause: failure.cause if failure
36
36
  end
37
37
  value
38
38
  end
@@ -67,7 +67,7 @@ module Net
67
67
  device.connect
68
68
  yield device
69
69
  end
70
- raise failure if failure
70
+ raise failure, cause: failure.cause if failure
71
71
 
72
72
  value
73
73
  end
@@ -18,7 +18,7 @@ module Net
18
18
 
19
19
  # 普通采集命令保持视图不变;需要切换视图的采集策略显式调整。
20
20
  def prompt_text(_command)
21
- TerminalRenderer.render(@device.current_prompt.to_s).lines.last.to_s.strip
21
+ TerminalRenderer.render(@device.current_prompt.to_s).lines("\n").last.to_s.strip
22
22
  end
23
23
 
24
24
  # 公共策略不增加逐命令响应检查。
@@ -104,7 +104,7 @@ module Net
104
104
  def config_body?(step)
105
105
  body = TerminalRenderer.render(step.output.delete_suffix(step.prompt.to_s)).strip
106
106
  # 只需确认首个正文行;不为大配置创建全部行对象或重新拼接整份响应。
107
- body.each_line.with_index.any? do |line, index|
107
+ body.each_line("\n").with_index.any? do |line, index|
108
108
  text = line.strip
109
109
  !text.empty? && !(index.zero? && text == step.command.text)
110
110
  end
@@ -59,10 +59,10 @@ module Net
59
59
  # 移除精确命令、静态响应和提示符,保留回车重绘前后的设备消息。
60
60
  def evidence_lines(step, output)
61
61
  prompt = TerminalRenderer.render(step.prompt.to_s).strip
62
- responses = step.command.interactions.filter_map { |reply| reply.static_response&.chomp }
62
+ responses = step.command.interactions.filter_map { |reply| reply.static_response&.chomp("\n") }
63
63
  echoes = [step.command.text, "#{prompt}#{step.command.text}", *responses]
64
64
  # 原文中的每次回车分开核对,避免重绘回显误报,也保留被覆盖的诊断。
65
- output.each_line.flat_map { |line| line.split(/\r(?!\n)/) }.map do |line|
65
+ output.each_line("\n").flat_map { |line| line.split(/\r(?!\n)/) }.map do |line|
66
66
  # 样式不改变字符历史;光标移动和擦除不能拿来删掉原始失败消息。
67
67
  line.gsub(/\e\[[0-9;:]*m/, "").strip
68
68
  end.reject { |line| line.empty? || line == prompt || echoes.include?(line) }
@@ -29,7 +29,7 @@ module Net
29
29
  failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
30
30
  next false if history.match?(failure) || TerminalRenderer.render(history).match?(failure) || rendered.match?(failure)
31
31
 
32
- rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
32
+ rendered.lines("\n").any? { |line| line.strip.match?(persistence_pattern) }
33
33
  end
34
34
  end
35
35
 
@@ -33,7 +33,7 @@ module Net
33
33
  failure ||= error
34
34
  end
35
35
  # break 和非局部 return 跳过尾部返回值;收尾故障必须在展开作用域时交付。
36
- raise failure if failure && (!completed || !failure.is_a?(StandardError))
36
+ raise failure, cause: failure.cause if failure && (!completed || !failure.is_a?(StandardError))
37
37
  end
38
38
 
39
39
  [value, failure]
@@ -70,8 +70,8 @@ module Net
70
70
  def self.parse(text, name: nil)
71
71
  raise ArgumentError, "script must be a String" unless text.is_a?(String)
72
72
 
73
- commands = text.each_line.with_index(1).filter_map do |line, line_number|
74
- line = line.chomp
73
+ commands = text.each_line("\n").with_index(1).filter_map do |line, line_number|
74
+ line = line.chomp("\n")
75
75
  next if line.strip.empty? || line.lstrip.start_with?("#")
76
76
 
77
77
  Command.new(line, source: name, line: line_number)
@@ -70,10 +70,10 @@ module Net
70
70
  [login_prompt, command_prompt, password_prompt, username_prompt, enable_prompt].each do |pattern|
71
71
  raise ArgumentError, "prompt must be a nonempty Regexp" unless pattern.is_a?(Regexp) && !pattern.match?("")
72
72
  end
73
- @authentication_errors = authentication_errors.freeze
74
- @command_errors = command_errors.freeze
75
- @login_interactions = login_interactions.freeze
76
- @command_interactions = command_interactions.freeze
73
+ @authentication_errors = patterns(authentication_errors, :authentication_errors)
74
+ @command_errors = patterns(command_errors, :command_errors)
75
+ @login_interactions = interactions(login_interactions, :login_interactions)
76
+ @command_interactions = interactions(command_interactions, :command_interactions)
77
77
  freeze
78
78
  end
79
79
 
@@ -88,6 +88,24 @@ module Net
88
88
 
89
89
  private
90
90
 
91
+ # 自定义对话遵循与 Profile 相同的非空匹配规则,不冻结调用方数组。
92
+ def patterns(values, name)
93
+ unless values.is_a?(Array) && values.all? { |value| value.is_a?(Regexp) && !value.match?("") }
94
+ raise ArgumentError, "#{name} must be an Array of nonempty Regexp objects"
95
+ end
96
+
97
+ values.map { |value| value.frozen? ? value : value.dup.freeze }.freeze
98
+ end
99
+
100
+ # 交互对象自身不可变,只复制所属列表。
101
+ def interactions(values, name)
102
+ unless values.is_a?(Array) && values.all?(Interaction)
103
+ raise ArgumentError, "#{name} must be an Array of Interaction objects"
104
+ end
105
+
106
+ values.dup.freeze
107
+ end
108
+
91
109
  # 只截取命中错误模式的当前行,避免把整段设备配置写进诊断信息。
92
110
  def diagnostic_line(output)
93
111
  match = command_errors.lazy.filter_map { |pattern| pattern.match(output) }.first
@@ -95,8 +113,8 @@ module Net
95
113
 
96
114
  start = match.begin(0).positive? ? output.rindex(/[\r\n]/, match.begin(0) - 1) : nil
97
115
  start = start ? start + 1 : 0
98
- finish = output.index(/[\r\n]/, match.end(0)) || output.bytesize
99
- output.byteslice(start, finish - start).strip
116
+ finish = output.index(/[\r\n]/, match.end(0)) || output.length
117
+ output[start...finish].strip
100
118
  end
101
119
  end
102
120
 
@@ -31,7 +31,7 @@ module Net
31
31
  end
32
32
 
33
33
  def commit
34
- additions = File.binread(path).lines - @original.lines
34
+ additions = File.binread(path).lines("\n") - @original.lines("\n")
35
35
  return if additions.empty?
36
36
 
37
37
  with_lock do
@@ -42,10 +42,10 @@ module Net
42
42
  reject_changed_key!(file.path) unless @replace
43
43
  remove_host(file.path) if @replace
44
44
  contents = File.binread(file.path)
45
- entries = contents.lines
45
+ entries = contents.lines("\n")
46
46
  additions.each { |line| entries << line unless entries.include?(line) }
47
47
  File.open(file.path, "wb", 0o600) do |replacement|
48
- replacement.write(entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join)
48
+ replacement.write(entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join(""))
49
49
  replacement.flush
50
50
  replacement.fsync
51
51
  end
@@ -130,7 +130,7 @@ module Net
130
130
  output, status = Open3.capture2e("ssh-keygen", "-f", path, "-F", @host)
131
131
  raise IOError, "reading device host key failed" unless [0, 1].include?(status.exitstatus)
132
132
 
133
- output.lines.reject { |line| line.start_with?("#") }.map { |line| line.split[1, 2] }.uniq
133
+ output.lines("\n").reject { |line| line.start_with?("#") }.map { |line| line.split[1, 2] }.uniq
134
134
  end
135
135
 
136
136
  def remove_host(path)
@@ -37,7 +37,7 @@ module Net
37
37
  def write(bytes)
38
38
  @buffer << bytes
39
39
  while (ending = @buffer.index("\n"))
40
- @on_line.call(@buffer.slice!(0, ending + 1).chomp)
40
+ @on_line.call(@buffer.slice!(0, ending + 1).chomp("\n"))
41
41
  end
42
42
  bytes.bytesize
43
43
  end
@@ -138,8 +138,8 @@ module Net
138
138
  def log_response_output(bytes)
139
139
  return unless debug? && !@writer && !@redactor.output_sensitive?
140
140
 
141
- @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
142
- write_event("device_output", level: :debug, output: line.chomp)
141
+ @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line("\n") do |line|
142
+ write_event("device_output", level: :debug, output: line.chomp("\n"))
143
143
  end
144
144
  rescue Error
145
145
  raise
@@ -42,7 +42,7 @@ module Net
42
42
  end
43
43
  next if Expect.monotonic < deadline
44
44
 
45
- raise build_read_error(nil, phase, command, raw.join), cause: nil
45
+ raise build_read_error(nil, phase, command, raw.join("")), cause: nil
46
46
  end
47
47
  end
48
48
 
@@ -63,22 +63,22 @@ module Net
63
63
  def validate_event!(event, bytes, raw, phase, command)
64
64
  if bytes > @session.configuration.max_output_bytes
65
65
  raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
66
- phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
66
+ phase: phase, command: command, output: (raw + [event.match]).join("")), cause: nil
67
67
  end
68
- raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
68
+ raise build_read_error(event, phase, command, raw.join("")), cause: nil unless event.matched?
69
69
  end
70
70
 
71
71
  # 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
72
72
  def finish_response(prompt, raw, output, phase, command)
73
73
  if prompt.empty?
74
74
  raise @session.build_error(PromptError, "prompt pattern did not consume any output",
75
- phase: phase, command: command, output: raw.join), cause: nil
75
+ phase: phase, command: command, output: raw.join("")), cause: nil
76
76
  end
77
77
 
78
78
  raw << prompt
79
79
  output << prompt
80
- raw_text = raw.join
81
- Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
80
+ raw_text = raw.join("")
81
+ Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join(""), prompt: prompt)
82
82
  end
83
83
 
84
84
  # 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
@@ -91,7 +91,7 @@ module Net
91
91
  value = interaction_response(reply, prompt, phase, command)
92
92
  raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
93
93
 
94
- @session.redactor.remember(value.chomp) if reply.sensitive?
94
+ @session.redactor.remember(value.chomp("\n")) if reply.sensitive?
95
95
  @session.write(value, deadline: deadline, phase: phase, command: command)
96
96
  end
97
97
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "command"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -10,6 +11,13 @@ module Net
10
11
 
11
12
  # 保存命令输出、提示符和耗时,并冻结结果。
12
13
  def initialize(command:, output:, prompt:, duration:)
14
+ raise ArgumentError, "command must be a Command" unless command.is_a?(Command)
15
+ raise ArgumentError, "output must be a String" unless output.is_a?(String)
16
+ raise ArgumentError, "prompt must be a String or nil" unless prompt.nil? || prompt.is_a?(String)
17
+ unless duration.is_a?(Numeric) && duration.real? && duration.finite? && duration >= 0
18
+ raise ArgumentError, "duration must be a finite nonnegative real number"
19
+ end
20
+
13
21
  @command = command
14
22
  @output = output.frozen? ? output : output.dup.freeze
15
23
  @prompt = prompt&.dup&.freeze
@@ -51,7 +59,14 @@ module Net
51
59
  def each(&) = steps.each(&)
52
60
 
53
61
  # 拼接所有已完成步骤的输出字节。
54
- def output = steps.map(&:output).join.b
62
+ def output
63
+ return steps.first.output.b if steps.size == 1
64
+
65
+ # 拼接前统一编码;仅在拼接后调用 b 无法避免不同编码之间的冲突。
66
+ steps.map do |step|
67
+ step.output.encoding == Encoding::BINARY ? step.output : step.output.b
68
+ end.join("").force_encoding(Encoding::BINARY)
69
+ end
55
70
 
56
71
  # 成功时返回配置或输出,失败时重新抛出领域错误。
57
72
  def value!
@@ -67,25 +67,13 @@ module Net
67
67
  if @operation_owner == [Thread.current, Fiber.current] && !@performing
68
68
  return perform_locked(phase, &)
69
69
  end
70
- unless @lock.try_lock
71
- raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
72
- end
73
-
74
- begin
75
- perform_locked(phase, &)
76
- ensure
77
- @lock.unlock
78
- end
70
+ with_session_lock(phase) { perform_locked(phase, &) }
79
71
  end
80
72
 
81
73
  # 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
82
74
  def with_operation(phase)
83
- unless @lock.try_lock
84
- raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
85
- end
86
- @operation_owner = [Thread.current, Fiber.current]
87
- result = nil
88
- begin
75
+ with_session_lock(phase, operation: true) do
76
+ result = nil
89
77
  @log.with_operation_context(phase) { result = yield }
90
78
  rescue Error => error
91
79
  raise unless result.is_a?(Result)
@@ -93,9 +81,6 @@ module Net
93
81
  # 日志收尾失败不能抹去已完成的设备步骤,也不能覆盖原业务错误。
94
82
  Result.new(steps: result.steps, config: result.config,
95
83
  error: result.error || normalize_error(error, phase: phase))
96
- ensure
97
- @operation_owner = nil
98
- @lock.unlock
99
84
  end
100
85
  end
101
86
 
@@ -108,15 +93,7 @@ module Net
108
93
 
109
94
  # 独占关闭会话;已有操作占用时返回会话繁忙错误。
110
95
  def close
111
- unless @lock.try_lock
112
- raise build_error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
113
- end
114
-
115
- begin
116
- close_resources
117
- ensure
118
- @lock.unlock
119
- end
96
+ with_session_lock(:close, busy_message: "cannot close a session owned by another operation") { close_resources }
120
97
  end
121
98
 
122
99
  # 执行特权认证,并更新提示符及特权状态。
@@ -234,11 +211,44 @@ module Net
234
211
 
235
212
  private
236
213
 
214
+ # 锁取得与所有权登记不可拆开;只在交接及释放窗口延迟取消,业务等待仍可中断。
215
+ def with_session_lock(phase, operation: false, busy_message: "session already belongs to another operation")
216
+ acquired = false
217
+ failure = nil
218
+ begin
219
+ Thread.handle_interrupt(Exception => :never) do
220
+ acquired = @lock.try_lock
221
+ @operation_owner = [Thread.current, Fiber.current] if acquired && operation
222
+ end
223
+ unless acquired
224
+ raise build_error(SessionBusy, busy_message, phase: phase), cause: nil
225
+ end
226
+
227
+ yield
228
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 解锁期间的二次取消不能覆盖原业务故障。
229
+ failure = error
230
+ raise
231
+ ensure
232
+ if acquired
233
+ begin
234
+ Thread.handle_interrupt(Exception => :never) do
235
+ @operation_owner = nil if operation
236
+ @lock.unlock
237
+ end
238
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 完成释放后再交付取消,并避免原故障形成 cause 环。
239
+ error = failure || error
240
+ raise error, cause: error.cause
241
+ end
242
+ end
243
+ end
244
+ end
245
+
237
246
  # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
238
247
  def perform_locked(phase)
239
- @performing = true
240
248
  completed = false
249
+ failure = nil
241
250
  begin
251
+ @performing = true
242
252
  unless connected?
243
253
  close_resources unless state == :closed
244
254
  connect_session
@@ -251,15 +261,34 @@ module Net
251
261
  failure = normalize_error(error, phase: phase)
252
262
  close_preserving_failure
253
263
  raise failure, cause: nil
254
- rescue Exception # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
264
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
265
+ failure = error
255
266
  close_preserving_failure
256
267
  raise
257
268
  ensure
269
+ finish_perform(completed, failure)
270
+ end
271
+ end
272
+
273
+ # 资源查询和关闭仍可取消;不论它们如何退出,都先清除执行标记再交付首个故障。
274
+ def finish_perform(completed, failure)
275
+ begin
258
276
  # throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
259
277
  # 半途的设备提示符误当成下一条命令的响应。
260
278
  close_preserving_failure unless completed || !connected?
261
- @state = :ready if connected?
262
- @performing = false
279
+ connected = connected?
280
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾取消不能覆盖已记录的操作故障。
281
+ failure ||= error
282
+ ensure
283
+ begin
284
+ Thread.handle_interrupt(Exception => :never) do
285
+ @state = :ready if connected
286
+ @performing = false
287
+ end
288
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 状态登记完成后才交付二次取消。
289
+ failure ||= error
290
+ end
291
+ raise failure, cause: failure.cause if failure
263
292
  end
264
293
  end
265
294
 
@@ -164,7 +164,8 @@ module Net
164
164
 
165
165
  # 删除行尾空白,写出当前行并重置光标。
166
166
  def emit_line(newline:)
167
- @target.write(utf8_safe(@line.sub(/[ \t]+\z/n, "")))
167
+ # 其他 ASCII 空白控制字节已由 consume_control 消费,缓冲区只需裁剪空格和制表符。
168
+ @target.write(utf8_safe(@line.rstrip))
168
169
  @target.write("\n") if newline
169
170
  @line.clear
170
171
  @cursor = 0
@@ -176,7 +177,7 @@ module Net
176
177
  return text.b if text.valid_encoding?
177
178
  raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
178
179
 
179
- text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
180
+ text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join("") }.b
180
181
  end
181
182
  end
182
183
  end
@@ -8,9 +8,16 @@ module Net
8
8
  module Connector
9
9
  # 传输事件使用从零开始的模式索引;最后一个额外索引表示流式数据。
10
10
  Event = Struct.new(:index, :before, :match, :error) do
11
- # 规范化事件的默认字节字段,便于读取器统一处理。
11
+ # 固定每次读取的字节快照,传输复用缓冲区时不能改写先前分片。
12
12
  def initialize(index: nil, before: "".b, match: "".b, error: nil)
13
- super
13
+ unless before.is_a?(String) && match.is_a?(String)
14
+ raise ArgumentError, "before and match must be Strings"
15
+ end
16
+
17
+ before = before.b.freeze unless before.frozen? && before.encoding == Encoding::BINARY
18
+ match = match.b.freeze unless match.frozen? && match.encoding == Encoding::BINARY
19
+ super(index, before, match, error)
20
+ freeze
14
21
  end
15
22
 
16
23
  # 判断读取是否命中了模式而不是传输错误。
@@ -79,8 +86,8 @@ module Net
79
86
  def read(patterns, timeout:)
80
87
  reader = @channel.respond_to?(:expect_result) ? :expect_result : :expect
81
88
  result = @channel.public_send(reader, *patterns, STREAM, timeout: timeout)
82
- Event.new(index: result.number && (result.number - 1), before: result.before.to_s.b,
83
- match: result.match.to_s.b, error: result.error)
89
+ Event.new(index: result.number && (result.number - 1), before: result.before.to_s,
90
+ match: result.match.to_s, error: result.error)
84
91
  end
85
92
 
86
93
  # 临时设置写入超时,完成写入后恢复原设置。
@@ -52,6 +52,7 @@ module Net
52
52
  Thread.handle_interrupt(Exception => :never) do
53
53
  threads << Thread.new do
54
54
  failure = nil
55
+ exhausted = false
55
56
  begin
56
57
  # 全部线程成功建立后才允许设备操作,创建失败不能留下已发送的变更。
57
58
  Thread.handle_interrupt(Exception => :immediate) do
@@ -60,10 +61,13 @@ module Net
60
61
  index, item = task
61
62
  results[index] = yield item
62
63
  end
64
+ exhausted = true
63
65
  end
64
66
  rescue Exception => error # rubocop:disable Lint/RescueException -- 将中断同步交给调用线程,避免全局 abort_on_exception 波及其他线程。
65
67
  failure = error
66
68
  ensure
69
+ # Thread.exit/kill 不经过 rescue;不能把尚未完成的任务伪装成合法 nil 结果。
70
+ failure ||= RuntimeError.new("execution worker stopped before completing its tasks") unless exhausted
67
71
  finished << [Thread.current, failure]
68
72
  end
69
73
  end
@@ -73,7 +77,7 @@ module Net
73
77
  count.times do
74
78
  thread, failure = finished.pop
75
79
  thread.join
76
- raise failure if failure
80
+ raise failure, cause: failure.cause if failure
77
81
  end
78
82
  completed = true
79
83
  rescue Exception => error # rubocop:disable Lint/RescueException -- 记录取消或原任务故障,收尾中再次取消不能覆盖它。
@@ -102,7 +106,8 @@ module Net
102
106
  end
103
107
  rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复交付时保留原故障,二次取消不改变批次失败原因。
104
108
  # 二次取消可能已以原故障为 cause;不能反向关联形成异常原因环。
105
- raise primary_failure || error, cause: nil
109
+ error = primary_failure || error
110
+ raise error, cause: error.cause
106
111
  end
107
112
  end
108
113
  end
@@ -44,7 +44,7 @@ module Net
44
44
  preview = plan_summary(plan)
45
45
  @output.puts JSON.generate(plan: preview) if options[:json]
46
46
  @output.flush
47
- return 1 if options[:stdin_credentials] && @input.gets&.strip != "RUN"
47
+ return 1 if options[:stdin_credentials] && @input.gets("\n")&.strip != "RUN"
48
48
 
49
49
  Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
50
50
  report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
@@ -138,7 +138,7 @@ module Net
138
138
  require "io/console"
139
139
  output.print "#{label}:"
140
140
  output.flush
141
- password = input.noecho(&:gets)&.chomp
141
+ password = input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
142
142
  output.puts
143
143
  raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
144
144
 
@@ -14,7 +14,7 @@ module Net
14
14
  end
15
15
 
16
16
  begin
17
- values = JSON.parse(input.gets || "")
17
+ values = JSON.parse(input.gets("\n") || "")
18
18
  rescue JSON::ParserError
19
19
  raise ArgumentError, "凭据 JSON 无效", cause: nil
20
20
  end
@@ -241,7 +241,7 @@ module Net
241
241
  text.each_char.take_while do |character|
242
242
  width += character.ord > 255 ? 2 : 1
243
243
  width < columns
244
- end.join
244
+ end.join("")
245
245
  rescue IOError, SystemCallError
246
246
  line
247
247
  end
@@ -39,15 +39,20 @@ module Net
39
39
  unless vendor_overrides.is_a?(Hash)
40
40
  raise ArgumentError, "vendor_overrides must be a Hash"
41
41
  end
42
- vendor_overrides.to_h do |label, vendor|
42
+ vendor_overrides.each_with_object({}) do |(label, vendor), mapping|
43
43
  unless label.is_a?(String) && vendor.is_a?(String) && !label.empty?
44
44
  raise ArgumentError, "vendor_overrides must map labels to connector names"
45
45
  end
46
46
  key = normalize(label)
47
+ raise ArgumentError, "vendor_overrides labels must remain nonempty after normalization" if key.empty?
48
+
47
49
  value = vendor.to_sym
48
50
  raise ArgumentError, "vendor_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(value)
51
+ if mapping.key?(key) && mapping[key] != value
52
+ raise ArgumentError, "vendor_overrides contains conflicting normalized labels"
53
+ end
49
54
 
50
- [key, value]
55
+ mapping[key] = value
51
56
  end.freeze
52
57
  end
53
58
 
@@ -55,19 +60,22 @@ module Net
55
60
  def host_mapping(host_overrides)
56
61
  raise ArgumentError, "host_overrides must be a Hash" unless host_overrides.is_a?(Hash)
57
62
 
58
- host_overrides.to_h do |host, vendor|
63
+ host_overrides.each_with_object({}) do |(host, vendor), mapping|
59
64
  unless host.is_a?(String) && !host.include?("/") && vendor.is_a?(String)
60
65
  raise ArgumentError, "host_overrides must map IP addresses to connector names"
61
66
  end
62
67
  begin
63
68
  address = IPAddr.new(host).to_s
64
69
  rescue IPAddr::Error
65
- raise ArgumentError, "host_overrides contains an invalid IP address"
70
+ raise ArgumentError, "host_overrides contains an invalid IP address", cause: nil
66
71
  end
67
72
  connector = vendor.to_sym
68
73
  raise ArgumentError, "host_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
74
+ if mapping.key?(address) && mapping[address] != connector
75
+ raise ArgumentError, "host_overrides contains conflicting normalized IP addresses"
76
+ end
69
77
 
70
- [address, connector]
78
+ mapping[address] = connector
71
79
  end.freeze
72
80
  end
73
81
 
@@ -84,8 +92,10 @@ module Net
84
92
  end
85
93
  connector = rule.fetch("connector").to_sym
86
94
  raise ArgumentError, "mapping contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
95
+ vendor = normalize(rule.fetch("vendor"))
96
+ raise ArgumentError, "mapping vendor must remain nonempty after normalization" if vendor.empty?
87
97
 
88
- { vendor: normalize(rule.fetch("vendor")), os: rule["os"] && normalize(rule["os"]),
98
+ { vendor: vendor, os: rule["os"] && normalize(rule["os"]),
89
99
  model_prefix: rule["model_prefix"] && normalize(rule["model_prefix"]), connector: connector }.freeze
90
100
  end.freeze
91
101
  end
@@ -153,7 +163,7 @@ module Net
153
163
 
154
164
  [IPAddr.new(value).to_s.freeze, true]
155
165
  rescue IPAddr::Error
156
- raise ArgumentError, "host list entries must be IP addresses"
166
+ raise ArgumentError, "host list entries must be IP addresses", cause: nil
157
167
  end.freeze
158
168
  end
159
169
 
@@ -37,9 +37,11 @@ module Net
37
37
  end
38
38
 
39
39
  # 分开保存默认策略、显式覆盖与凭据来源;fixed 实例只承载已复制的公开快照。
40
- def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
40
+ def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false, path_context: nil)
41
41
  @env, @defaults, @overrides = env, defaults, overrides
42
42
  @policy, @fixed = policy, fixed
43
+ # 路径上下文只在内部固定策略之间传递,不属于用户配置或凭据来源。
44
+ @path_context = (path_context || [Dir.pwd, Dir.home]).map { |path| path.dup.freeze }.freeze if fixed
43
45
  end
44
46
 
45
47
  # 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
@@ -47,7 +49,7 @@ module Net
47
49
  if @fixed
48
50
  public_values = @env.merge(@overrides).merge(values).slice(*non_secret_keys)
49
51
  .transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
50
- return self.class.new(env: public_values, fixed: true).freeze
52
+ return self.class.new(env: public_values, fixed: true, path_context: @path_context).freeze
51
53
  end
52
54
 
53
55
  policy = @policy.with_overrides(values) if @policy
@@ -150,7 +152,7 @@ module Net
150
152
  unless directory.is_a?(String) && !directory.empty? && !directory.include?("\0")
151
153
  raise ArgumentError, "netdisco.cache_directory must be a nonempty path without NUL"
152
154
  end
153
- { directory: File.expand_path(directory), ttl: number("ND_CACHE_TTL", InventoryCache::DEFAULT_TTL),
155
+ { directory: absolute_path(directory), ttl: number("ND_CACHE_TTL", InventoryCache::DEFAULT_TTL),
154
156
  enabled: boolean("ND_CACHE_ENABLED", true) }.freeze
155
157
  end
156
158
 
@@ -169,7 +171,7 @@ module Net
169
171
  path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
170
172
  raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
171
173
 
172
- File.expand_path(path)
174
+ absolute_path(path)
173
175
  end
174
176
 
175
177
  # 与 Worker 共用并发上限,非法值在任务与目录创建前失败。
@@ -226,11 +228,13 @@ module Net
226
228
  # 本机核验根目录属于策略快照,解析路径不证明文件已经上传。
227
229
  def tftp_root
228
230
  path = value("NC_TFTP_ROOT")
229
- File.expand_path(path) if path
231
+ absolute_path(path) if path
230
232
  end
231
233
 
232
234
  # 端口、TLS 与连接超时进入固定策略;主机、数据库名和登录凭据仍从环境读取。
233
235
  def database_options
236
+ return @policy.database_options if @policy
237
+
234
238
  options = {}
235
239
  %i[port connect_timeout].each do |name|
236
240
  key = "ND_DB_#{name.to_s.upcase}"
@@ -242,14 +246,20 @@ module Net
242
246
  item = value("ND_DB_#{name.to_s.upcase}")
243
247
  options[name] = item if item
244
248
  end
245
- DatabaseClient.connection_policy(**options)
249
+ options = DatabaseClient.connection_policy(**options)
250
+ # libpq 的 system 是系统信任库标记;其他显式路径按字面解析,不展开波浪号。
251
+ if @fixed && options[:sslrootcert] && options[:sslrootcert] != "system"
252
+ options = options.merge(sslrootcert: File.absolute_path(options[:sslrootcert], @path_context.first).freeze).freeze
253
+ end
254
+ options
246
255
  end
247
256
 
248
257
  # 经 Configuration 复用连接校验;厂商协议覆盖通用协议,不包含设备凭据。
249
258
  def connection_options(vendor = nil)
259
+ known_hosts = value("NC_KNOWN_HOSTS")
250
260
  configuration = Configuration.new(
251
261
  protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
252
- log_level: value("NC_LOG_LEVEL") || "info", known_hosts: value("NC_KNOWN_HOSTS"),
262
+ log_level: value("NC_LOG_LEVEL") || "info", known_hosts: known_hosts && absolute_path(known_hosts),
253
263
  host_key_policy: value("NC_HOST_KEY_POLICY") || "strict",
254
264
  max_script_output_bytes: optional_integer("NC_MAX_SCRIPT_OUTPUT_BYTES")
255
265
  )
@@ -281,7 +291,7 @@ module Net
281
291
  # 会话日志目录属于公开策略;调用方和批次入口负责实际创建。
282
292
  def log_directory
283
293
  path = value("NC_LOG_DIRECTORY")
284
- File.expand_path(path) if path
294
+ absolute_path(path) if path
285
295
  end
286
296
 
287
297
  # 生成已验证的无凭据配置摘要,不展开原环境或 resolver。
@@ -326,6 +336,20 @@ module Net
326
336
  @defaults.fetch(key, default)
327
337
  end
328
338
 
339
+ # 固定策略及其覆盖共用快照的工作目录和主目录;活设置仍按调用时的环境解析。
340
+ def absolute_path(path)
341
+ return @policy.absolute_path(path) if @policy
342
+ return File.expand_path(path) unless @fixed
343
+
344
+ if path == "~" || path.start_with?("~/")
345
+ home = @path_context.last
346
+ raise ArgumentError, "non-absolute home" unless home.start_with?(File::SEPARATOR)
347
+
348
+ path = home + path.delete_prefix("~")
349
+ end
350
+ File.expand_path(path, @path_context.first)
351
+ end
352
+
329
353
  private
330
354
 
331
355
  # 身份指纹只用于隔离缓存;凭据、SQL 和参数均不写入缓存文件或摘要。
@@ -61,7 +61,7 @@ module Net
61
61
 
62
62
  # 过滤表头、分隔线和设备提示符,保留待解析的邻居行。
63
63
  def neighbor_lines(output)
64
- output.lines.drop_while { |line| !line.match?(TABLE_HEADER) }.map(&:strip).reject do |line|
64
+ output.lines("\n").drop_while { |line| !line.match?(TABLE_HEADER) }.map(&:strip).reject do |line|
65
65
  line.empty? || line.match?(TABLE_HEADER) || line.match?(/\A[-=+ ]+\z/) ||
66
66
  line.match?(/\A(?:<[^>]+>|\[[^\]]+\])\z/)
67
67
  end
@@ -52,7 +52,7 @@ module Net
52
52
 
53
53
  # 去掉命令、表头、总数和提示符,只保留邻居数据行。
54
54
  def neighbor_lines(output)
55
- output.lines.map(&:strip).reject do |line|
55
+ output.lines("\n").map(&:strip).reject do |line|
56
56
  line.empty? || line == neighbor_command || line.match?(/\ASystem Name\s+Local Interface\b/i) ||
57
57
  line.match?(/\ATotal lldp neighbor number:\s*\d+\.?\z/i) ||
58
58
  line.match?(/\A[-=+ ]+\z/) || line.match?(/\A\S+(?:#|\([MBF]\))\z/)
@@ -50,7 +50,7 @@ module Net
50
50
 
51
51
  pending = +""
52
52
  quote = nil
53
- text.each_line do |line|
53
+ text.each_line("\n") do |line|
54
54
  next if pending.empty? && line.strip.empty?
55
55
  return false if pending.empty? && !line.start_with?("set ")
56
56
 
@@ -67,7 +67,7 @@ module Net
67
67
  # 邻居字段区只有空行和提示符时,才视为明确的空块。
68
68
  def empty_neighbor_block?(block)
69
69
  tail = block.split(/^\s*Neighbor information:\s*$/i, 2)
70
- tail.size == 2 && tail.last.lines.all? { |line| line.strip.empty? || line.match?(/^\S+[>#]\s*$/) }
70
+ tail.size == 2 && tail.last.lines("\n").all? { |line| line.strip.empty? || line.match?(/^\S+[>#]\s*$/) }
71
71
  end
72
72
  end
73
73
  end
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.10.2"
6
+ VERSION = "0.11.0"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors