zonesync 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e2ac350c2fe58aaed3387c001755daba2e460747b09e12ca187e3643895cfec
4
- data.tar.gz: b9c28c96bfcb6e4964060aa6fac6b05af5533d7903dd74b76850cc1d58904158
3
+ metadata.gz: aaa6c630bddb382ab5b051983a3af48b5d59583a2e6befb51bf4098e3ccbdbb0
4
+ data.tar.gz: 7005f724efb994e55bc9d581780a8ba5711729ba208b9ef4c8fb77cc48d018bc
5
5
  SHA512:
6
- metadata.gz: 8861b5bbcb843c30e3017eb2989fc3391913452246d5a6d798b8ccac677430ae845f5189271a6a07d8f4b7f72f07db34153f480281a3e6d398e135c3b0b28cf1
7
- data.tar.gz: 6a471b920e9cd971a023eca9992894c65f43ef9e6fffe9d873abb32706f265db98043fdeb7e749cd7a87feeda0faefac8bd4b1477f27059e28b8e793f3f091fe
6
+ metadata.gz: ca2cc5e0fefcbf14efe91cb80b84ef20b485715891d92b8f2cd1f58cb2b6193bf9a7a51e822fa046a516bb275a7c5a5fb98e19d2cb9ae08cee41208434986f9e
7
+ data.tar.gz: bb66c82041407b2e3d3a57e0c1c381dc9a7ac77fb1d09db52d61892fb76409541a3d5059f916205f00dbf304a71218a0df7f60c6fb995350a1928d8026e8b617
@@ -1,22 +1,56 @@
1
1
  # typed: strict
2
2
  require "sorbet-runtime"
3
+ require "zonesync/record_hash"
3
4
 
4
5
  module Zonesync
6
+ class ValidationError < StandardError
7
+ extend T::Sig
8
+
9
+ sig { void }
10
+ def initialize
11
+ @errors = T.let([], T::Array[StandardError])
12
+ end
13
+
14
+ sig { params(error: StandardError).void }
15
+ def add(error)
16
+ @errors << error
17
+ end
18
+
19
+ sig { returns(T::Boolean) }
20
+ def any?
21
+ @errors.any?
22
+ end
23
+
24
+ sig { returns(String) }
25
+ def message
26
+ @errors.map(&:message).join("\n\n#{'-' * 60}\n\n")
27
+ end
28
+
29
+ sig { returns(T::Array[StandardError]) }
30
+ attr_reader :errors
31
+ end
32
+
5
33
  class ConflictError < StandardError
6
34
  extend T::Sig
7
35
 
8
- sig { params(existing: T.nilable(Record), new: Record).void }
9
- def initialize existing, new
10
- @existing = existing
11
- @new = new
36
+ sig { params(conflicts: T::Array[[T.nilable(Record), Record]]).void }
37
+ def initialize(conflicts)
38
+ @conflicts = conflicts
12
39
  end
13
40
 
14
41
  sig { returns(String) }
15
42
  def message
16
- <<~MSG
17
- The following untracked DNS record already exists and would be overwritten.
18
- existing: #{@existing}
19
- new: #{@new}
43
+ conflicts_text = @conflicts.sort_by { |_existing, new_rec| new_rec.name }.map do |existing_rec, new_rec|
44
+ " existing: #{existing_rec}\n new: #{new_rec}"
45
+ end.join("\n\n")
46
+
47
+ count = @conflicts.length
48
+ record_word = count == 1 ? "record" : "records"
49
+ exists_word = count == 1 ? "exists" : "exist"
50
+
51
+ <<~MSG.chomp
52
+ The following untracked DNS #{record_word} already #{exists_word} and would be overwritten:
53
+ #{conflicts_text}
20
54
  MSG
21
55
  end
22
56
  end
@@ -41,20 +75,71 @@ module Zonesync
41
75
  class ChecksumMismatchError < StandardError
42
76
  extend T::Sig
43
77
 
44
- sig { params(existing: T.nilable(Record), new: Record).void }
45
- def initialize existing, new
78
+ sig {
79
+ params(
80
+ existing: T.nilable(Record),
81
+ new: T.nilable(Record),
82
+ expected_record: T.nilable(Record),
83
+ actual_record: T.nilable(Record),
84
+ missing_hash: T.nilable(String)
85
+ ).void
86
+ }
87
+ def initialize(existing = nil, new = nil, expected_record: nil, actual_record: nil, missing_hash: nil)
46
88
  @existing = existing
