net-connector 0.7.0 → 0.7.2

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: 83213050dfe70e3f81b22303e33a960c1d50880a6f8455f993568661a0464203
4
- data.tar.gz: 4e1780c49d1fb686db3e0e955ae78384e1abb11583863b54680535aab6be15ef
3
+ metadata.gz: 1d0c8bdb5d1bb484683d8bc2b5da59f3c75f506479b4911776ca7b1ce9a82bc1
4
+ data.tar.gz: 8ee5fc708d89da6778af7aa5742775969e81cd1b1cea55ff9adc031aea2bb433
5
5
  SHA512:
6
- metadata.gz: a0c786d8d8dc12880cdbd44bfd0fc6ed440b01b5245cb20a090428accdaf9a159f133c7f442eb8397feab65e5dbec9431bd6618197bce1819b229708fd832ee7
7
- data.tar.gz: 21e51022f1e7161026776fc637dfbcfdc484f46edd250d61eadcc505b59731e00e4836dda483fcb27035f858fdc31c514dbd8a1b6678cf5df41b1fd482469766
6
+ metadata.gz: 04f5fa015e849927a3fda6b858f41b06bc352167e698962a62817d716b7267cd54feb5a0c219e2c48f976e200527ad72a95268a4cb40c70ef1b241f1965398b8
7
+ data.tar.gz: 21395d46e9fad39a3e8dd45ddfa512031576d4e89121d9d911ce3973e384becc51474d5035d2b6efdb9771f854a4a893976655409ab1012e5fcec9764f131bcf
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## 未发布
4
4
 
5
+ ## 0.7.2 - 2026-10-06
6
+
7
+ - 修复 IOS/NX-OS 配置清理误删 banner 内命令示例、进度文本及其后正文的问题,避免用截断配置覆盖旧备份。
8
+ - 命令完成后即使日志或事件回调出错,失败结果仍保留已完成步骤,TFTP 错误仍携带设备确认的上传回执,不自动重放命令。
9
+ - 修复固定文件名 TFTP 上传把前一设备残留文件误认为本次上传的问题,按服务器文件系统的时间核验,仍支持向预创建文件重新上传相同内容。
10
+ - `log_file` 只接受普通文件,拒绝 FIFO 等特殊文件,避免连接前阻塞;其他输出流可通过 `logger` 注入。
11
+ - 加快终端渲染、配置采集、debug 日志及大规模设备地址过滤,保留控制符处理、UTF-8 校验和输出限额。
12
+ - 修复 ANSI 行首擦除后破坏中文等 UTF-8 文本的问题,非法字节仍按日志或严格解析策略处理。
13
+
14
+ ## 0.7.1 - 2026-10-05
15
+
16
+ - 兼容 expect-pty 0.7 的 Session、expect 与 transcript 接口,保留旧版和输出限额语义。
17
+
18
+ - 版本标签触发 RubyGems OIDC 可信发布,不再依赖静态 `RUBYGEMS_API_KEY` Secret;保留完整 CI 矩阵、原始产物发布、敏感信息扫描和 GitHub/RubyGems SHA256 回读校验。
19
+
5
20
  ## 0.7.0 - 2026-09-28
6
21
 
7
22
  - 统一内部参数、预算和声明校验的 `validate_*!` 命名;区分脚本发送前与响应后的预算检查,复用并发预检,简化状态计数并补充值对象复制边界测试。保持本轮增量重构的公开接口、错误及完成证据语义。
data/README.md CHANGED
@@ -190,6 +190,8 @@ ruby examples/backup.rb --concurrency 10 --username backup-user --ask-password
190
190
 
191
191
  `Configuration` 支持 `protocol: :ssh`(默认)或 `:telnet`,以及端口、超时、输出大小、`log_file`、`logger`、`log_format`、`log_level` 和 `known_hosts` 等参数。文本日志使用 Ruby 标准库 `Logger`,记录毫秒时间、级别、设备、中文说明和完整事件字段。`:info` 包括连接、登录、命令响应、脚本处理和 TFTP 结果;`:debug` 增加逐行脱敏回显;`:warn`、`:error` 只保留相应级别。`:raw` 文件只写经过现有敏感保护的设备字节,不添加事件字段。
192
192
 
