net-connector 0.4.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -68
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +140 -95
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -5
  7. data/docs/architecture.md +321 -248
  8. data/lib/net/connector/device/base.rb +12 -4
  9. data/lib/net/connector/device/interface_description.rb +3 -0
  10. data/lib/net/connector/device/profile/builder.rb +236 -0
  11. data/lib/net/connector/device/profile.rb +2 -227
  12. data/lib/net/connector/device/running_config/strategy.rb +5 -0
  13. data/lib/net/connector/device/running_config.rb +7 -0
  14. data/lib/net/connector/engine/command.rb +14 -2
  15. data/lib/net/connector/engine/configuration.rb +10 -7
  16. data/lib/net/connector/engine/dialogue.rb +53 -28
  17. data/lib/net/connector/engine/errors.rb +41 -54
  18. data/lib/net/connector/engine/execution.rb +32 -5
  19. data/lib/net/connector/engine/log.rb +23 -17
  20. data/lib/net/connector/engine/session.rb +39 -7
  21. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  22. data/lib/net/connector/netdisco/batch.rb +31 -3
  23. data/lib/net/connector/netdisco/cli.rb +92 -50
  24. data/lib/net/connector/netdisco/client.rb +206 -73
  25. data/lib/net/connector/netdisco/config_file.rb +26 -4
  26. data/lib/net/connector/netdisco/device.rb +4 -8
  27. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  28. data/lib/net/connector/netdisco/fleet.rb +105 -108
  29. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  30. data/lib/net/connector/netdisco/plan.rb +77 -0
  31. data/lib/net/connector/netdisco/planner.rb +12 -23
  32. data/lib/net/connector/netdisco/report.rb +94 -0
  33. data/lib/net/connector/netdisco/rules.rb +28 -5
  34. data/lib/net/connector/netdisco/settings.rb +194 -85
  35. data/lib/net/connector/netdisco/worker.rb +43 -27
  36. data/lib/net/connector/operations/backup_lock.rb +116 -0
  37. data/lib/net/connector/operations/local_backup.rb +49 -9
  38. data/lib/net/connector/operations/parse_output.rb +20 -3
  39. data/lib/net/connector/operations/private_file.rb +94 -5
  40. data/lib/net/connector/operations/safe_file.rb +62 -0
  41. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  42. data/lib/net/connector/operations/saved_config.rb +40 -10
  43. data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
  44. data/lib/net/connector/operations/tftp/strategy.rb +9 -11
  45. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  46. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  47. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  48. data/lib/net/connector/operations/topology/strategy.rb +34 -0
  49. data/lib/net/connector/operations/topology.rb +98 -29
  50. data/lib/net/connector/operations.rb +6 -0
  51. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  52. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  53. data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
  54. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
  55. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  56. data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
  57. data/lib/net/connector/vendor/h3c/topology.rb +17 -3
  58. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  59. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
  60. data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
  61. data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
  62. data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
  63. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
  64. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
  65. data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
  66. data/lib/net/connector/vendor/radware/topology.rb +3 -0
  67. data/lib/net/connector/version.rb +1 -1
  68. metadata +60 -28
@@ -21,37 +21,29 @@ module Net
21
21
  worker_count = [tasks.size, @concurrency].min
22
22
  worker_count.times { queue << nil }
23
23
  callback_errors = Queue.new