47
89
  @new = new
90
+ @expected_record = expected_record
91
+ @actual_record = actual_record
92
+ @missing_hash = missing_hash
48
93
  end
49
94
 
50
95
  sig { returns(String) }
51
96
  def message
97
+ # V2 manifest integrity violation
98
+ if @missing_hash
99
+ return generate_v2_message
100
+ end
101
+
102
+ # V1 checksum mismatch
52
103
  <<~MSG
53
104
  The zonesync_checksum TXT record does not match the current state of the DNS records. This probably means that someone else has changed them.
54
105
  existing: #{@existing}
55
106
  new: #{@new}
56
107
  MSG
57
108
  end
109
+
110
+ private
111
+
112
+ sig { returns(String) }
113
+ def generate_v2_message
114
+ if @expected_record && @actual_record
115
+ # Record was modified
116
+ actual_hash = RecordHash.generate(@actual_record)
117
+ <<~MSG.chomp
118
+ The following tracked DNS record has been modified externally:
119
+ Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
120
+ Actual: #{@actual_record.name} #{@actual_record.ttl} #{@actual_record.type} #{@actual_record.rdata} (hash: #{actual_hash})
121
+
122
+ This probably means someone else has changed it. Use --force to override.
123
+ MSG
124
+ elsif @expected_record
125
+ # Record was deleted
126
+ <<~MSG.chomp
127
+ The following tracked DNS record has been deleted externally:
128
+ Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
129
+ Not found in current remote records.
130
+
131
+ This probably means someone else has deleted it. Use --force to override.
132
+ MSG
133
+ else
134
+ # Fallback: we don't have source records to look up details
135
+ <<~MSG.chomp
136
+ The following tracked DNS record has been modified or deleted externally:
137
+ Expected hash: #{@missing_hash} (not found in current records)
138
+
139
+ This probably means someone else has changed it. Use --force to override.
140
+ MSG
141
+ end
142
+ end
58
143
  end
59
144
 
60
145
  class DuplicateRecordError < StandardError
@@ -26,7 +26,7 @@ module Zonesync
26
26
  sig { params(other: Provider, force: T::Boolean).returns(T::Array[Operation]) }
27
27
  def diff! other, force: false
28
28
  operations = diff(other).call
29
- Validator.call(operations, self, force: force)
29
+ Validator.call(operations, self, other, force: force)
30
30
  operations
31
31
  end
32
32
 
@@ -118,7 +118,7 @@ module Zonesync
118
118
 
119
119
  missing = expected_set - found_set
120
120
  if missing.any?
121
- raise ConflictError.new(nil, diffable.first || remote_records.first)
121
+ raise ConflictError.new([[nil, diffable.first || remote_records.first]])
122
122
  end
123
123
 
124
124
  diffable.sort
@@ -53,6 +53,37 @@ module Zonesync
53
53
  string << " ; #{comment}" if comment
54
54
  string
55
55
  end
56
+
57
+ sig { params(other: Record).returns(T::Boolean) }
58
+ def identical_to?(other)
59
+ name == other.name && type == other.type && ttl == other.ttl && rdata == other.rdata
60
+ end
61
+
62
+ sig { params(other: Record).returns(T::Boolean) }
63
+ def conflicts_with?(other)
64
+ return false unless name == other.name && type == other.type
65
+
66
+ case type
67
+ when "CNAME", "SOA"
68
+ true
69
+ when "MX"
70
+ existing_priority = rdata.split(' ').first
71
+ new_priority = other.rdata.split(' ').first
72
+ existing_priority == new_priority
73
+ else
74
+ false
75
+ end
76
+ end
77
+
78
+ sig { params(type: String).returns(T::Boolean) }
79
+ def self.single_record_per_name?(type)
80
+ type == "CNAME" || type == "SOA"
81
+ end
82
+
83
+ sig { params(records: T::Array[Record]).returns(T::Array[Record]) }
84
+ def self.non_meta(records)
85
+ records.reject { |r| r.manifest? || r.checksum? }
86
+ end
56
87
  end
57
88
  end
58
89
 
@@ -3,28 +3,42 @@ require "sorbet-runtime"
3
3
  require "zonesync/record_hash"
4
4
 
5
5
  module Zonesync
