net-connector 0.10.0 → 0.10.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: b6ba09a377ed95488c9ecd62788732557564339638dec3ae596bbce0226369fa
4
- data.tar.gz: '0876aa9703bdbb2e46db010fcf263a2e1f1e7549e0f63d1bcf60f8529193e3ed'
3
+ metadata.gz: 7f994834e561576d4fafde11f003653a865dd4d35da5a5fd8a7a4c2a016cc2d5
4
+ data.tar.gz: f7585fe57b094fd687b2de70e34b6b41eb0e46c220d5fc9bfe4357a25c31f6f9
5
5
  SHA512:
6
- metadata.gz: 6636cacf21a401c68a6b6dd596e3c4ab11eb153879e6e6e9f74339cf72bea7f8e71e6937745300fc9e513c7b4521b1eecdb249a1cf3abe7b6c3a15fc253b34a4
7
- data.tar.gz: f97ff17b82418d78a1c959f420d4c17a53efc7ce4101694719e394e7d50d42f1ae2e86167a3b470850e598eba9becc42cb6364133fe2e6888eca426342534129
6
+ metadata.gz: c862e76e800b3ef6174e8aeadaedd4724dafbd08858954874e6955c0c71a4f21c1f9fd124dfe60e59e5389380330193740cbed6e2c0369ee6f9eaddd0b0d56ce
7
+ data.tar.gz: 6d62272a65c854987a1d9d73e1cb6aeba177743df828e37ec20595b31e9c42ada27dd787989a9cf1ecc17a3ae7fa1fe46f3c6db992e736ffcb2337c42b081feb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.10.2 - 2026-10-16
4
+
5
+ - 修复空清单缓存文件阻断查询的问题,空文件按缓存未命中处理,远端查询失败仍正常报告。
6
+ - 减少大清单缓存读取和刷新时的临时对象分配,保留字段白名单、限额和不可变快照。
7
+ - 修复本地备份及 TFTP 归档完成后锁关闭失败导致回执丢失的问题,报告保留已保存路径和原有设备错误,直接备份通过 `BackupFinalizeError#backup` 提供完成元数据。
8
+
9
+ ## 0.10.1 - 2026-10-11
10
+
11
+ - 设备 `open`、PTY、SSH 信任快照、日志与本地文件统一保护资源交接和关闭边界;异步取消在资源登记或回收后交付,业务等待仍可取消,收尾故障不覆盖首个操作错误。
12
+ - 修复线程回收同时发生二次取消时的循环异常原因,保留原始失败。
13
+ - 配置正文检查提前结束扫描,减少大配置的对象分配和字符串复制,保持原有命令回显与空配置判定。
14
+
3
15
  ## 0.10.0 - 2026-10-11
4
16
 
5
17
  - 新增 `sync_interface_descriptions`:一次调用、每台设备一个连接,完成邻居发现、描述修改、读回与保存;无变化时不写入或保存,后端仍支持定制格式和分阶段审批。
data/docs/architecture.md CHANGED
@@ -73,6 +73,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
73
73
 
74
74
  提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
75
75
 
76
+ 直接备份在路径锁退出前保存完成元数据,锁关闭的普通故障转换为 `BackupFinalizeError`,其中 `backup` 保留结果,`underlying_type` 经过类型白名单。该错误只确认备份操作已完成,不制造文件同步回执;未变文件的历史持久性仍未补验。Fleet 的外层路径锁及 TFTP 归档锁同样保存最新结果,退出故障转为 saved_with_error 或 reported_with_error,已有设备诊断优先,中断继续传播。
77
+
76
78
  TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
77
79
 
78
80
  TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