24
- threads = Array.new(worker_count) do
25
- Thread.new do
26
- Thread.current.report_on_exception = false
27
- while (task = queue.pop)
28
- index, device = task
29
- started_at = Time.now.utc
30
- begin
31
- on_start&.call(device)
32
- rescue StandardError => error
33
- callback_errors << { host: device.host, error_type: error.class.name }.freeze
34
- end
35
- result = begin
36
- yield device
37
- rescue StandardError => error
38
- on_error.call(device, error)
39
- end
40
- result = result.with(started_at: started_at, finished_at: Time.now.utc)
41
- outcomes[index] = result
42
- Array(on_result).each do |callback|
43
- begin
44
- callback.call(result)
45
- rescue StandardError => error
46
- callback_errors << { host: device.host, error_type: error.class.name }.freeze
24
+ finished = Queue.new
25
+ threads = []
26
+ completed = false
27
+ begin
28
+ # 创建线程也属于批次生命周期;后续创建失败时要关闭已有任务。
29
+ worker_count.times do
30
+ threads << Thread.new do
31
+ Thread.current.report_on_exception = false
32
+ while (task = queue.pop)
33
+ index, device = task
34
+ result = run_task(device, on_error, on_start, callback_errors) { yield device }
35
+ outcomes[index] = result
36
+ Array(on_result).each do |callback|
37
+ notify(callback, result, host: device.host, errors: callback_errors)
47
38
  end
48
39
  end
40
+ ensure
41
+ finished << Thread.current
49
42
  end
50
43
  end
51
- end
52
- completed = false
53
- begin
54
- threads.each(&:value)
44
+ # 按完成顺序观察失败,避免后启动线程的中断被前面的慢设备阻塞。
45
+ # 结果仍写回清单槽位,完成通知不会改变公开结果的顺序。
46
+ worker_count.times { finished.pop.value }
55
47
  completed = true
56
48
  ensure
57
49
  # 中断或某个 worker 抛出非 StandardError 时,不让其他设备任务
@@ -69,6 +61,30 @@ module Net
69
61
  end
70
62
  Array.new(callback_errors.size) { callback_errors.pop }.freeze
71
63
  end
64
+
65
+ private
66
+
67
+ # 普通设备故障转换为结果,中断仍交给批次统一停止其余线程。
68
+ def run_task(device, on_error, on_start, callback_errors)
69
+ started_at = Time.now.utc
70
+ started = monotonic
71
+ notify(on_start, device, host: device.host, errors: callback_errors) unless on_start.nil?
72
+ result = begin
73
+ yield
74
+ rescue StandardError => error
75
+ on_error.call(device, error)
76
+ end
77
+ result.with(started_at: started_at, finished_at: Time.now.utc, duration_ms: ((monotonic - started) * 1000).round)
78
+ end
79
+
80
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+
82
+ # 回调故障不覆盖设备结果,也不把异常消息中的凭据写入报告。
83
+ def notify(callback, value, host:, errors:)
84
+ callback.call(value)
85
+ rescue StandardError => error
86
+ errors << { host: host, error_type: error.class.name }.freeze
87
+ end
72
88
  end
73
89
  end