193
+ `log_file` 只接受普通文件,拒绝符号链接、FIFO 和设备文件;应用管理的输出流可通过 `logger` 注入。命令已经完成后若日志或事件回调失败,失败结果仍保留完成步骤,TFTP 错误仍携带已确认的设备回执;调用方不能据此自动重放命令。
194
+
193
195
  每次连接生成 `session_id`,每条实际发送的命令分配 `command_id`;日志还包含 `operation`、`phase`、脚本 `source` / `line`、`duration_ms`、`response_bytes` 和失败 `code`。`command_complete` 的 `response_received` 只表示收到了提示符;`operation_complete` 覆盖脚本准备、执行及后处理,不替代 TFTP 服务端核验或设备持久化证据。普通自定义事件使用 `device.log_event("audit", level: :info, count: 2)`。
194
196
 
195
197
  可注入 `logger: Rails.logger` 或普通 `Logger`。有效级别取 `log_level` 与调用方**当前**级别中较严格的一项;连接器不修改它的级别、formatter 或 progname,也不关闭它。消息是已脱敏且冻结的 `Net::Connector::Log::Event`,`to_s` 供文本显示,`to_h` 供应用 formatter 输出 JSON:
data/docs/RELEASING.md CHANGED
@@ -56,16 +56,19 @@ ruby script/release.rb --artifact /path/to/verified/net-connector-X.Y.Z.gem
56
56
 
57
57
  ## GitHub Actions 发布
58
58
 
59
- 仓库中配置 `RUBYGEMS_API_KEY` Actions Secret,并赋予发布此 gem 所需的权限。
60
- Runner 不继承本机 RubyGems 登录。GitHub 操作使用工作流自带的 token。
59
+ Actions 使用 RubyGems Trusted Publishing,通过 GitHub OIDC 交换短期发布凭据,不再需要仓库的 `RUBYGEMS_API_KEY` Secret。
61
60
 