6
- Validator = Struct.new(:operations, :destination) do
6
+ Validator = Struct.new(:operations, :destination, :source) do
7
7
  extend T::Sig
8
8
 
9
- sig { params(operations: T::Array[Operation], destination: Provider, force: T::Boolean).void }
10
- def self.call(operations, destination, force: false)
11
- new(operations, destination).call(force: force)
9
+ sig { params(operations: T::Array[Operation], destination: Provider, source: T.nilable(Provider), force: T::Boolean).void }
10
+ def self.call(operations, destination, source = nil, force: false)
11
+ new(operations, destination, source).call(force: force)
12
12
  end
13
13
 
14
14
  sig { params(force: T::Boolean).void }
15
15
  def call(force: false)
16
+ validation_error = ValidationError.new
17
+
16
18
  if !force && operations.any? && !manifest.existing?
17
- raise MissingManifestError.new(manifest.generate)
19
+ validation_error.add(MissingManifestError.new(manifest.generate))
18
20
  end
19
- # Only validate checksums for v1 manifests (v2 manifests provide integrity via hashes)
21
+
20
22
  if !force && manifest.v1_format? && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
21
- raise ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum)
23
+ validation_error.add(ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum))
22
24
  end
23
- operations.each do |method, args|
24
- if method == :add
25
- validate_addition args.first, force: force
26
- end
25
+
26
+ if !force && manifest.v2_format?
27
+ integrity_error = validate_v2_manifest_integrity
28
+ validation_error.add(integrity_error) if integrity_error
27
29
  end
30
+
31
+ conflicts = operations.map do |method, args|
32
+ method == :add ? validate_addition(args.first, force: force) : nil
33
+ end.compact
34
+ validation_error.add(ConflictError.new(conflicts)) if conflicts.any?
35
+
36
+ if validation_error.errors.length == 1
37
+ raise validation_error.errors.first
38
+ elsif validation_error.errors.length > 1
39
+ raise validation_error
40
+ end
41
+
28
42
  nil
29
43
  end
30
44
 
@@ -35,68 +49,96 @@ module Zonesync
35
49
  destination.manifest
36
50
  end
37
51
 
38
- sig { params(record: Record, force: T::Boolean).void }
39
- def validate_addition record, force: false
40
- return if manifest.matches?(record)
41
- return if force
52
+ sig { returns(T.nilable(ChecksumMismatchError)) }
53
+ def validate_v2_manifest_integrity
54
+ manifest_data = T.must(manifest.existing).rdata[1..-2]
55
+ expected_hashes = manifest_data.split(",")
56
+ actual_records = Record.non_meta(destination.records)
57
+ actual_hash_to_record = actual_records.map { |r| [RecordHash.generate(r), r] }.to_h
42
58
 
43
- # Use hash-based conflict detection when we have a v2 manifest
44
- if manifest.existing? && !manifest.existing.rdata[1..-2].include?(";")
45
- # V2 hash-based manifest: check for untracked records that conflict
46
- record_hash = RecordHash.generate(record)
47
- expected_hashes = manifest.existing.rdata[1..-2].split(",")
59
+ missing_hash = expected_hashes.find { |hash| !actual_hash_to_record.key?(hash) }
60
+ return nil unless missing_hash
61
+
62
+ expected_record = find_expected_record(missing_hash)
63
+ actual_record = find_modified_record(expected_record, actual_records)
64
+
65
+ ChecksumMismatchError.new(
66
+ expected_record: expected_record,
67
+ actual_record: actual_record,
68
+ missing_hash: missing_hash
69
+ )
70
+ end
71
+
72
+ sig { params(missing_hash: String).returns(T.nilable(Record)) }
73
+ def find_expected_record(missing_hash)
74
+ return nil unless source
75
+
76
+ source_records = Record.non_meta(source.records)
77
+ source_records.find { |r| RecordHash.generate(r) == missing_hash }
78
+ end
79
+
80
+ sig { params(expected_record: T.nilable(Record), actual_records: T::Array[Record]).returns(T.nilable(Record)) }
81
+ def find_modified_record(expected_record, actual_records)
82
+ return nil unless expected_record
48
83
 
