net-connector 0.4.1 → 0.4.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +61 -9
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -6
  7. data/docs/architecture.md +184 -7
  8. data/lib/net/connector/device/base.rb +11 -4
  9. data/lib/net/connector/device/running_config.rb +2 -0
  10. data/lib/net/connector/engine/command.rb +14 -2
  11. data/lib/net/connector/engine/configuration.rb +10 -7
  12. data/lib/net/connector/engine/dialogue.rb +52 -28
  13. data/lib/net/connector/engine/errors.rb +40 -55
  14. data/lib/net/connector/engine/execution.rb +32 -5
  15. data/lib/net/connector/engine/log.rb +11 -9
  16. data/lib/net/connector/engine/session.rb +38 -7
  17. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  18. data/lib/net/connector/netdisco/batch.rb +31 -3
  19. data/lib/net/connector/netdisco/cli.rb +73 -31
  20. data/lib/net/connector/netdisco/client.rb +206 -73
  21. data/lib/net/connector/netdisco/config_file.rb +26 -4
  22. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  23. data/lib/net/connector/netdisco/fleet.rb +103 -60
  24. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  25. data/lib/net/connector/netdisco/report.rb +94 -0
  26. data/lib/net/connector/netdisco/rules.rb +28 -5
  27. data/lib/net/connector/netdisco/settings.rb +194 -85
  28. data/lib/net/connector/netdisco/worker.rb +26 -17
  29. data/lib/net/connector/operations/backup_lock.rb +116 -0
  30. data/lib/net/connector/operations/local_backup.rb +49 -9
  31. data/lib/net/connector/operations/parse_output.rb +20 -3
  32. data/lib/net/connector/operations/private_file.rb +94 -5
  33. data/lib/net/connector/operations/safe_file.rb +62 -0
  34. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  35. data/lib/net/connector/operations/saved_config.rb +40 -10
  36. data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
  37. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  38. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  39. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  40. data/lib/net/connector/operations/topology/strategy.rb +20 -0
  41. data/lib/net/connector/operations/topology.rb +97 -29
  42. data/lib/net/connector/operations.rb +6 -0
  43. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  44. data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
  45. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  46. data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
  47. data/lib/net/connector/vendor/h3c/topology.rb +9 -4
  48. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
  49. data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
  50. data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
  51. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
  52. data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
  53. data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
  54. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  55. data/lib/net/connector/version.rb +1 -1
  56. metadata +35 -5
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "expect/redactor"
4
+
3
5
  module Net
4
6
  module Connector
5
- # 操作错误携带已脱敏上下文;原始配置只存在于明确的结果或日志中。
7
+ # 操作错误携带已脱敏上下文;敏感配置正文只存在于明确返回的业务结果中。
6
8
  class Error < StandardError
7
9
  attr_reader :code, :host, :phase, :command, :output, :source, :line, :underlying
8
10
 
@@ -48,6 +50,8 @@ module Net
48
50
 
49
51
  class OutputLimitExceeded < Error; end
50
52
 
53
+ class ScriptOutputLimitExceeded < OutputLimitExceeded; end
54
+
51
55
  class SessionBusy < Error; end
52
56
 
53
57
  class UnsupportedOperation < Error; end
@@ -72,10 +76,16 @@ module Net
72
76
  def inspect = "#<#{self.class} type=#{type}>"
73
77
  end
74
78
 
75
- # 每个会话独立保存脱敏词,包括配置凭据和动态认证响应。
79
+ # 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
76
80
  class Redactor
81
+ MASK = "[REDACTED]".b.freeze
82
+
77
83
  # 保存初始凭据,并建立第一版脱敏词表。
78
84
  def initialize(*secrets)
85
+ unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
86
+ raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
87
+ end
88
+
79
89
  @configured = secrets.compact.map(&:b).map(&:freeze).freeze
80
90
  reset
81
91
  end
@@ -83,9 +93,10 @@ module Net
83
93
  # 重置动态词表,同时保留配置中的固定凭据。
84
94
  def reset
85
95
  @secrets = []
86
- @pattern = nil
96
+ @patterns = nil
87
97
  @scoped = false
88
98
  @sensitive = false
99
+ @output_sensitive = false
89
100
  @configured.each { |secret| remember(secret) }
90
101
  end
91
102
 
@@ -94,18 +105,20 @@ module Net
94
105
  return yield if reuse && @scoped
95
106
 
96
107
  previous = @secrets
97
- previous_pattern = @pattern
108
+ previous_patterns = @patterns
98
109
  previous_scoped = @scoped
99
110
  previous_sensitive = @sensitive