62
- 提交和推送版本修改后,创建并推送对应的 `vX.Y.Z` 标签,在该标签上手动运行
63
- `Release` 工作流。只允许标签与 gem 版本一致且说明已归档的发布。
64
- 工作流先复用完整 CI 矩阵,全部通过后下载 Ubuntu / Ruby 4.0 验证的同一个
65
- gem,再执行 `--artifact`;发布阶段不重新构建。
61
+ 首次在 [net-connector 的 Trusted publishers 页面](https://rubygems.org/gems/net-connector/trusted_publishers)
62
+ 创建 GitHub Actions 发布者:Repository owner 为 `gatework`,Repository name 为 `net-connector`,Workflow filename 为
63
+ `release.yml`。可将 Environment 限定为 `release`;工作流始终使用该环境。需要审批时,在 GitHub 仓库 Settings → Environments 中配置。
66
64
 
67
- 普通 push、PR 和标签推送只触发 CI。Release 工作流仅手动触发,避免本地
68
- 发布创建标签时又自动开始第二次发布。
65
+ 提交和推送版本修改后,推送对应的 `vX.Y.Z` 标签会自动触发 Release。也可在 Actions → Release → Run workflow 选择同一版本标签恢复失败任务。
66
+ 只允许标签与 Gem 版本一致且说明已归档的发布。避免本地与 Actions 同时上传同一版本,优先使用标签工作流。
67
+
68
+ 工作流先复用完整 CI 矩阵,全部通过后下载 Ubuntu / Ruby 4.0 验证的同一个 Gem,配置 OIDC 凭据并执行 `--artifact`;
69
+ 发布阶段不重新构建,源码、历史、包的敏感信息扫描及 SHA256 回读继续生效。
70
+
71
+ 没有匹配的可信发布者时,OIDC 交换会失败;先补齐 RubyGems 配置再恢复原包发布。本地脚本仍使用既有 Gem 登录和 MFA。
69
72
 
70
73
  ## 失败后重试
71
74
 
data/docs/architecture.md CHANGED
@@ -54,6 +54,8 @@
54
54
 
55
55
  采集命令匹配当前会话的完整提示符行,不以末尾单个 `#`、`>` 或 `]` 判断完成。PAN-OS 切换视图时保留已认证的设备身份。缺少最终提示符会使采集失败,旧备份保持不变;只有提示符或命令回显的响应属于 `:incomplete_configuration`,不是成功的空配置。PAN-OS 在 `show` 前后都检查候选配置差异。
56
56
 
57
+ IOS/NX-OS 清理器处理单条配置命令的响应,不把正文当成多命令会话记录。banner 内的命令、进度及完成提示均属于配置内容;易变时间注释仅从首个配置语句之前的响应头删除。终端渲染器成段处理普通文本,控制序列仍逐字节解释;光标覆盖、行尾空白、UTF-8 校验和逐行字节限额保持一致。
58
+
57
59
  `LocalBackup` 在采集前取得 `BackupLock`,持有到摘要比较、替换与结果构造结束。锁名由 realpath 父目录与归一化文件名决定;Unicode NFC、大小写折叠后的摘要同时覆盖尚未创建的目标。区分大小写的文件系统也保守合并这些锁,目标名称本身不变。锁文件用 NOFOLLOW/0600 打开,再验证普通文件、所有者、单硬链接和 inode;释放仅关闭 FD,不删除锁文件。默认 `flock(LOCK_EX | LOCK_NB)`,可选等待共用有限单调期限,超时均为 `BackupBusy`。
58
60
 
59
61
  Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把锁,再向下层备份授权一次同进程、同 Fiber 的借用。借用在采集前消费,回调递归备份不能重复使用;没有全局路径缓存。路径锁在外、会话锁在内,`Base#backup` 拒绝已有会话操作中的嵌套调用。独立进程必须采用同一 flock 协议;调用方保护目录及祖先,锁不约束不合作的写入者。
@@ -136,10 +138,10 @@ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态
136
138
  任务 ensure 和 Session 超时仍保留;任意用户回调应自行保证返回,不能承诺非合作
137
139
  回调的硬期限。清单获取 deadline 只约束 Client.devices,不覆盖整个 Fleet 任务。
138
140
 
139
- NC-07C(TFTP 服务端验证适配器)同为可选扩展,本轮 **deferred**:尚无调用方存储、
140
- 时间/版本关联和目标隔离协议,当前业务执行不生成 server_verified。只检查文件
141
- 存在或只把固定名上传串行化均不足以实现验证,因此 PAN-OS 同名冲突拒绝规则不变。
142
- 后续适配器必须关联设备、实际目标、时间/版本、摘要及本次任务;验证失败也不能重传。
141
+ NC-07C 的本地服务器目录核验已接入 `TftpVerification`;单设备 API 仍只报告设备回执。
142
+ Fleet 可把回执与本地普通文件的时间、稳定性、摘要及目录项身份关联,生成 server_verified。
143
+ 启用历史归档并提供服务器目录时,PAN-OS 固定名可在路径锁内顺序上传、核验和归档;
144
+ 其他场景仍拒绝同批固定名碰撞。验证失败不能重传设备命令。
143
145
 
144
146
  本地租约不阻止其他设备会话/管理员写入;flock 不隔离未合作的进程,也不能使祖先目录
145
147
  或网络文件系统成为可信事务。目录同步不等于断电恢复认证,TFTP 的同批检查不解决外部
@@ -260,7 +262,7 @@ TFTP 策略的类方法 `filename(host, label: nil)` 是必需的无 I/O 命名
260
262
 
261
263
  TFTP 策略必须实现 `validate_options!(target, source_file:, vrf:)`、`receipt_metadata(target, source_file:, explicit_source:)`、`resolve_source_file`、`default_path`、`remote_path`、`script` 和 `device_reported_complete?`。可继承 `Tftp::Strategy` 的公共默认值;重写上传流程时必须一并审视继承的参数约束和来源声明。预检不得访问设备,元数据只允许 configuration_kind/source_file/format/requested_path。接口缺失在 Profile 构造时失败,不再动态回退到另一套行为。
262
264
 
263
- 服务器核验适配器尚未接入;TFTP API 不会自动生成 server_verified 结果。PAN-OS 固定名仍按现有计划拒绝同批碰撞,串行运行不改变该限制,也不保证跨进程或跨批次隔离。
265
+ 单设备 TFTP API 返回 device_reported;Fleet 的本地目录核验可提升为 server_verified。`TftpArchive` 在持有目标路径锁后保存旧文件,并记录其 dev、ino、size、mtime、ctime。上传时间取同一服务器目录内临时文件的 mtime,随即清理临时文件,避免文件系统时钟精度低于 Time.now 时误拒绝新上传。核验要求非空文件的新鲜时间及稳定读取;已有文件还必须发生状态变化。重新写入相同内容可以通过,未变化的旧文件即使 mtime 在未来也不能冒充新上传。预创建的服务器目标文件在上传前保持可用,成功归档后按原规则清理;未合作的外部写入者仍不受路径锁约束。
264
266
 
265
267
  验收入口是 `script/ci`,也可用 `bundle exec rake release:check`。它检查源码、可用 Git 历史和 gem 内容中的敏感数据,执行 Ruby 与工作流 lint、完整测试,并在隔离 gem 目录及最小 Bundler 应用中安装。真实本地 PTY 烟测覆盖厂商加载、配置采集、打包模板和 CLI,不接触网络设备。初次下载依赖和工具需要联网,详见[验证文档](VERIFICATION.md)及[发布文档](RELEASING.md)。
266
268
 
@@ -367,12 +369,16 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
367
369
 
368
370
  错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause 或调用栈。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
369
371
 
372
+ 单条命令的完整响应在输出日志和完成事件之前保存;即使这些日志处理失败,Session 仍在脱敏作用域退出前把响应交给 Execution,保留已完成步骤与输出预算。TFTP 可从失败结果的完成步骤构造设备回执,再报告日志错误;不会据此重放命令。
373
+
370
374
  每次连接有独立 session_id,每次发送命令分配 command_id;operation、phase、source、line 贯穿命令开始、回显、完成和错误。command_complete 在 info 级别记录 duration_ms、response_bytes,status: response_received 只表示响应已完成。operation_complete 在脚本准备、执行、回调及最终处理之后记录成功或失败,不代替文件持久性或 TFTP 服务端核验。
371
375
 
372
376
  `device.log_event(name, level: :info, **fields)` 是唯一自定义事件入口。会话身份和命令上下文由引擎提供,不能从 fields 覆盖。事件名和字段值经终端渲染与脱敏后冻结;非法字段名丢弃,复杂对象和非有限浮点数隐藏,不调用任意对象的 inspect/to_s。敏感范围内自定义事件整体隐藏,包括事件名和任意字段,防止配置钩子输出未登记的秘密。
373
377
 
374
378
  注入的 logger 由调用方持有;连接器不修改级别、formatter、progname,也不关闭它。每次写入同时检查配置与 logger 当前级别。消息对象支持 to_s/inspect 和安全 to_h,应用可自行输出 JSON。自有文本日志使用毫秒时间及逐行事件;raw 文件只写经过敏感保护的字节。上下文退出前先收尾渲染与脱敏缓冲,避免上一命令尾部被标成下一条命令。
375
379
 
380
+ `log_file` 使用 NOFOLLOW/NONBLOCK 打开,再对同一 FD 检查普通文件类型,随后才修改权限或写入。FIFO、设备文件和目录不能用作日志文件;应用拥有的输出流通过 `logger` 接入。日志打开先于登录,不能依赖登录超时为文件打开提供期限。
381
+
376
382
  ## 公开契约与 Rails 接入
377
383
 
378
384
  应用代码优先使用 `Net::Connector.open/build`、`Base` 的公开设备方法、`Command`、`Script`、结果对象,以及 `Netdisco::Fleet`。厂商扩展使用 `Profile` DSL 和文档中列出的策略接口;`Profile::Builder`、`Session` 的状态字段、工作线程调度及解析辅助方法属于内部实现。扩展直接加载当前设备模块或厂商目录下的类。项目仍处于 0.x,公开契约的变更会在更新记录中说明。
@@ -61,14 +61,17 @@ module Net
61
61
  prompt = @prompt_resolver&.call(command)
62
62
  # 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
63
63
  validate_send_budget!(command) if @prompt_resolver
64
- response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do
64
+ response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do |received|
65
65
  # 必须在 Session 恢复命令词表前读取;最终处理只能继承敏感性,不长期保留秘密。
66
66
  @sensitive ||= @session.redactor.sensitive?
67
+ if received
68
+ @last_executed_command = command
69
+ @output_bytes += received.raw.bytesize
70
+ # 完成事实先进入结果;日志失败不能抹去已执行命令,也不能触发重放。
71
+ yield received if block_given?
72
+ end
67
73
  end
68
- @last_executed_command = command
69
- @output_bytes += response.raw.bytesize
70
74
  # 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
71
- yield response if block_given?
72
75
  validate_response_budget!(command)
73
76
  response
74
77
  end
@@ -224,8 +224,11 @@ module Net
224
224
  # 以私有权限打开设备日志文件。
225
225
  def open_file(path)
226
226
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
227
- file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW, 0o600)
227
+ file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
228
228
  begin