49
- # Find conflicting records that would be overwritten by this addition
50
- conflicting_record = destination.records.find do |r|
51
- next if r.manifest? || r.checksum?
52
-
53
- # Skip if this record is tracked in the manifest
54
- r_hash = RecordHash.generate(r)
55
- next if expected_hashes.include?(r_hash)
56
-
57
- # Skip if it's exactly the same record (we're just starting to track it)
58
- next if r.name == record.name && r.type == record.type && r.ttl == record.ttl && r.rdata == record.rdata
59
-
60
- # Check for conflicts based on record type
61
- case record.type
62
- when "CNAME", "SOA"
63
- # These types only allow one record per name
64
- r.name == record.name && r.type == record.type
65
- when "MX"
66
- # MX records conflict if same name and same priority (first part of rdata)
67
- if r.name == record.name && r.type == record.type
68
- existing_priority = r.rdata.split(' ').first
69
- new_priority = record.rdata.split(' ').first
70
- existing_priority == new_priority
71
- end
72
- else
73
- # For other types (A, AAAA, TXT, etc.), multiple records are allowed
74
- # Only conflict if trying to add identical record (but we already checked that above)
75
- false
76
- end
84
+ # For CNAME and SOA, only one record per name is allowed, so check for modification
85
+ # For other types (A, AAAA, TXT, MX), only check if there's exactly one record
86
+ # with that name/type - if there are multiples, we can't determine which one it "became"
87
+ if Record.single_record_per_name?(expected_record.type)
88
+ actual_records.find do |r|
89
+ r.name == expected_record.name && r.type == expected_record.type
77
90
  end
78
91
  else
79
- # V1 name-based manifest or no manifest
80
- if manifest.existing?
81
- # V1 name-based manifest: use old shorthand logic
82
- shorthand = manifest.shorthand_for(record, with_type: true)
83
- conflicting_record = destination.records.find do |r|
84
- manifest.shorthand_for(r, with_type: true) == shorthand
85
- end
86
- else
87
- # No manifest: only conflict if exact same record exists
88
- conflicting_record = destination.records.find do |r|
89
- r.name == record.name &&
90
- r.type == record.type &&
91
- r.ttl == record.ttl &&
92
- r.rdata == record.rdata
93
- end
92
+ matching_records = actual_records.select do |r|
93
+ r.name == expected_record.name && r.type == expected_record.type
94
94
  end
95
+ matching_records.first if matching_records.count == 1
95
96
  end
97
+ end
98
+
99
+ sig { params(record: Record, force: T::Boolean).returns(T.nilable([T.nilable(Record), Record])) }
100
+ def validate_addition record, force: false
101
+ return nil if manifest.matches?(record)
102
+ return nil if force
103
+
104
+ conflicting_record = if manifest.v2_format?
105
+ expected_hashes = manifest.existing.rdata[1..-2].split(",")
106
+ find_v2_conflict(record, expected_hashes)
107
+ elsif manifest.existing?
108
+ find_v1_conflict(record)
109
+ else
110
+ find_unmanaged_conflict(record)
111
+ end
112
+
113
+ return nil if !conflicting_record
114
+ return nil if conflicting_record == record
115
+ [conflicting_record, record]
116
+ end
96
117
 
97
- return if !conflicting_record
98
- return if conflicting_record == record
99
- raise Zonesync::ConflictError.new(conflicting_record, record)
118
+ sig { params(record: Record, expected_hashes: T::Array[String]).returns(T.nilable(Record)) }
119
+ def find_v2_conflict(record, expected_hashes)
120
+ destination.records.find do |r|
121
+ next if r.manifest? || r.checksum?
122
+ next if expected_hashes.include?(RecordHash.generate(r))
123
+ next if r.identical_to?(record)
124
+
125
+ r.conflicts_with?(record)
126
+ end
127
+ end
128
+
129
+ sig { params(record: Record).returns(T.nilable(Record)) }
130
+ def find_v1_conflict(record)
131
+ shorthand = manifest.shorthand_for(record, with_type: true)
132
+ destination.records.find do |r|
133
+ manifest.shorthand_for(r, with_type: true) == shorthand
134
+ end
135
+ end
136
+
137
+ sig { params(record: Record).returns(T.nilable(Record)) }
138
+ def find_unmanaged_conflict(record)
139
+ destination.records.find do |r|
140
+ r.identical_to?(record)
141
+ end
100
142
  end
101
143
  end
102
144
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zonesync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2025-09-18 00:00:00.000000000 Z
12
+ date: 2025-10-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: diff-lcs