@@ -299,6 +301,8 @@ TFTP 策略必须实现 `validate_options!(target, source_file:, vrf:)`、`recei
299
301
 
300
302
  ## 加载入口与厂商策略
301
303
 
304
+ 公开设备 `open` 与批量任务共用 `DeviceSession` 的关闭边界。PTY、SSH 信任快照、日志及本地文件在取得资源与登记所有权之间延后异步取消,关闭时保护回收过程;设备命令、文件读取和锁竞争仍可取消。清理保留首个故障,二次取消不能替换主错误或形成循环异常原因。
305
+
302
306
  `require "net/connector"` 加载设备 API 和引擎,厂商由 `vendor_class` 按需加载。TextFSM 能力模块可预先组合,但外部 `textfsm` gem 只在实际解析时加载。仅需会话引擎时使用 `require "net/connector/engine/core"`;离线文件操作使用 `net/connector/storage`,不加载厂商。
303
307
 
304
308
  `engine/dialogue` 只定义 Interaction、Dialogue 与 Response;读取循环位于 `engine/response_reader`。`engine/errors` 不加载脱敏运行时,`engine/redactor` 负责秘密作用域。Session 明确加载并持有读取器与脱敏器。
data/docs/netdisco.md CHANGED
@@ -57,6 +57,8 @@ The directory must belong to the current user with mode `0700`; cache and lock f
57
57
 
58
58
  Expired inventory refreshes automatically. A remote inventory failure falls back to validated history without changing its original timestamp. Configuration errors, missing credentials, interruption and unsafe files remain errors. Failed cache writes preserve newly fetched inventory. `fleet.inventory_status` reports `:fresh_cache`, `:refreshed`, `:stale_cache` or `:uncached`, the original `fetched_at`, and safe failure metadata; the CLI warns on history fallback or cache write failure.
59
59
 
60
+ An empty or malformed cache file is a cache miss and triggers a remote query. It cannot supply fallback history if that query fails.
61
+
60
62
  Use `net-backup --refresh-inventory`, `fleet.devices(force_refresh: true)`, or `fleet.plan_backup(force_refresh: true)` to bypass freshness. TFTP planning accepts the same keyword. A forced refresh also falls back to valid history if the remote request fails. `cache_enabled: false` disables caching. Injected clients stay uncached unless explicitly wrapped in `Netdisco::InventoryCache`; shared wrapper status describes the latest call, so synchronize concurrent access to the same instance. Cache locks bound waiting and coalesce ordinary expired-cache refreshes across processes; independently requested forced refreshes still query the source.
61
63
 
62
64
  ## CLI planning and execution
@@ -189,6 +191,8 @@ Verification holds a target lock across the pre-upload file-state/time baseline,
189
191
 
190
192
  Batch examples enable history preservation. With an accessible server directory, uploads use hostname/IP names and are retained in both `<batch>/tftp/` and server `archive/<batch>/`; pre-existing same-name files are archived under `previous/` before upload. File-state and filesystem-time baselines are captured under the upload lock. Verification or archival failure preserves available receipts and committed paths without reporting success.
191
193
 
194
+ Path-lock cleanup failures after a completed local backup or TFTP archive retain the saved paths as `:saved_with_error` or `:reported_with_error`. An earlier device error remains the primary diagnostic; callbacks receive the retained outcome and commands are not replayed.
195
+
192
196
  Without a server directory, batch examples add a batch identifier to names, retain `device_reported`, and cannot save local configuration copies. PAN-OS uses the fixed `running-config.xml` name, so the history-preserving example refuses its upload without local server access. Plans still prevent same-batch filename collisions.
193
197
 
194
198
  Reports distinguish `verification`, `server_file_verified`, `local_file`, `server_archive_file`, `bytes`, and `sha256`. TFTP has no configuration `change` notification.
@@ -250,6 +250,8 @@ TFTP 的 `verification` 汇总与设备条目的 `server_file_verified` 区分
250
250
 
251
251
  提供 `verification_root:` 时,即使不保留历史,TFTP 核验也在目标锁内采集上传前文件状态与文件系统时间、执行上传并检查指纹。未变化的旧文件不能凭未来时间戳获得服务器核验;服务器目录须可写以建立锁和时间探针。结果回调与最终报告采用核验后的结果,文件证据不覆盖设备操作或关闭故障。回执路径与锁定目标不一致时保留设备证据,但缺少该路径的上传前基线,不核验或归档旧文件。
252
252
 
253
+ 本地备份或 TFTP 归档完成后若路径锁收尾失败,结果以 `:saved_with_error` 或 `:reported_with_error` 保留已保存路径。已有设备错误仍作为首个诊断,回调收到保留回执的结果,命令不自动重放。
254
+
253
255
  ## 清单缓存
254
256
 
255
257
  HTTP 和 PostgreSQL 默认启用本地清单缓存,有效期三天(259200 秒),目录为当前工作目录下的 `.cache/net-connector/inventory`。YAML `netdisco.cache_directory`、`cache_ttl` 和 `cache_enabled` 分别控制目录、秒数和是否启用;TTL 须为有限正数,设置 `cache_enabled: false` 关闭缓存。
@@ -260,6 +262,8 @@ HTTP 和 PostgreSQL 默认启用本地清单缓存,有效期三天(259200
260
262
 
261
263
  过期后自动刷新;远端请求失败时回退已验证历史,不更新原获取时间。配置错误、缺少凭据、中断和不安全文件不会被回退掩盖。缓存写入失败仍交付刚取得的新清单。`fleet.inventory_status` 提供 `:fresh_cache`、`:refreshed`、`:stale_cache`、`:uncached`、原始 `fetched_at` 和安全故障字段,CLI 对历史回退和缓存写入失败单独提示。
262
264
 
265
+ 空文件或格式损坏的缓存按未命中处理,重新查询远端;查询失败时,这类文件不能作为回退历史。
266
+
263
267
  强刷使用 `net-backup --refresh-inventory`、`fleet.devices(force_refresh: true)`、`fleet.plan_backup(force_refresh: true)`;TFTP 规划也支持此参数。强刷失败同样回退已验证历史。直接注入客户端时不自动缓存,可显式包裹 `Netdisco::InventoryCache`。一个包装器的状态描述最近调用,并发共享实例须由调用方同步。有界跨进程锁合并普通过期刷新,独立强刷请求各自访问远端。
264
268
 
265
269
  ## 配置批次与接口修正
data/docs/usage.md CHANGED
@@ -39,6 +39,8 @@ Create and protect the parent directory first. `open` closes the session on retu
39
39
 
40
40
  Writes use mode `0600`, file synchronization, atomic replacement, and parent-directory synchronization. A collection failure leaves the old file intact. Failure after replacement raises `BackupPersistenceError`; `error.backup` retains metadata and `error.receipt` distinguishes `:committed` from `:durable`. Unsupported directory synchronization is reported as `:backup_durability_unsupported`. Do not repeat the device operation to recover file finalization.
41
41
 
42
+ If path-lock cleanup fails after backup completes, `BackupFinalizeError` retains `error.backup` and a safe `error.underlying_type`. Its code is `:backup_finalize_failed`; it does not claim a new durability check for an unchanged file. Interruptions still propagate.
43
+
42
44
  Private `.net-connector-<digest>.lock` files remain in the directory. All writers must follow the same locking protocol; ancestor directories remain the caller's responsibility.
43
45
 
44
46
  For an in-memory configuration, use `device.running_config.value!`. The `Result` retains completed steps on failure. Collection suppresses configuration content in logs and error diagnostics, including debug/raw and application loggers; returned output and backup files remain sensitive.
data/docs/usage.zh-CN.md CHANGED
@@ -43,6 +43,8 @@ end
43
43
 
44
44
  写入使用 `0600` 临时文件、文件同步、原子替换及父目录同步。替换后同步或收尾失败会抛出 `BackupPersistenceError`,`error.backup` 保留已写文件的元数据,`error.receipt` 区分 `:committed`(持久性未确认)和 `:durable`(同步已完成)。目录同步不受支持对应 `:backup_durability_unsupported`,不会静默报告持久化成功,也不会自动重做设备采集。
45
45
 
46
+ 备份完成后若路径锁收尾失败,`BackupFinalizeError` 保留 `error.backup` 和安全的 `error.underlying_type`,错误码为 `:backup_finalize_failed`;内容未变时不会声称重新验证过文件持久性,中断仍向调用方传播。
47
+
46
48
  调用方应创建并保护备份目录,运行配置可能含有设备凭据。同目录下的 `.net-connector-<摘要>.lock` 是私有的长期锁文件,正常释放不删除它。所有写入者须使用本库的锁协议;`NOFOLLOW` 不保护被外部替换的祖先目录。锁名保守合并大小写及 Unicode 等价写法,实际备份文件名不变;在区分大小写的文件系统上,这些名称也会串行。
47
49
 
48
50
  ## 设备发起的 TFTP 备份
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
+ require_relative "../device_session"
4
5
  require_relative "../engine/core"
5
6
  require_relative "profile"
6
7
  require_relative "running_config"
@@ -62,20 +63,13 @@ module Net
62
63
  def self.open(**)
63
64
  raise ArgumentError, "open requires a block" unless block_given?
64
65
 
65
- device = new(**)
66
- begin
66
+ value, failure = DeviceSession.call(factory: -> { new(**) }) do |device|
67
67
  device.connect
68
68
  yield device
69
- rescue Exception # rubocop:disable Lint/RescueException -- 本次连接或块失败时仍关闭设备,并保留原始故障或中断。
70
- failed = true
71
- raise
72
- ensure
73
- begin
74
- device.close
75
- rescue Exception # rubocop:disable Lint/RescueException -- 只保留本次作用域的原始异常,不受外层已处理异常影响。
76
- raise unless failed
77
- end
78
69
  end
70
+ raise failure if failure
71
+
72
+ value
79
73
  end
80
74
 
81
75
  # 校验连接配置,创建对话语法、传输层、恢复器和会话对象。
@@ -39,6 +39,19 @@ module Net
39
39
  end
40
40
  end
41
41
 
42
+ # 备份已完成后的路径锁收尾错误;不把未变文件冒充本次完成 fsync 的写入回执。
43
+ class BackupFinalizeError < Error
44
+ attr_reader :backup, :underlying_type
45
+
46
+ def initialize(backup:, underlying:, host: nil)
47
+ raise ArgumentError, "backup must be a completed Backup" unless backup.instance_of?(Backup)
48
+
49
+ @backup = backup
50
+ @underlying_type = ErrorMetadata.type(underlying.class.name) || "StandardError"
51
+ super("backup completed; path lock finalization failed", code: :backup_finalize_failed, host: host, phase: :backup)
52
+ end
53
+ end
54
+
42
55
  # 先取得路径锁再采集和原子写入;提交后的收尾错误仍保留备份元数据。
43
56
  class LocalBackup
44
57
  # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
@@ -56,8 +69,13 @@ module Net
56
69
 
57
70
  # 采集配置、识别变更并原子写入私有文件。
58
71
  def call(path:, lock_timeout: 0)
72
+ backup = nil
59
73
  host = @device.host if @device.respond_to?(:host)
60
- Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
74
+ Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| backup = collect_and_write(lock.path) }
75
+ rescue StandardError => error
76
+ raise unless backup
77
+
78
+ raise BackupFinalizeError.new(backup: backup, underlying: error, host: host), cause: nil
61
79
  end
62
80
 
63
81
  private
@@ -103,9 +103,11 @@ module Net
103
103
  # 排除命令回显和提示符,确认响应确实包含配置正文。
104
104
  def config_body?(step)
105
105
  body = TerminalRenderer.render(step.output.delete_suffix(step.prompt.to_s)).strip
106
- lines = body.lines
107
- lines.shift if lines.first&.strip == step.command.text
108
- !lines.join.strip.empty?
106
+ # 只需确认首个正文行;不为大配置创建全部行对象或重新拼接整份响应。
107
+ body.each_line.with_index.any? do |line, index|
108
+ text = line.strip
109
+ !text.empty? && !(index.zero? && text == step.command.text)
110
+ end
109
111
  end
110
112
 
111
113
  # 将缺少配置的情况统一映射为可识别的设备错误码。
@@ -14,11 +14,16 @@ module Net
14
14
  begin
15
15
  # 构造器在阻塞等待中仍可取消;非阻塞的返回交接须先登记所有权再交付取消。
16
16
  # 工厂尚未返回时的部分资源由工厂清理,成功返回后统一由本作用域关闭。
17
- Thread.handle_interrupt(Exception => :on_blocking) { connector = factory.call }
17
+ Thread.handle_interrupt(Exception => :on_blocking) do
18
+ connector = factory.call
19
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 工厂同步故障先于退出屏蔽范围时交付的取消。
20
+ failure = error
21
+ raise
22
+ end
18
23
  value = yield connector
19
24
  completed = true
20
25
  rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾后保留最先发生的中断,普通故障单独交付业务层。
21
- failure = error
26
+ failure ||= error
22
27
  completed = true
23
28
  ensure
24
29
  begin
@@ -7,7 +7,7 @@ module Net
7
7
  CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
8
8
  PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
9
9
  OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
10
- BackupPersistenceError TftpCompletionError].freeze
10
+ BackupPersistenceError BackupFinalizeError TftpCompletionError].freeze
11
11
  TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
12
12
  %w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
13
13
  RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
@@ -20,7 +20,7 @@ module Net
20
20
  @host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
21
21
  @replace = replace
22
22
  @original = read_shared
23
- @file = Tempfile.new([".nc-known-hosts-", ""], File.dirname(@destination))
23
+ create_snapshot
24
24
  @path = @file.path
25
25
  @file.write(@original)
26
26
  @file.flush
@@ -57,6 +57,36 @@ module Net
57
57
  end
58
58
 
59
59
  def close
60
+ failure = nil
61
+ # 清除引用至删除快照期间延后异步取消,不能留下失去所有者的临时文件。
62
+ Thread.handle_interrupt(Exception => :never) do
63
+ close_snapshot
64
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 保存同步清理故障,退出屏蔽范围后继续传播。
65
+ failure = error
66
+ raise
67
+ end
68
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖清理过程中已经发生的故障。
69
+ raise failure || error, cause: nil
70
+ end
71
+
72
+ private
73
+
74
+ # 临时文件返回后先登记所有权,阻塞构造期间仍允许取消。
75
+ def create_snapshot
76
+ failure = nil
77
+ begin
78
+ Thread.handle_interrupt(Exception => :on_blocking) do
79
+ @file = Tempfile.new([".nc-known-hosts-", ""], File.dirname(@destination))
80
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 构造故障先于退出屏蔽范围时递送的二次取消。
81
+ failure = error
82
+ end
83
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 成功交接后取消仍传播,但不覆盖已登记的构造故障。
84
+ failure ||= error
85
+ end
86
+ raise failure, cause: nil if failure
87
+ end
88
+
89
+ def close_snapshot
60
90
  file = @file
61
91
  @file = nil
62
92
  File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
@@ -71,8 +101,6 @@ module Net
71
101
  end
72
102
  end
73
103
 
74
- private
75
-
76
104
  # 初始化失败时保留原始异常,同时尽力清理已经创建的临时文件。
77
105
  def close_preserving_failure
78
106
  close
@@ -31,15 +31,17 @@ module Net
31
31
  @transport = transport
32
32
  @session_id = SecureRandom.hex(8).freeze
33
33
  @command_sequence = 0
34
- @io = open_file(@configuration.log_file) if @configuration.log_file
34
+ open_file(@configuration.log_file) if @configuration.log_file
35
35
  if @configuration.log_format == :raw
36
36
  @writer = @output = RedactingWriter.new(@io, @redactor) if @io
37
37
  elsif @io || @configuration.logger
38
38
  @logger = @configuration.logger || ::Logger.new(@io, formatter: Formatter.new)
39
39
  open_transcript if @io && @configuration.log_level == :debug
40
40
  end
41
- rescue => error
41
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件已登记后的中断也必须释放资源,仍原样交付中断。
42
42
  close_preserving_failure
43
+ raise unless error.is_a?(StandardError)
44
+
43
45
  raise build_error("unable to open session log", error), cause: nil
44
46
  end
45
47
 
@@ -192,10 +194,16 @@ module Net
192
194
  failed = true
193
195
  raise
194
196
  ensure
197
+ close_failure = nil
195
198
  begin
196
- @io&.close
197
- rescue Exception # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
198
- raise unless failed
199
+ Thread.handle_interrupt(Exception => :never) do
200
+ @io&.close
201
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件关闭先发生的故障优先于待处理中断。
202
+ close_failure = error
203
+ raise
204
+ end
205
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 文件关闭不能覆盖首个日志收尾故障或中断。
206
+ raise close_failure || error, cause: nil unless failed
199
207
  ensure
200
208
  @writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
201
209
  @attached = false
@@ -244,29 +252,28 @@ module Net
244
252
 
245
253
  # 以私有权限打开设备日志文件。
246
254
  def open_file(path)
255
+ failure = nil
247
256
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
248
- file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
249
- begin
250
- # FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
251
- raise ArgumentError, "session log must be a regular file" unless file.stat.file?
252
-
253
- file.chmod(0o600)
254
- file.binmode
255
- file.write("\n") if file.size.positive? && @configuration.log_format == :text
256
- file
257
- rescue Exception # rubocop:disable Lint/RescueException -- 中断时也释放尚未完成设置的文件。
258
- begin
259
- file.close
260
- rescue Exception # rubocop:disable Lint/RescueException -- 保留文件设置的原始故障或中断。
261
- # 保留设置失败的原始原因。
262
- end
257
+ # 返回文件后先登记唯一所有者;后续设置失败由 open 统一释放,不重复交接资源。
258
+ Thread.handle_interrupt(Exception => :never) do
259
+ @io = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
260
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 非阻塞打开的主故障不能被待处理中断替换。
261
+ failure = error
263
262
  raise
264
263
  end
264
+ # FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
265
+ raise ArgumentError, "session log must be a regular file" unless @io.stat.file?
266
+
267
+ @io.chmod(0o600)
268
+ @io.binmode
269
+ @io.write("\n") if @io.size.positive? && @configuration.log_format == :text
270
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留打开阶段的首个故障。
271
+ raise failure || error, cause: nil
265
272
  end
266
273
 
267
274
  # 在日志打开失败后尽力释放资源。
268
275
  def close_preserving_failure
269
- close
276
+ Thread.handle_interrupt(Exception => :never) { close }
270
277
  rescue Exception # rubocop:disable Lint/RescueException -- 清理故障或中断不能覆盖日志打开失败。
271
278
  nil
272
279
  end
@@ -37,18 +37,23 @@ module Net
37
37
 
38
38
  # 打开伪终端、设置终端大小并启动设备进程。
39
39
  def open
40
+ failure = nil
40
41
  configuration.validate_endpoint!
41
- @channel = if @channel_factory
42
- @channel_factory.call
43
- else
44
- if defined?(Expect::Session)
42
+ # 构造期间的阻塞仍可取消;返回交接先登记信道,再交付异步中断。
43
+ Thread.handle_interrupt(Exception => :on_blocking) do
44
+ @channel = if @channel_factory
45
+ @channel_factory.call
46
+ elsif defined?(Expect::Session)
45
47
  Expect::Session.new(buffer_limit: nil, write_timeout: configuration.write_timeout)
46
48
  else
47
49
  Expect.new(raw_pty: true, reset_timeout_on_read: false, buffer_limit: nil,
48
- preserve_buffer: false, log_stdout: false, log_listeners: false,
49
- debug_level: 0, write_timeout: configuration.write_timeout)
50
+ preserve_buffer: false, log_stdout: false, log_listeners: false,
51
+ debug_level: 0, write_timeout: configuration.write_timeout)
50
52
  end
51
- end
53
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 退出屏蔽范围交付取消之前,先保存构造器的同步故障。
54
+ failure = error
55
+ raise
56
+ end
52
57
  # Profile 使用宽、高;PTY 使用行数、列数。
53
58
  @channel.slave.winsize = @terminal_size.reverse if @terminal_size
54
59
  if @channel.respond_to?(:transcript=)
@@ -65,9 +70,9 @@ module Net
65
70
  @channel.spawn(*argv)
66
71
  end
67
72
  self
68
- rescue Exception # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
73
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 中断时也必须释放尚未完成初始化的 PTY。
69
74
  close_preserving_failure
70
- raise
75
+ raise failure || error, cause: nil
71
76
  end
72
77
 
73
78
  # 从信道读取下一事件,并把字符串字段转换为字节字符串。
@@ -107,10 +112,19 @@ module Net
107
112
 
108
113
  # 清除信道和日志目标,并强制关闭底层进程。
109
114
  def close
110
- channel = @channel
111
- @channel = nil
112
- @log_target = nil
113
- channel&.hard_close
115
+ failure = nil
116
+ # 从字段移交到关闭完成期间不能取消,避免丢失尚未回收的进程。
117
+ Thread.handle_interrupt(Exception => :never) do
118
+ channel = @channel
119
+ @channel = nil
120
+ @log_target = nil
121
+ channel&.hard_close
122
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
123
+ failure = error
124
+ raise
125
+ end
126
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留最先观察到的故障。
127
+ raise failure || error, cause: nil
114
128
  end
115
129
 
116
130
  # 把人工交互结果转换为设备 EOF、超时或输入状态。
@@ -156,14 +170,21 @@ module Net
156
170
  end
157
171
 
158
172
  def open
173
+ failure = nil
159
174
  configuration.validate_endpoint!
160
175
  unless configuration.host_key_policy == :strict
161
- @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
176
+ # 临时信任文件的返回交接与信道遵守同一所有权边界。
177
+ Thread.handle_interrupt(Exception => :on_blocking) do
178
+ @known_hosts = KnownHosts.new(configuration, replace: @replace_host_key)
179
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 构造器先失败时,延迟取消不得覆盖其原始异常。
180
+ failure = error
181
+ raise
182
+ end
162
183
  end
163
184
  super
164
- rescue Exception # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
185
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 启动失败或中断时释放临时信任文件。
165
186
  close_preserving_failure
166
- raise
187
+ raise failure || error, cause: nil
167
188
  end
168
189
 
169
190
  def authenticated
@@ -172,18 +193,29 @@ module Net
172
193
  end
173
194
 
174
195
  def close
175
- known_hosts = @known_hosts
176
- @known_hosts = nil
177
- super
178
- rescue Exception # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
179
- failed = true
180
- raise
181
- ensure
182
- begin
183
- known_hosts&.close
184
- rescue Exception # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
185
- raise unless failed
196
+ failure = nil
197
+ # 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
198
+ Thread.handle_interrupt(Exception => :never) do
199
+ known_hosts = @known_hosts
200
+ @known_hosts = nil
201
+ begin
202
+ super
203
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
204
+ failure = error
205
+ raise
206
+ ensure
207
+ begin
208
+ known_hosts&.close
209
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
210
+ unless failure
211
+ failure = error
212
+ raise
213
+ end
214
+ end
215
+ end
186
216
  end
217
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
218
+ raise failure || error, cause: nil
187
219
  end
188
220
 
189
221
  # 返回 SSH 协议标识。
@@ -101,7 +101,8 @@ module Net
101
101
  end
102
102
  end
103
103
  rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复交付时保留原故障,二次取消不改变批次失败原因。
104
- raise primary_failure || error
104
+ # 二次取消可能已以原故障为 cause;不能反向关联形成异常原因环。
105
+ raise primary_failure || error, cause: nil
105
106
  end
106
107
  end
107
108
  end
@@ -52,6 +52,8 @@ module Net
52
52
  if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
53
53
  { artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
54
54
  underlying_type: error.underlying_type }
55
+ elsif error.instance_of?(BackupFinalizeError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
56
+ { artifact_phase: :finalize, underlying_type: error.underlying_type }
55
57
  elsif error.instance_of?(TftpCompletionError) && backup.instance_of?(TftpReceipt) && error.receipt.equal?(backup)
56
58
  { artifact_state: :reported_uploaded, artifact_phase: error.code == :transfer_finalize_failed ? :finalize : :path,
57
59
  verification: :device_reported, underlying_type: error.underlying_type }
@@ -246,14 +246,20 @@ module Net
246
246
 
247
247
  # 执行单台设备的本地配置采集。
248
248
  def backup_device(device, directory, log_directory, settings_snapshot, filename_style: :ip)
249
+ outcome = nil
249
250
  destination = File.join(directory, device.backup_filename(style: filename_style))
250
251
  Storage::BackupLock.synchronize(destination, host: device.host) do |path_lock|
251
252
  Storage::SafeFile.open(destination, missing: true) { |_file, _stat| nil }
252
- run_device(device, log_directory, settings_snapshot, success_status: :backed_up,
253
- partial_status: :saved_with_error, backup_class: Backup) do |connector|
253
+ outcome = run_device(device, log_directory, settings_snapshot, success_status: :backed_up,
254
+ partial_status: :saved_with_error, backup_class: Backup) do |connector|
254
255
  path_lock.with_delegated_lock { connector.backup(path: destination) }
255
256
  end
256
257
  end
258
+ rescue StandardError => error
259
+ raise unless outcome
260
+ return outcome unless outcome.success?
261
+
262
+ build_outcome(device, :saved_with_error, backup: outcome.backup, error: error)
257
263
  end
258
264
 
259
265
  # 隔离单台设备的凭据、连接、操作和关闭异常。
@@ -290,7 +296,8 @@ module Net
290
296
  result
291
297
  end
292
298
  if failure && backup.nil?
293
- if backup_class == Backup && failure.instance_of?(BackupPersistenceError) && failure.backup.instance_of?(Backup)
299
+ if backup_class == Backup && [BackupPersistenceError, BackupFinalizeError].any? { |type| failure.instance_of?(type) } &&
300
+ failure.backup.instance_of?(Backup)
294
301
  backup = failure.backup
295
302
  elsif backup_class == TftpReceipt && failure.instance_of?(TftpCompletionError) && failure.receipt.instance_of?(TftpReceipt)
296
303
  backup = failure.receipt
@@ -111,7 +111,7 @@ module Net
111
111
  return nil if stat.size > @max_inventory_bytes
112
112
 
113
113
  body = file.read(@max_inventory_bytes + 1)
114
- return nil if body.bytesize > @max_inventory_bytes
114
+ return nil if body.nil? || body.bytesize > @max_inventory_bytes
115
115
 
116
116
  entry = JSON.parse(body)
117
117
  unless entry.is_a?(Hash) && entry.keys.sort == %w[fetched_at identity rows schema] &&
@@ -161,15 +161,21 @@ module Net
161
161
 
162
162
  bytes = 0
163
163
  rows.map do |row|
164
- unless row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].strip.empty? &&
165
- Client::FIELDS.all? { |field| row[field].nil? || row[field].is_a?(String) }
164
+ unless row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].strip.empty?
166
165
  raise InvalidCache
167
166
  end
168
- selected = row.slice(*Client::FIELDS)
169
- bytes += selected.sum { |key, value| key.bytesize + (value&.bytesize || 0) }
170
- raise InvalidCache if bytes > @max_inventory_bytes
167
+ # 一次遍历完成白名单、限额与复制,避免中间 Hash 和逐字段求和产生的数组。
168
+ Client::FIELDS.each_with_object({}) do |field, selected|
169
+ next unless row.key?(field)
171
170
 
172
- selected.transform_values { |value| value&.dup&.freeze }.freeze
171
+ value = row[field]
172
+ raise InvalidCache unless value.nil? || value.is_a?(String)
173
+
174
+ bytes += field.bytesize + (value&.bytesize || 0)
175
+ raise InvalidCache if bytes > @max_inventory_bytes
176
+
177
+ selected[field] = value&.dup&.freeze
178
+ end.freeze
173
179
  end.freeze
174
180
  end
175
181
 
@@ -55,6 +55,7 @@ module Net
55
55
 
56
56
  # 上传与归档在同一目标锁内完成,后续失败仍保留可确认的上传回执和提交路径。
57
57
  def upload_and_archive(device, started_at:)
58
+ outcome = nil
58
59
  raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
59
60
 
60
61
  remote_filename = upload_filename(device)
@@ -66,14 +67,22 @@ module Net
66
67
  upload_started_at = server_timestamp
67
68
  outcome = yield remote_filename
68
69
  receipt = outcome.backup
69
- return outcome unless receipt.is_a?(TftpReceipt) && receipt.path
70
+ next outcome unless receipt.is_a?(TftpReceipt) && receipt.path
70
71
 
71
72
  verified = @verifier.verify_outcome(outcome.with(started_at: started_at), started_at: upload_started_at,
72
73
  previous: previous, expected_path: remote_filename)
73
- return outcome.success? ? archive_failure(outcome) : outcome unless verified.backup.verification == :server_verified
74
+ unless verified.backup.verification == :server_verified
75
+ outcome = outcome.success? ? archive_failure(outcome) : outcome
76
+ next outcome
77
+ end
74
78
 
75
- archive_uploaded_file(verified)
79
+ outcome = archive_uploaded_file(verified)
76
80
  end
81
+ rescue StandardError => error
82
+ raise unless outcome
83
+
84
+ # 目标锁的关闭发生在块返回之后;保存最新回执,不能丢失已完成的归档路径。
85
+ outcome.success? ? archive_failure(outcome, persistence_error: error) : outcome
77
86
  end
78
87
 
79
88
  private
@@ -46,17 +46,43 @@ module Net
46
46
  return yield self
47
47
  end
48
48
 
49
- file = nil
49
+ file = acquisition_error = nil
50
50
  begin
51
- file = open_lock
51
+ # 文件取得与所有权登记不可拆开;竞争锁和业务块仍立即响应中断。
52
+ Thread.handle_interrupt(Exception => :never) do
53
+ begin
54
+ file = open_lock
55
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 取得失败先于边界退出时递送的中断。
56
+ acquisition_error = error
57
+ end
58
+ end
59
+ raise acquisition_error, cause: nil if acquisition_error
60
+
52
61
  verify_lock!(file)
53
62
  acquire(file)
54
63
  verify_lock!(file)
55
64
  @owner = [Process.pid, Fiber.current]
56
65
  yield self
66
+ rescue Exception # rubocop:disable Lint/RescueException -- 解锁收尾保留首个操作故障或中断。
67
+ failed = true
68
+ raise acquisition_error, cause: nil if acquisition_error
69
+
70
+ raise
57
71
  ensure
58
72
  @owner = nil
59
- file&.close
73
+ cleanup_error = nil
74
+ begin
75
+ Thread.handle_interrupt(Exception => :never) do
76
+ begin
77
+ file&.close
78
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 在递送待处理中断前登记关闭故障。
79
+ cleanup_error = error
80
+ end
81
+ end
82
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 待处理中断不能覆盖已登记的关闭故障。
83
+ cleanup_error ||= error
84
+ end
85
+ raise cleanup_error, cause: nil if cleanup_error && !failed
60
86
  end
61
87
  end
62
88
 
@@ -10,17 +10,43 @@ module Net
10
10
  Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
11
11
 
12
12
  def self.open(path, missing: false, replace_symlink: false)
13
- file = open_handle(path, missing: missing, replace_symlink: replace_symlink)
14
- return nil unless file
15
-
13
+ file = acquisition_error = nil
16
14
  begin
15
+ # 取得描述符与登记所有权共用短暂边界;读取和业务块仍可中断。
16
+ Thread.handle_interrupt(Exception => :never) do
17
+ begin
18
+ file = open_handle(path, missing: missing, replace_symlink: replace_symlink)
19
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 取得失败先于边界退出时递送的中断。
20
+ acquisition_error = error
21
+ end
22
+ end
23
+ raise acquisition_error, cause: nil if acquisition_error
24
+ return nil unless file
25
+
17
26
  stat = file.stat
18
27
  raise ArgumentError, "saved configuration is not a regular file" unless stat.file?
19
28
 
20
29
  file.binmode
21
30
  yield file, stat
31
+ rescue Exception # rubocop:disable Lint/RescueException -- 收尾不能覆盖读取故障或用户中断。
32
+ failed = true
33
+ raise acquisition_error, cause: nil if acquisition_error
34
+
35
+ raise
22
36
  ensure
23
- file.close
37
+ cleanup_error = nil
38
+ begin
39
+ Thread.handle_interrupt(Exception => :never) do
40
+ begin
41
+ file&.close
42
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 在递送待处理中断前登记关闭故障。
43
+ cleanup_error = error
44
+ end
45
+ end
46
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 待处理中断不能覆盖已登记的关闭故障。
47
+ cleanup_error ||= error
48
+ end
49
+ raise cleanup_error, cause: nil if cleanup_error && !failed
24
50
  end
25
51
  end
26
52
 
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.10.0"
6
+ VERSION = "0.10.2"
7
7
  end
8
8
  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.10.0
4
+ version: 0.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-11 00:00:00.000000000 Z
10
+ date: 2026-10-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: expect-pty