229
+ # FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
230
+ raise ArgumentError, "session log must be a regular file" unless file.stat.file?
231
+
229
232
  file.chmod(0o600)
230
233
  file.binmode
231
234
  file.write("\n") if file.size.positive? && @configuration.log_format == :text
@@ -152,12 +152,15 @@ module Net
152
152
 
153
153
  # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
154
154
  def execute_command(command, timeout:, prompt: nil)
155
+ response = nil
155
156
  redactor.with_scope(reuse: true) do
156
157
  protect_command(command)
157
- @log.with_command_context(command) { execute_command_with_logging(command, timeout: timeout, prompt: prompt) }
158
+ @log.with_command_context(command) do
159
+ execute_command_with_logging(command, timeout: timeout, prompt: prompt) { |received| response = received }
160
+ end
158
161
  ensure
159
- # 失败的敏感探测也可能被钩子捕获后继续;离开作用域前始终通知执行器。
160
- yield if block_given?
162
+ # 日志失败也要交付已完成响应;失败的敏感探测同样必须在恢复词表前通知执行器。
163
+ yield response if block_given?
161
164
  end
162
165
  end
163
166
 
@@ -254,13 +257,13 @@ module Net
254
257
  end
255
258
  end
256
259
 
257
- def execute_command_with_logging(command, timeout:, prompt:)
260
+ def execute_command_with_logging(command, timeout:, prompt:, &)
258
261
  started = Expect.monotonic