74
90
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../engine/errors"
5
+
6
+ module Net
7
+ module Connector
8
+ class BackupBusy < Error; end
9
+
10
+ module Operations
11
+ # 路径锁在设备采集之前取得;锁文件长期保留,避免等待者落在已经删除的 inode 上。
12
+ class BackupLock
13
+ STORAGE_KEY = :net_connector_backup_delegation
14
+ private_constant :STORAGE_KEY
15
+
16
+ attr_reader :path
17
+
18
+ def self.lock_path(path)
19
+ destination = File.expand_path(path)
20
+ directory = File.realpath(File.dirname(destination))
21
+ # 锁名先折叠 Unicode 与大小写,避免大小写不敏感文件系统上的别名绕过互斥。
22
+ # 在区分大小写的文件系统上可能保守地合并两个锁,但不会改写目标文件名。
23
+ filename = File.basename(destination).b.force_encoding(Encoding::UTF_8)
24
+ filename = filename.valid_encoding? ? filename.unicode_normalize(:nfc).downcase(:fold).unicode_normalize(:nfc) : filename.b.downcase
25
+ File.join(directory, ".net-connector-#{Digest::SHA256.hexdigest(filename)}.lock")
26
+ end
27
+
28
+ def self.synchronize(path, **options, &block) = new(path, **options).synchronize(&block)
29
+
30
+ def initialize(path, timeout: 0, host: nil)
31
+ raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
32
+ unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0 && timeout.to_f.finite?
33
+ raise ArgumentError, "lock_timeout must be a nonnegative finite number"
34
+ end
35
+
36
+ @path = File.expand_path(path).freeze
37
+ @lock_path = self.class.lock_path(@path).freeze
38
+ @timeout, @host = timeout.to_f, host
39
+ end
40
+
41
+ def synchronize
42
+ # Fleet 只向下层备份借用一次;消费后清除,采集回调中的递归备份仍须竞争锁。
43
+ # Thread#[] 是 Fiber 局部存储;fork 后不能消费父进程的借用记录。
44
+ if Thread.current[STORAGE_KEY] == [@lock_path, Process.pid]
45
+ Thread.current[STORAGE_KEY] = nil
46
+ return yield self
47
+ end
48
+
49
+ file = nil
50
+ begin
51
+ file = open_lock
52
+ verify_lock!(file)
53
+ acquire(file)
54
+ verify_lock!(file)
55
+ @owner = [Process.pid, Fiber.current]
56
+ yield self
57
+ ensure
58
+ @owner = nil
59
+ file&.close
60
+ end
61
+ end
62
+
63
+ # 仅持有实际 flock 的同一 Fiber 可授权一次内层采集,避免把路径锁变成任意可重入锁。
64
+ def delegate
65
+ raise ThreadError, "backup path delegation requires lock ownership" unless @owner == [Process.pid, Fiber.current]
66
+
67
+ previous = Thread.current[STORAGE_KEY]
68
+ begin
69
+ Thread.current[STORAGE_KEY] = [@lock_path, Process.pid]
70
+ yield
71
+ ensure
72
+ Thread.current[STORAGE_KEY] = previous
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def open_lock
79
+ File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
80
+ rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
81
+ raise ArgumentError, "backup lock must be a private regular file", cause: nil
82
+ end
83
+
84
+ def verify_lock!(file)
85
+ stat = file.stat
86
+ unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
87
+ raise ArgumentError, "backup lock must be an owned regular file with mode 0600 and one link"
88
+ end
89
+ current = File.lstat(@lock_path)
90
+ unless current.file? && current.dev == stat.dev && current.ino == stat.ino
91
+ raise ArgumentError, "backup lock path changed while acquiring ownership"
92
+ end
93
+ rescue Errno::ENOENT
94
+ raise ArgumentError, "backup lock path changed while acquiring ownership", cause: nil
95
+ end
96
+
97
+ def acquire(file)
98
+ deadline = monotonic + @timeout
99
+ loop do
100
+ acquired = file.flock(File::LOCK_EX | File::LOCK_NB)
101
+ return if acquired && (@timeout.zero? || monotonic < deadline)
102
+
103
+ remaining = deadline - monotonic
104
+ if remaining <= 0
105
+ raise BackupBusy.new("backup path already belongs to another operation", host: @host, phase: :backup), cause: nil
106
+ end
107
+ wait([remaining, 0.05].min)
108
+ end
109
+ end
110
+
111
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ def wait(seconds) = sleep(seconds)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "private_file"
5
+ require_relative "backup_lock"
6
+ require_relative "safe_file"
5
7
 
6
8
  module Net
7
9
  module Connector
@@ -15,19 +17,47 @@ module Net
15
17
  def changed? = [:created, :changed].include?(change)
16
18
  end
17
19
 
20
+ # 只携带库自身的提交回执和配置元数据,不携带正文或原始文件系统异常。
21
+ class BackupPersistenceError < Error
22
+ attr_reader :backup, :receipt, :underlying_type
23
+
24
+ def initialize(backup:, write_error:, host: nil)
25
+ unless backup.instance_of?(Backup) && Operations::PrivateFile.receipt_error?(write_error) &&
26
+ write_error.receipt.committed? && write_error.receipt.path == backup.path
27
+ raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
28
+ end
29
+
30
+ @backup, @receipt = backup, write_error.receipt
31
+ @underlying_type = write_error.underlying_type
32
+ code = if write_error.instance_of?(Operations::PrivateFile::DirectorySyncUnsupported)
33
+ :backup_durability_unsupported
34
+ elsif receipt.durable?
35
+ :backup_finalize_failed
36
+ else
37
+ :backup_persistence_unconfirmed
38
+ end
39
+ super(write_error.message, code: code, host: host, phase: :backup)
40
+ end
41
+ end
42
+
18
43
  module Operations