111
+ previous_output_sensitive = @output_sensitive
100
112
  @secrets = previous.dup
101
113
  @scoped = true
102
114
  begin
103
115
  yield
104
116
  ensure
105
117
  @secrets = previous
106
- @pattern = previous_pattern
118
+ @patterns = previous_patterns
107
119
  @scoped = previous_scoped
108
120
  @sensitive = previous_sensitive
121
+ @output_sensitive = previous_output_sensitive
109
122
  end
110
123
  end
111
124
 
@@ -115,7 +128,16 @@ module Net
115
128
  # 判断当前脱敏范围是否包含敏感交互。
116
129
  def sensitive? = @sensitive
117
130
 
118
- # 记住新的敏感字节,并按长度排序避免短词先匹配。
131
+ # 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
132
+ def output_sensitive!
133
+ @output_sensitive = true
134
+ sensitive!
135
+ end
136
+
137
+ # 后续查询继承输出边界,即使查询命令本身没有敏感标记。
138
+ def output_sensitive? = @output_sensitive
139
+
140
+ # 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
119
141
  def remember(secret)
120
142
  return if secret.nil? || secret.empty?
121
143
 
@@ -123,63 +145,26 @@ module Net
123
145
  return if @secrets.include?(bytes)
124
146
 
125
147
  @secrets << bytes.freeze
126
- @secrets.sort_by! { |value| -value.bytesize }
127
- @pattern = nil
128
- end
129
-
130
- # 保留可能跨下一分片的尾部;完整匹配始终作为整体脱敏。
131
- def stream_chunk(bytes, final: false)
132
- return [call(bytes), "".b] if final || @secrets.empty?
133
-
134
- retained = [@secrets.first.bytesize, "[REDACTED]".bytesize].max - 1
135
- boundary = [bytes.bytesize - retained, 0].max
136
- matches(bytes).each do |start, finish|
137
- if start < boundary && finish > boundary
138
- boundary = start
139
- break
140
- end
141
- end
142
- [call(bytes.byteslice(0, boundary)), bytes.byteslice(boundary..)]
148
+ @patterns = nil
143
149
  end
144
150
 
145
- # 单次扫描替换所有敏感词,并保留已有脱敏标记。
151
+ # 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
146
152
  def call(text)
147
- text = text.to_s.b
148
- return text if @secrets.empty?
149
-
150
- output = +"".b
151
- offset = 0
152
- matches(text).each do |start, finish|
153
- output << text.byteslice(offset, start - offset) << "[REDACTED]"
154
- offset = finish
155
- end
156
- output << text.byteslice(offset..)
153
+ Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
157
154
  end
158
155
 