259
262
  @log.log_event("command_start")
260
263
  sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
261
264
  private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
262
265
  @log.log_event("device_output", level: :debug) if @log.debug? && !private_output
263
- response = exchange_command(command, timeout: timeout, prompt: prompt)
266
+ response = exchange_command(command, timeout: timeout, prompt: prompt, &)
264
267
  @log.log_response_output(response.raw) unless private_output
265
268
  details = { duration_ms: elapsed_ms(started), response_bytes: response.raw.bytesize }
266
269
  @log.log_event("command_complete", status: "response_received", **details)
@@ -306,6 +309,8 @@ module Net
306
309
  raise build_error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
307
310
  end
308
311
 
312
+ # 恢复敏感命令的日志接线也可能失败;在离开暂停范围前保存完成事实。
313
+ yield response
309
314
  response
310
315
  end
311
316
  if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
@@ -7,8 +7,17 @@ module Net
7
7
  module Connector
8
8
  # 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
9
9
  class TerminalRenderer
10
+ DEFAULT_MAX_LINE_BYTES = 32 * 1024 * 1024
11
+ private_constant :DEFAULT_MAX_LINE_BYTES
12
+
10
13
  # 将字节流完整渲染为字符串,供测试和一次性转换使用。
11
14
  def self.render(input, strict_utf8: false)
15
+ # 日志元数据通常是无控制符的 ASCII 单行;直接返回独立副本,仍受默认行限额约束。
16
+ if [true, false].include?(strict_utf8) && input.instance_of?(String) && input.bytesize <= DEFAULT_MAX_LINE_BYTES &&
17
+ input.ascii_only? && input.match?(/\A[\x20-\x7f]*\z/n)
18
+ return input.b.rstrip
19
+ end
20
+
12
21
  output = StringIO.new("".b)
13
22
  renderer = new(output, strict_utf8: strict_utf8)
14
23
  renderer.write(input)
@@ -17,7 +26,7 @@ module Net
17
26
  end
18
27
 
19
28
  # 初始化当前行、光标和转义序列状态。
20
- def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
29
+ def initialize(target, max_line_bytes: DEFAULT_MAX_LINE_BYTES, strict_utf8: false)
21
30
  raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
22
31
  unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
23
32
  raise ArgumentError, "max_line_bytes must be positive"
@@ -33,10 +42,23 @@ module Net
33
42
  @strict_utf8 = strict_utf8
34
43
  end
35
44
 
36
- # 逐字节消费终端输出,并返回输入字节数。
45
+ # 普通文本按片段写入;只有控制序列逐字节解释,避免大配置和逐行日志的 Ruby 调用开销。
37
46
  def write(input)
38
47
  bytes = input.to_s.b
39
- bytes.each_byte { |byte| consume(byte) }
48
+ offset = 0
49
+ while offset < bytes.bytesize
50
+ if @escape_state
51
+ consume_escape(bytes.getbyte(offset))
52
+ offset += 1
53
+ next
54
+ end
55
+
56
+ control = bytes.index(/[\x00-\x08\x0a-\x1f]/n, offset)
57
+ ending = control || bytes.bytesize
58
+ write_text(bytes.byteslice(offset, ending - offset)) if ending > offset
59
+ consume_control(bytes.getbyte(control)) if control
60
+ offset = ending + 1
61
+ end
40
62
  bytes.bytesize
41
63
  end
42
64
 
@@ -54,16 +76,13 @@ module Net
54
76
  private
55
77
 
56
78
  # 处理普通控制字符、换行、回车和转义序列起始符。
57
- def consume(byte)
58
- return consume_escape(byte) if @escape_state
59
-
79
+ def consume_control(byte)
60
80
  case byte
61
81
  when 7 then nil