19
44
  class LocalBackup
20
45
  # 保存执行配置采集的设备对象。
21
46
  def initialize(device) = @device = device
22
47
 
23
48
  # 采集配置、识别变更并原子写入私有文件。
24
- def call(path:)
25
- raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
49
+ def call(path:, lock_timeout: 0)
50
+ host = @device.host if @device.respond_to?(:host)
51
+ BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
52
+ end
53
+
54
+ private
26
55
 
56
+ def collect_and_write(destination)
27
57
  contents = @device.running_config.value!
28
- destination = File.expand_path(path)
29
58
  digest = Digest::SHA256.hexdigest(contents)
30
- previous_digest = Digest::SHA256.file(destination).hexdigest if File.file?(destination) && !File.symlink?(destination)
59
+ previous = SafeFile.fingerprint(destination, missing: true, replace_symlink: true)
60
+ previous_digest = previous&.sha256
31
61
  change = if previous_digest.nil?
32
62
  :created
33
63
  elsif previous_digest == digest
@@ -36,12 +66,22 @@ module Net
36
66
  :changed
37
67
  end
38
68
 
39
- if change != :unchanged || (File.stat(destination).mode & 0o777) != 0o600
40
- PrivateFile.write(destination, contents)
69
+ backup = Backup.new(path: destination, bytes: contents.bytesize,
70
+ sha256: digest, collected_at: Time.now.utc, change: change,
71
+ previous_sha256: previous_digest)
72
+ if change != :unchanged || previous.mode != 0o600 || !SafeFile.same_entry?(destination, previous)
73
+ write_backup(backup, contents)
41
74
  end
42
- Backup.new(path: destination, bytes: contents.bytesize,
43
- sha256: digest, collected_at: Time.now.utc, change: change,
44
- previous_sha256: previous_digest)
75
+ backup
76
+ end
77
+
78
+ def write_backup(backup, contents)
79
+ PrivateFile.write_receipt(backup.path, contents)
80
+ rescue PrivateFile::WriteError => error
81
+ raise unless PrivateFile.receipt_error?(error) && error.receipt.committed?
82
+
83
+ host = @device.host if @device.respond_to?(:host)
84
+ raise BackupPersistenceError.new(backup: backup, write_error: error, host: host), cause: nil
45
85
  end
46
86
  end
47
87
  end
@@ -21,11 +21,19 @@ module Net
21
21
  @index = index
22
22
  end
23
23
 
24
- # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
25
- def call(text, template: nil, vendor: nil, command: nil, host: nil)
24
+ # 解析按 UTF-8 字节处理副本,先验证原文,避免回车或转义序列隐藏非法字节。
25
+ # 拓扑选模板和计数也使用这份副本,但仍保留未经终端渲染的完整证据。
26
+ def utf8(text, host: nil)
26
27
  raise ArgumentError, "text must be a String" unless text.is_a?(String)
27
28
 