159
- # 返回脱敏器类型摘要。
160
- def inspect = "#<#{self.class}>"
161
-
162
- private
163
-
164
- # 前瞻保留重叠匹配;跨标记边界的真实秘密不能被已有标记遮蔽。
165
- def pattern
166
- @pattern ||= /(?=(#{Regexp.union((@secrets + ["[REDACTED]"]).uniq.sort_by { |secret| -secret.bytesize })}))/n
156
+ # 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
157
+ def stream
158
+ Expect::Redactor.new(patterns, replacement: MASK)
167
159
  end
168
160
 
169
- # 合并重叠的秘密匹配区间,防止替换顺序露出部分凭据。
170
- def matches(text)
171
- ranges = []
172
- text.to_enum(:scan, pattern).each do
173
- match = Regexp.last_match
174
- start, finish = match.begin(1), match.end(1)
175
- if ranges.last && start < ranges.last.last
176
- ranges.last[1] = [ranges.last.last, finish].max
177
- else
178
- ranges << [start, finish]
179
- end
180
- end
181
- ranges
161
+ # 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
162
+ def patterns
163
+ @patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
182
164
  end
165
+
166
+ # 返回脱敏器类型摘要。
167
+ def inspect = "#<#{self.class}>"
183
168
  end
184
169
  end
185
170
  end
@@ -18,6 +18,8 @@ module Net
18
18
  @prompt = prompt
19
19
  @context = {}
20
20
  @steps = []
21
+ @output_bytes = 0
22
+ @output_limit = session.configuration.max_script_output_bytes
21
23
  end
22
24
 
23
25
  # 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
@@ -30,24 +32,38 @@ module Net
30
32
 
31
33
  @current_command = command
32
34
  started = Expect.monotonic
33
- response = query(command)
34
- step = CommandResult.new(command: command, output: response.output, prompt: response.prompt,
35
- duration: Expect.monotonic - started)
36
- steps << step
35
+ step = nil
36
+ response = query(command) do |received|
37
+ step = CommandResult.new(command: command, output: received.output, prompt: received.prompt,
38
+ duration: Expect.monotonic - started)
39
+ steps << step
40
+ end
37
41
  # 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
38
42
  @after_command.call(command, response, self)
39
43
  yield step if block_given?
44
+ check_output_budget!(@last_query_command, completed: true)
40
45
  rescue => error
41
46
  raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
42
47
  end
43
48
  end
49
+ check_output_budget!(@last_query_command, completed: true)
44
50
  Result.new(steps: steps)
45
51
  end
46
52
 
47
53
  # 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
48
54
  def query(command)
49
55
  command = Command.new(command) unless command.is_a?(Command)
50
- @session.exchange(command, timeout: @command_timeout, prompt: @prompt&.call(command))
56
+ check_output_budget!(command, completed: false)
57
+ prompt = @prompt&.call(command)
58
+ # 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
59
+ check_output_budget!(command, completed: false) if @prompt
60
+ response = @session.exchange(command, timeout: @command_timeout, prompt: prompt)
61
+ @last_query_command = command
62
+ @output_bytes += response.raw.bytesize
63
+ # 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
64
+ yield response if block_given?
65
+ check_output_budget!(command, completed: true)
66
+ response
51
67
  end
52
68
 
53
69
  # 只有当前会话未特权时才执行特权认证。
@@ -59,6 +75,17 @@ module Net
59
75
  def failure(message)
60
76
  @session.error(ScriptError, message, phase: :script, command: @current_command)
61
77
  end
78
+
79
+ private
80
+
81
+ # 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
82
+ def check_output_budget!(command, completed:)
83
+ return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @output_limit)
84
+
85
+ raise @session.error(ScriptOutputLimitExceeded,
86
+ "script output reached max_script_output_bytes; commands already sent may have executed",
87
+ phase: :script, command: command), cause: nil
88
+ end
62
89
  end
63
90
  end
64
91
  end
@@ -89,7 +89,7 @@ module Net
89
89
 
90
90
  # 应用注入的日志器没有终端写入器,改由日志器记录完整回显。
91
91
  def response_output(bytes)
92
- return unless detailed? && !@writer
92
+ return unless detailed? && !@writer && !@redactor.output_sensitive?
93
93
 
94
94
  @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
95
95
  @logger.debug("#{@tag} #{line.chomp}") if ::Logger::DEBUG >= @event_level
@@ -169,18 +169,17 @@ module Net
169
169
 
170
170
  # 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
171
171
  class RedactingWriter
172
- # 保存目标流、脱敏器和等待下一分片的尾部。
172
+ # 保存目标与作用域;每个日志目标独占 expect-pty 的过滤流。
173
173
  def initialize(target, redactor)
174
174
  @target = target
175
175
  @redactor = redactor
176
- @pending = "".b
176
+ @filter = redactor.stream
177
177
  end
178
178
 
179
179
  # 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
180
180
  def write(bytes)
181
- @pending << bytes.b
182
- safe, @pending = @redactor.stream_chunk(@pending)
183
- @target.write(safe)
181
+ @filter.patterns = @redactor.patterns
182
+ @target.write(@filter.append(bytes.b))
184
183
  bytes.bytesize
185
184
  end
186
185
 
@@ -189,11 +188,13 @@ module Net
189
188
 
190
189
  # 在日志结束时脱敏并写出剩余尾部。
191
190
  def finish
192
- safe, @pending = @redactor.stream_chunk(@pending, final: true)
193
- @target.write(safe)
191
+ @filter.patterns = @redactor.patterns
192
+ # 保留旧日志的完整词匹配契约;配置正文由 Session 的敏感范围直接隔离。
193
+ @target.write(@filter.finish(partial: false))
194
194
  flush
195
195
  end
196
196
  end
197
+
197
198
  private_constant :RedactingWriter
198
199
 
199
200
  # 以私有权限打开设备日志文件。
@@ -224,7 +225,8 @@ module Net
224
225
 
225
226
  # 将日志异常包装成统一错误并保留脱敏原因。
226
227
  def failure(message, error)
227
- LogError.new(message, phase: :logging, underlying: UnderlyingError.new(error, @redactor))
228
+ LogError.new(message, phase: :logging,
229
+ underlying: UnderlyingError.new(error, @redactor, sensitive: @redactor.sensitive?))
228
230
  end
229
231
  end
230
232
  end
@@ -10,6 +10,8 @@ module Net
10
10
  module Connector
11
11
  # 连接状态、传输层和日志的唯一所有者;同一时刻只允许一个操作占用会话。
12
12
  class Session
13
+ MAX_ERROR_OUTPUT_BYTES = 4096
14
+
13
15
  attr_reader :configuration, :transport, :reader, :redactor, :state, :prompt
14
16
 
15
17
  # 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
@@ -75,6 +77,13 @@ module Net
75
77
  end
76
78
  end
77
79
 
80
+ # 文件业务必须先取得路径锁,再调用会话;不能持有会话租约去等待另一个备份者。
81
+ def assert_path_lock_order!(phase)
82
+ return unless @lock.locked?
83
+
84
+ raise error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
85
+ end
86
+
78
87
  # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
79
88
  def perform_locked(phase)
80
89
  @performing = true
@@ -140,6 +149,19 @@ module Net
140
149
  end
141
150
  end
142
151
 
152
+ # 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
153
+ # 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
154
+ def output_scope(enabled)
155
+ return yield unless enabled
156
+
157
+ redactor.scope do
158
+ redactor.output_sensitive!
159
+ yield
160
+ rescue => error
161
+ raise normalize_error(error, phase: :script), cause: nil
162
+ end
163
+ end
164
+
143
165
  # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
144
166
  def exchange(command, timeout:, prompt: nil)
145
167
  redactor.scope(reuse: true) do
@@ -147,9 +169,10 @@ module Net
147
169
  started = Expect.monotonic
148
170
  @log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
149
171
  sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
150
- @log.event("device_output", level: :debug) if @log.detailed? && !command.sensitive? && !sensitive_dialogue
172
+ private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
173
+ @log.event("device_output", level: :debug) if @log.detailed? && !private_output
151
174
  response = exchange_command(command, timeout: timeout, prompt: prompt)
152
- @log.response_output(response.raw) unless command.sensitive? || sensitive_dialogue
175
+ @log.response_output(response.raw) unless private_output
153
176
  @log.event("command_complete", status: "response_received")
154
177
  @log.event("command_detail", level: :debug,
155
178
  duration_ms: ((Expect.monotonic - started) * 1000).round,
@@ -165,6 +188,7 @@ module Net
165
188
 
166
189
  # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
167
190
  def protect_command(command)
191
+ redactor.output_sensitive! if command.output_sensitive?
168
192
  if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
169
193
  redactor.sensitive!
170
194
  end
@@ -189,7 +213,7 @@ module Net
189
213
 
190
214
  response
191
215
  end
192
- if command.sensitive? || interactions.any?(&:sensitive?)
216
+ if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
193
217
  @log.pause(&operation)
194
218
  else
195
219
  operation.call
@@ -220,20 +244,20 @@ module Net
220
244
 
221
245
  # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
222
246
  def error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
223
- sensitive = redactor.sensitive? || command&.sensitive?
247
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
224
248
  message = safe_error_text(message, sensitive: sensitive)
225
249
  output = safe_error_text(output, sensitive: sensitive)
226
250
  klass.new(message, host: configuration.host, phase: phase,
227
251
  command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
228
252
  source: command&.source, line: command&.line,
229
- output: output.byteslice(-4096, 4096) || output,
253
+ output: truncate_output(output),
230
254
  underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
231
255
  end
232
256
 
233
257
  # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
234
258
  def normalize_error(exception, phase:, command: nil)
235
259
  if exception.is_a?(Error)
236
- sensitive = redactor.sensitive? || command&.sensitive?
260
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
237
261
  failed_command = exception.command || command&.text
238
262
  if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
239
263
  failed_command = "[REDACTED]"
@@ -245,7 +269,7 @@ module Net
245
269
  host: exception.host || configuration.host, phase: exception.phase || phase,
246
270
  command: failed_command && redactor.call(failed_command),
247
271
  source: exception.source || command&.source, line: exception.line || command&.line,
248
- output: output.byteslice(-4096, 4096) || output,
272
+ output: truncate_output(output),
249
273
  underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
250
274
  )
251
275
  end
@@ -265,6 +289,13 @@ module Net
265
289
 
266
290
  private :safe_error_text
267
291
 
292
+ # 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
293
+ def truncate_output(text)
294
+ text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
295
+ end
296
+
297
+ private :truncate_output
298
+
268
299
  # 返回不包含凭据的会话状态摘要。
269
300
  def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
270
301
 
@@ -8,20 +8,21 @@ module Net
8
8
  # 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
9
9
  class TerminalRenderer
10
10
  # 将字节流完整渲染为字符串,供测试和一次性转换使用。
11
- def self.render(input)
11
+ def self.render(input, strict_utf8: false)
12
12
  output = StringIO.new("".b)
13
- renderer = new(output)
13
+ renderer = new(output, strict_utf8: strict_utf8)
14
14
  renderer.write(input)
15
15
  renderer.finish
16
16
  output.string
17
17
  end
18
18
 
19
19
  # 初始化当前行、光标和转义序列状态。
20
- def initialize(target, max_line_bytes: 32 * 1024 * 1024)
20
+ def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
21
21
  raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
22
22
  unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
23
23
  raise ArgumentError, "max_line_bytes must be positive"
24
24
  end
25
+ raise ArgumentError, "strict_utf8 must be true or false" unless [true, false].include?(strict_utf8)
25
26
 
26
27
  @target = target
27
28
  @line = "".b
@@ -29,6 +30,7 @@ module Net
29
30
  @escape_state = nil
30
31
  @csi_parameters = "".b
31
32
  @max_line_bytes = max_line_bytes
33
+ @strict_utf8 = strict_utf8
32
34
  end
33
35
 
34
36
  # 逐字节消费终端输出,并返回输入字节数。
@@ -146,10 +148,11 @@ module Net
146
148
  @cursor = 0
147
149
  end
148
150
 
149
- # 尽量保持有效 UTF-8;非法字节转义为可读的十六进制文本。
151
+ # 日志把非法字节转义;解析必须拒绝终端编辑产生的损坏字节,不能制造可写业务证据。
150
152
  def utf8_safe(line)
151
153
  text = line.dup.force_encoding(Encoding::UTF_8)
152
154
  return text.b if text.valid_encoding?
155
+ raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
153
156
 
154
157
  text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
155
158
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "diagnostic"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -8,9 +9,29 @@ module Net
8
9
  # 单台设备的最终结果,保留状态、配置产物和错误类型。
9
10
  Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
10
11
  :started_at, :finished_at) do
12
+ attr_reader :diagnostic
13
+
11
14
  # 创建结果,允许计划阶段尚无执行时间。
12
- def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil)
13
- super
15
+ def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil,
16
+ duration_ms: nil, diagnostic: nil)
17
+ unless duration_ms.nil? || (duration_ms.is_a?(Numeric) && duration_ms.real? && duration_ms.finite? && duration_ms >= 0)
18
+ raise ArgumentError, "duration_ms must be nonnegative and finite"
19
+ end
20
+ raise ArgumentError, "diagnostic must be a Diagnostic" unless diagnostic.nil? || diagnostic.instance_of?(Diagnostic)
21
+
22
+ @duration_ms, @diagnostic = duration_ms, diagnostic
23
+ super(device: device, status: status, backup: backup, error_code: error_code, error_type: error_type,
24
+ started_at: started_at, finished_at: finished_at)
25
+ end
26
+
27
+ # Data 成员保持不变;Worker 加上时间、旧文件迁移调整产物时仍保留内部元数据。
28
+ def with(**attributes)
29
+ return self if attributes.empty?
30
+
31
+ metadata = { duration_ms: @duration_ms, diagnostic: diagnostic }
32
+ metadata[:duration_ms] = nil if attributes.key?(:started_at) || attributes.key?(:finished_at)
33
+ metadata[:diagnostic] = nil if attributes.key?(:error_code) || attributes.key?(:error_type)
34
+ self.class.new(**to_h, **metadata.merge(attributes))
14
35
  end
15
36
 
16
37
  # 判断设备是否完成配置保存或上报上传成功。
@@ -24,7 +45,9 @@ module Net
24
45
 
25
46
  # 计算设备任务的耗时,计划阶段没有时间时返回空值。
26
47
  def duration_ms
27
- ((finished_at - started_at) * 1000).round if started_at && finished_at
48
+ return @duration_ms unless @duration_ms.nil?
49
+
50
+ [((finished_at - started_at) * 1000).round, 0].max if started_at && finished_at
28
51
  end
29
52
  end
30
53
 
@@ -40,6 +63,9 @@ module Net
40
63
  # 空清单与已尝试但未全部成功的批次分别标记。
41
64
  def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
42
65
 
66
+ # 单独请求 v2 包装,不改变旧 Data 的成员、解构或默认 summary。
67
+ def report(policy: :strict) = Report.new(self, policy: policy)
68
+
43
69
  # 生成可写入报告及供命令行展示的结构化摘要。
44
70
  def summary
45
71
  {
@@ -66,6 +92,8 @@ module Net
66
92
  }
67
93
  end
68
94
  end
95
+
96
+ autoload :Report, File.expand_path("report", __dir__)
69
97
  end
70
98
  end
71
99
  end