62
82
  when 8 then @cursor = [@cursor - 1, 0].max
63
83
  when 10 then emit_line(newline: true)
64
84
  when 13 then @cursor = 0
65
85
  when 27 then @escape_state = :escape
66
- else write_byte(byte) if byte == 9 || byte >= 32
67
86
  end
68
87
  end
69
88
 
@@ -124,20 +143,23 @@ module Net
124
143
  case mode
125
144
  when 0 then @line = @line.byteslice(0, @cursor)
126
145
  when 1
127
- @line = (" " * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
146
+ @line = (" ".b * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
128
147
  when 2
129
148
  @line.clear
130
149
  @cursor = 0
131
150
  end
132
151
  end
133
152
 
134
- # 在光标位置写入字节,并限制单行缓冲大小。
135
- def write_byte(byte)
136
- raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if @cursor >= @max_line_bytes
153
+ # 覆盖光标后的同等字节数并保留余下后缀;超限时仍保留已经消费的有效前缀。
154
+ def write_text(bytes)
155
+ available = @max_line_bytes - @cursor
156
+ length = bytes.bytesize > available ? available : bytes.bytesize
157
+ raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" unless length.positive?
137
158
 
138
159
  @line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
139
- (@cursor < @line.bytesize) ? @line.setbyte(@cursor, byte) : @line << byte
140
- @cursor += 1
160
+ @line[@cursor, length] = length == bytes.bytesize ? bytes : bytes.byteslice(0, length)
161
+ @cursor += length
162
+ raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if length < bytes.bytesize
141
163
  end
142
164
 
143
165
  # 删除行尾空白,写出当前行并重置光标。
@@ -41,14 +41,29 @@ module Net
41
41
  @channel = if @channel_factory
42
42
  @channel_factory.call
43
43
  else
44
- Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
44
+ if defined?(Expect::Session)
45
+ Expect::Session.new(buffer_limit: nil, write_timeout: configuration.write_timeout)
46
+ else
47
+ Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
45
48
  preserve_buffer: false, log_stdout: false, log_listeners: false,
46
49
  debug_level: 0, write_timeout: configuration.write_timeout)
50
+ end
47
51
  end
48
52
  # Profile 使用宽、高;PTY 使用行数、列数。
49
53
  @channel.slave.winsize = @terminal_size.reverse if @terminal_size
50
- @channel.log_output = method(:write_log_output)
51
- @channel.spawn(*argv)
54
+ if @channel.respond_to?(:transcript=)
55
+ writer = Object.new
56
+ callback = method(:write_log_output)
57
+ writer.define_singleton_method(:write) do |bytes|
58
+ callback.call(bytes)
59
+ bytes.bytesize
60
+ end
61
+ @channel.transcript = writer
62
+ @channel.spawn(*argv, raw: true)
63
+ else
64
+ @channel.log_output = method(:write_log_output)
65
+ @channel.spawn(*argv)
66
+ end
52
67
  self
53
68
  rescue Exception # rubocop:disable Lint/RescueException -- A half-open PTY must be released on interrupts.
54
69
  begin
@@ -61,7 +76,8 @@ module Net
61
76
 
62
77
  # 从信道读取下一事件,并把字符串字段转换为字节字符串。
63
78
  def read(patterns, timeout:)
64
- result = @channel.expect_result(*patterns, STREAM, timeout: timeout)
79
+ reader = @channel.respond_to?(:expect_result) ? :expect_result : :expect
80
+ result = @channel.public_send(reader, *patterns, STREAM, timeout: timeout)
65
81
  Event.new(index: result.number && (result.number - 1), before: result.before.to_s.b,
66
82
  match: result.match.to_s.b, error: result.error)
67
83
  end
@@ -10,8 +10,8 @@ module Net
10
10
  # 校验并保存清单过滤和厂商映射规则。
11
11
  def initialize(include_hosts: [], exclude_hosts: [], include_vendors: [], vendor_overrides: {},
12
12
  host_overrides: {}, mappings: [])
13
- @include_hosts = string_list(include_hosts)
14
- @exclude_hosts = string_list(exclude_hosts)
13
+ @include_hosts = host_index(include_hosts)
14
+ @exclude_hosts = host_index(exclude_hosts)
15
15
  @include_vendors = vendor_list(include_vendors)
16
16
  @vendor_overrides = vendor_mapping(vendor_overrides)
17
17
  @host_overrides = host_mapping(host_overrides)
@@ -126,8 +126,8 @@ module Net
126
126
 
127
127
  # 判断设备是否通过地址和厂商过滤。
128
128
  def selected?(host, vendor)
129
- return false if @exclude_hosts.include?(host)
130
- return false if !@include_hosts.empty? && !@include_hosts.include?(host)
129
+ return false if @exclude_hosts.key?(host)
130
+ return false if !@include_hosts.empty? && !@include_hosts.key?(host)
131
131
  return false if !@include_vendors.empty? && !@include_vendors.include?(vendor)
132
132
 
133
133
  true
@@ -140,15 +140,15 @@ module Net
140
140
  wireless ? :h3c_wireless : :h3c
141
141
  end
142
142
 
143
- # 校验并标准化管理地址列表。
144
- def string_list(values)
143
+ # 外部仍接受地址数组;内部仅做成员匹配,建索引避免逐台扫描整个清单。
144
+ def host_index(values)
145
145
  raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
146
146
 
147
- values.map do |value|
147
+ values.to_h do |value|
148
148
  raise ArgumentError, "host list entries must be Strings" unless value.is_a?(String) && !value.empty?
149
149
  raise ArgumentError, "host list entries must be IP addresses" if value.include?("/")
150
150
 
151
- IPAddr.new(value).to_s.freeze
151
+ [IPAddr.new(value).to_s.freeze, true]
152
152
  rescue IPAddr::Error
153
153
  raise ArgumentError, "host list entries must be IP addresses"
154
154
  end.freeze
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "tempfile"
4
5
  require_relative "../storage/backup_lock"
5
6
  require_relative "../storage/private_file"
6
7
  require_relative "../storage/safe_file"
@@ -52,14 +53,15 @@ module Net
52
53
  remote_filename = upload_filename(device)
53
54
  return yield remote_filename unless @server_root
54
55
 
55
- # The lock spans the upload and copy, including a fixed-name PAN-OS upload.
56
+ # 固定文件名的基线与上传时间都在锁内采集;排队期间前一设备的文件不属于本次上传。
56
57
  Storage::BackupLock.synchronize(File.join(@server_root, remote_filename), host: device.host, timeout: 240) do
57
- archive_existing_file(device, remote_filename)
58
+ previous = archive_existing_file(device, remote_filename)
59
+ upload_started_at = server_timestamp
58
60
  outcome = yield remote_filename
59
61
  receipt = outcome.backup
60
62
  return outcome unless receipt.is_a?(TftpReceipt) && receipt.path
61
63
 
62
- verified = @verifier.verify_outcome(outcome.with(started_at: started_at))
64
+ verified = @verifier.verify_outcome(outcome.with(started_at: started_at), started_at: upload_started_at, previous: previous)
63
65
  return outcome.success? ? archive_failure(outcome) : outcome unless verified.backup.verification == :server_verified
64
66
 
65
67
  archive_uploaded_file(verified)
@@ -71,16 +73,22 @@ module Net
71
73
 
72
74
  private
73
75
 
76
+ # 文件系统的 mtime 可能落后于 Time.now;用同一服务器目录的时间建立上传边界。
77
+ def server_timestamp
78
+ Tempfile.create(".net-connector-upload-", @server_root) { |file| file.stat.mtime }
79
+ end
80
+
74
81
  # 首次采用固定远端名时,先保留服务器上原有文件,再允许设备覆盖。
75
82
  def archive_existing_file(device, remote_filename)
76
83
  existing_path = File.join(@server_root, remote_filename)
77
- bytes = Storage::SafeFile.open(existing_path, missing: true) { |file, _stat| file.read }
84
+ bytes, previous = Storage::SafeFile.open(existing_path, missing: true) { |file, stat| [file.read, stat] }
78
85
  return unless bytes
79
86
 
80
87
  previous_directory = File.join(@server_archive_directory, "previous")
81
88
  FileUtils.mkdir_p(previous_directory, mode: 0o700)
82
89
  archive_path = File.join(previous_directory, archive_filename(device, File.extname(remote_filename)))
83
90
  Storage::PrivateFile.write(archive_path, bytes)
91
+ previous
84
92
  rescue StandardError
85
93
  raise ArchiveFailed.new(host: device.host), cause: nil
86
94
  end
@@ -23,22 +23,23 @@ module Net
23
23
  report.with(outcomes: outcomes.freeze)
24
24
  end
25
25
 
26
- def verify_outcome(outcome)
27
- return outcome unless @root && outcome.started_at && outcome.backup.is_a?(TftpReceipt) && outcome.backup.path
26
+ def verify_outcome(outcome, started_at: outcome.started_at, previous: nil)
27
+ return outcome unless @root && started_at && outcome.backup.is_a?(TftpReceipt) && outcome.backup.path
28
28
 
29
- verify(outcome, outcome.backup)
29
+ verify(outcome, outcome.backup, started_at: started_at, previous: previous)
30
30
  end
31
31
 
32
32
  private
33
33
 
34
- def verify(outcome, receipt)
34
+ def verify(outcome, receipt, started_at: outcome.started_at, previous: nil)
35
35
  path = File.join(@root, receipt.path)
36
36
  parent = File.realpath(File.dirname(path))
37
37
  return outcome unless parent == @root || parent.start_with?(@root + File::SEPARATOR)
38
38
 
39
39
  size = nil
40
40
  fingerprint = Storage::SafeFile.open(path, missing: true) do |file, stat|
41
- next unless stat.size.positive? && stat.mtime >= outcome.started_at
41
+ next unless stat.size.positive? && stat.mtime >= started_at
42
+ next if previous && same_file_state?(previous, stat)
42
43
 
43
44
  size = stat.size
44
45
  value = Storage::SafeFile.fingerprint_io(path, file, stat)
@@ -52,6 +53,12 @@ module Net
52
53
  # 核验失败不抹去设备上传回执;要求服务器证据的策略仍判为未完成。
53
54
  outcome
54
55
  end
56
+
57
+ # 内容相同的重新上传仍有效;但未变化的旧文件不能仅凭未来 mtime 冒充新上传。
58
+ def same_file_state?(before, after)
59
+ [before.dev, before.ino, before.size, before.mtime, before.ctime] ==
60
+ [after.dev, after.ino, after.size, after.mtime, after.ctime]
61
+ end
55
62
  end
56
63
  end
57
64
  end
@@ -6,13 +6,14 @@ module Net
6
6
  module Connector
7
7
  module CiscoIos
8
8
  class RunningConfig < Net::Connector::RunningConfig::Rendered
9
- # 去掉终端长度设置、保存命令回显和易变化的配置时间注释。
9
+ # 每条命令已有独立响应;正文中的命令示例属于配置,不能按会话回显删除。
10
+ # 只清理首个配置语句之前的时间注释,避免改写 banner 等多行文本。
10
11
  def clean(text)
11
12
  config = super
12
- config.gsub!(/^[^\n]*#\s*terminal\s+length\s+0\s*\n/i, "")
13
- config.sub!(/^[^\n]*#\s*copy\s+run(?:ning-config)?\s+(?:start|startup-config)\s*\n.*\z/im, "")
14
- config.gsub!(/^!.*(?:Last\s+configuration\s+change|NVRAM\s+config).*\n/i, "")
15
- config
13
+ config.sub(/\A(?:[ \t]*\n|![^\n]*\n|(?:\S+[#])?show[ \t]+running-config[^\n]*\n|
14
+ Building[ \t]+configuration[^\n]*\n|Current[ \t]+configuration[^\n]*\n)*/ix) do |header|
15
+ header.gsub(/^!.*(?:Last\s+configuration\s+change|NVRAM\s+config).*\n/i, "")
16
+ end
16
17
  end
17
18
  end
18
19
  end
@@ -5,18 +5,8 @@ require_relative "../cisco_ios/running_config"
5
5
  module Net
6
6
  module Connector
7
7
  module CiscoNxos
8
+ # 分页交互由引擎处理;响应正文中的进度或命令示例必须原样保留。
8
9
  class RunningConfig < CiscoIos::RunningConfig
9
- # 先清除 NX-OS 分页与进度回显,再复用 IOS 的配置清理规则。
10
- def clean(text)
11
- without_pager = text.to_s.gsub(
12
- /[ \t]*(?:\x1b\[[0-9;]*[A-Za-z])*--More--
13
- (?:\x1b\[[0-9;]*[A-Za-z])*(?:[\r\x00\x08 ]|\x1b\[[0-9;]*[A-Za-z])*/ix, ""
14
- )
15
- config = super(without_pager)
16
- config.sub!(/\A.*?^(?=[^\n]*#\s*show\s+running-config\s*$)/im, "")
17
- config.gsub(/^[ \t]*(?:\[[# ]*\]|\])[ \t]*\d+%[ \t]*\n?/i, "")
18
- .gsub(/^[ \t]*Copy\s+complete\b[^\n]*\n?/i, "")
19
- end
20
10
  end
21
11
  end
22
12
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Connector
5
- VERSION = "0.7.0"
5
+ VERSION = "0.7.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-28 00:00:00.000000000 Z
10
+ date: 2026-10-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: expect-pty