28
- text = TerminalRenderer.render(text).force_encoding(Encoding::UTF_8)
29
+ text = text.b.force_encoding(Encoding::UTF_8)
30
+ invalid_encoding!(host) unless text.valid_encoding?
31
+ text
32
+ end
33
+
34
+ # 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
35
+ def call(text, template: nil, vendor: nil, command: nil, host: nil)
36
+ text = TerminalRenderer.render(utf8(text, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
29
37
  if template
30
38
  raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
31
39
 
@@ -41,6 +49,8 @@ module Net
41
49
  end
42
50
  table.parse(text, attributes: attributes).to_hashes
43
51
  end
52
+ rescue Encoding::InvalidByteSequenceError
53
+ invalid_encoding!(host)
44
54
  rescue TextFSM::IndexError
45
55
  raise ParsingError.new("TextFSM template index is invalid",
46
56
  code: :template_invalid, host: host, phase: :parse), cause: nil
@@ -54,6 +64,13 @@ module Net
54
64
  raise ParsingError.new("unable to read TextFSM template",
55
65
  code: :template_unreadable, host: host, phase: :parse), cause: nil
56
66
  end
67
+
68
+ private
69
+
70
+ def invalid_encoding!(host)
71
+ raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
72
+ host: host, phase: :parse), cause: nil
73
+ end
57
74
  end
58
75
  end
59
76
  end
@@ -5,19 +5,108 @@ require "tempfile"
5
5
  module Net
6
6
  module Connector
7
7
  module Operations
8
- # 将配置写入同目录的私有临时文件,再原子替换目标文件。
8
+ # 文件同步和目录项同步分别记录;原子可见不等于断电后仍能恢复新目录项。
9
9
  module PrivateFile
10
- # 写入完整内容并同步到磁盘,避免覆盖失败留下半份配置。
10
+ Receipt = Data.define(:path, :state, :phase) do
11
+ def initialize(path:, state:, phase:)
12
+ unless path.is_a?(String) && !path.empty? && %i[not_committed committed durable].include?(state) &&
13
+ %i[resolve_parent temporary_file write file_sync rename directory_open directory_sync cleanup complete].include?(phase)
14
+ raise ArgumentError, "invalid file write receipt"
15
+ end
16
+ super(path: path.dup.freeze, state: state, phase: phase)
17
+ end
18
+
19
+ def committed? = state != :not_committed
20
+ def durable? = state == :durable
21
+ end
22
+
23
+ class WriteError < IOError
24
+ attr_reader :receipt, :code, :underlying_type
25
+
26
+ def initialize(receipt:, underlying_type:)
27
+ raise ArgumentError, "receipt must be a file write receipt" unless receipt.instance_of?(Receipt)
28
+
29
+ @receipt = receipt
30
+ @underlying_type = underlying_type.dup.freeze
31
+ @code, message = if receipt.durable?
32
+ [:file_finalize_failed, "file replacement is durable; finalization failed"]
33
+ elsif receipt.committed?
34
+ [:file_persistence_unconfirmed, "file replacement is committed; durability is unconfirmed"]
35
+ else
36
+ [:file_write_failed, "file write failed before replacement was committed"]
37
+ end
38
+ super(message)
39
+ end
40
+
41
+ def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
42
+ end
43
+
44
+ class PersistenceError < WriteError; end
45
+
46
+ class DirectorySyncUnsupported < WriteError
47
+ def initialize(**options)
48
+ super
49
+ @code = :directory_sync_unsupported
50
+ end
51
+
52
+ def message = "file replacement is committed; directory synchronization is unsupported"
53
+ alias to_s message
54
+ end
55
+
56
+ UNSUPPORTED_DIRECTORY_SYNC = [Errno::EINVAL, Errno::ENOSYS, Errno::ENOTSUP, Errno::EOPNOTSUPP,
57
+ NotImplementedError].uniq.freeze
58
+ private_constant :UNSUPPORTED_DIRECTORY_SYNC
59
+
60
+ # 外部子类可重写消息或回执;只有本写入器定义的具体错误类型可提供完成事实。
61
+ def self.receipt_error?(error)
62
+ [WriteError, PersistenceError, DirectorySyncUnsupported].any? { |type| error.instance_of?(type) }
63
+ end
64
+
65
+ # 保留原路径返回值;失败时可从异常 receipt 判断目录项是否已经替换。
11
66
  def self.write(path, contents)
12
- Tempfile.create([".net-connector-", ".tmp"], File.dirname(path)) do |file|
67
+ write_receipt(path, contents)
68
+ path
69
+ end
70
+
71
+ def self.write_receipt(path, contents)
72
+ raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
73
+
74
+ receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
75
+ directory = File.realpath(File.dirname(receipt.path))
76
+ destination = File.join(directory, File.basename(receipt.path))
77
+ receipt = receipt.with(phase: :temporary_file)
78
+ Tempfile.create([".net-connector-", ".tmp"], directory) do |file|
13
79
  file.binmode
14
80
  file.chmod(0o600)
81
+ receipt = receipt.with(phase: :write)
15
82
  file.write(contents)
83
+ receipt = receipt.with(phase: :file_sync)
16
84
  file.flush
17
85
  file.fsync
18
- File.rename(file.path, path)
86
+ receipt = receipt.with(phase: :rename)
87
+ File.rename(file.path, destination)
88
+ receipt = receipt.with(state: :committed, phase: :directory_open)
89
+ File.open(directory, File::RDONLY | File::NOFOLLOW) do |parent|
90
+ raise IOError, "parent is not a directory" unless parent.stat.directory?
91
+
92
+ receipt = receipt.with(phase: :directory_sync)
93
+ parent.fsync
94
+ receipt = receipt.with(state: :durable, phase: :cleanup)
95
+ end
19
96
  end
20
- path
97
+ receipt.with(phase: :complete)
98
+ rescue StandardError, NotImplementedError => error
99
+ raise unless receipt
100
+
101
+ unsupported = receipt.phase == :directory_sync && UNSUPPORTED_DIRECTORY_SYNC.any? { |type| error.is_a?(type) }
102
+ klass = if unsupported
103
+ DirectorySyncUnsupported
104
+ elsif receipt.committed?
105
+ PersistenceError
106
+ else
107
+ WriteError
108
+ end
109
+ raise klass.new(receipt: receipt, underlying_type: error.class.name.to_s), cause: nil
21
110
  end
22
111
  end
23
112
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Net
6
+ module Connector
7
+ module Operations
8
+ # 对同一 FD 做类型检查和读取;NOFOLLOW 只保护最后一个路径分量,目录由调用方保护。
9
+ module SafeFile
10
+ Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
11
+
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
+
16
+ begin
17
+ stat = file.stat
18
+ raise ArgumentError, "saved configuration is not a regular file" unless stat.file?
19
+
20
+ file.binmode
21
+ yield file, stat
22
+ ensure
23
+ file.close
24
+ end
25
+ end
26
+
27
+ def self.fingerprint(path, **options)
28
+ self.open(path, **options) { |file, stat| fingerprint_io(path, file, stat) }
29
+ end
30
+
31
+ def self.fingerprint_io(path, file, stat)
32
+ digest = Digest::SHA256.new
33
+ while (chunk = file.read(64 * 1024))
34
+ digest << chunk
35
+ end
36
+ Fingerprint.new(path: path.dup.freeze, sha256: digest.hexdigest.freeze,
37
+ mode: stat.mode & 0o7777, dev: stat.dev, ino: stat.ino)
38
+ end
39
+
40
+ # 未变内容可保留 mtime,但不能把已替换的目录项误认成刚读取的旧文件。
41
+ def self.same_entry?(path, fingerprint)
42
+ stat = File.lstat(path)
43
+ stat.file? && stat.dev == fingerprint.dev && stat.ino == fingerprint.ino
44
+ rescue Errno::ENOENT
45
+ false
46
+ end
47
+
48
+ def self.open_handle(path, missing:, replace_symlink:)
49
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
50
+ rescue Errno::ENOENT
51
+ raise unless missing
52
+ rescue Errno::ELOOP
53
+ raise ArgumentError, "saved configuration is not a regular file", cause: nil unless replace_symlink
54
+ rescue Errno::ENXIO
55
+ raise ArgumentError, "saved configuration is not a regular file", cause: nil
56
+ end
57
+
58
+ private_class_method :open_handle
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../engine/errors"
4
+ require_relative "../safe_file"
5
+
6
+ module Net
7
+ module Connector
8
+ class SavedConfigChanged < Error; end
9
+
10
+ module Operations
11
+ class SavedConfig
12
+ # 每批只发布一份只读目录快照。记录文件身份而不读取正文;不跨批次缓存。
13
+ class LegacyIndex
14
+ Entry = Data.define(:name, :identity, :regular)
15
+ EMPTY = [].freeze
16
+
17
+ def initialize(directory)
18
+ @directory = directory
19
+ @mutex = Mutex.new
20
+ @entries = nil
21
+ @failed = false
22
+ end
23
+
24
+ def candidates(filename)
25
+ @mutex.synchronize do
26
+ raise IOError, "unable to index saved configurations", cause: nil if @failed
27
+
28
+ @entries ||= snapshot
29
+ @entries.fetch(filename, EMPTY)
30
+ rescue IOError, SystemCallError
31
+ @failed = true
32
+ raise IOError, "unable to index saved configurations", cause: nil
33
+ end
34
+ end
35
+
36
+ # 目录快照不是锁:同一 FD 读取前后及最终目录项都必须仍匹配快照。
37
+ # 非合作写入者仍可在检查之后修改文件,调用方必须保护整个备份目录。
38
+ def open(entry)
39
+ path = File.join(@directory, entry.name)
40
+ verify!(entry, File.lstat(path))
41
+ SafeFile.open(path) do |file, stat|
42
+ verify!(entry, stat)
43
+ value = yield path, file, stat
44
+ verify!(entry, file.stat)
45
+ verify!(entry, File.lstat(path))
46
+ value
47
+ end
48
+ rescue IOError, SystemCallError
49
+ changed!
50
+ rescue ArgumentError
51
+ raise unless entry.regular
52
+
53
+ changed!
54
+ end
55
+
56
+ private
57
+
58
+ def snapshot
59
+ entries = {}
60
+ Dir.children(@directory).each do |name|
61
+ filename = canonical_suffix(name)
62
+ next unless filename
63
+
64
+ (entries[filename] ||= []) << snapshot_entry(name)
65
+ end
66
+ entries.each_value(&:freeze)
67
+ entries.freeze
68
+ end
69
+
70
+ # 下划线仅还原地址部分,IPv6 zone 中的下划线保持原义;合法性仍由 IPAddr 判断。
71
+ def canonical_suffix(name)
72
+ suffix = name.b[/-([0-9a-f_.]+(?:%[a-z0-9_.-]+)?)\.txt\z/in, 1]
73
+ return unless suffix
74
+
75
+ address, zone = suffix.split("%", 2)
76
+ host = address.tr("_", ":")
77
+ host += "%#{zone}" if zone
78
+ SavedConfig.filename(host).freeze
79
+ rescue ArgumentError
80
+ nil
81
+ end
82
+
83
+ def snapshot_entry(name)
84
+ stat = File.lstat(File.join(@directory, name))
85
+ Entry.new(name: name.freeze, identity: identity(stat), regular: stat.file?)
86
+ rescue SystemCallError
87
+ # 列表与 lstat 之间消失的候选仍占据该身份,不能把后来出现的文件当作旧快照。
88
+ Entry.new(name: name.freeze, identity: nil, regular: false)
89
+ end
90
+
91
+ def identity(stat)
92
+ [stat.dev, stat.ino, stat.mode, stat.size, stat.mtime.to_r, stat.ctime.to_r].freeze
93
+ end
94
+
95
+ def verify!(entry, stat)
96
+ changed! unless entry.identity == identity(stat)
97
+ end
98
+
99
+ def changed!
100
+ raise SavedConfigChanged.new("legacy saved configuration changed or became unavailable after indexing",
101
+ code: :saved_config_changed, phase: :backup), cause: nil
102
+ end
103
+ end
104
+
105
+ private_constant :LegacyIndex
106
+ end
107
+ end
108
+ end
109
+ end