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 +4 -4
- data/lib/zonesync/errors.rb +95 -10
- data/lib/zonesync/provider.rb +2 -2
- data/lib/zonesync/record.rb +31 -0
- data/lib/zonesync/validator.rb +108 -66
- data/lib/zonesync/version.rb +1 -1
- metadata +2 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: aaa6c630bddb382ab5b051983a3af48b5d59583a2e6befb51bf4098e3ccbdbb0
         | 
| 4 | 
            +
              data.tar.gz: 7005f724efb994e55bc9d581780a8ba5711729ba208b9ef4c8fb77cc48d018bc
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: ca2cc5e0fefcbf14efe91cb80b84ef20b485715891d92b8f2cd1f58cb2b6193bf9a7a51e822fa046a516bb275a7c5a5fb98e19d2cb9ae08cee41208434986f9e
         | 
| 7 | 
            +
              data.tar.gz: bb66c82041407b2e3d3a57e0c1c381dc9a7ac77fb1d09db52d61892fb76409541a3d5059f916205f00dbf304a71218a0df7f60c6fb995350a1928d8026e8b617
         | 
    
        data/lib/zonesync/errors.rb
    CHANGED
    
    | @@ -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( | 
| 9 | 
            -
                def initialize | 
| 10 | 
            -
                  @ | 
| 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 | 
            -
                   | 
| 17 | 
            -
                     | 
| 18 | 
            -
             | 
| 19 | 
            -
             | 
| 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 { | 
| 45 | 
            -
             | 
| 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
         | 
    
        data/lib/zonesync/provider.rb
    CHANGED
    
    | @@ -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
         | 
    
        data/lib/zonesync/record.rb
    CHANGED
    
    | @@ -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 |  | 
    
        data/lib/zonesync/validator.rb
    CHANGED
    
    | @@ -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 | 
            -
                     | 
| 19 | 
            +
                    validation_error.add(MissingManifestError.new(manifest.generate))
         | 
| 18 20 | 
             
                  end
         | 
| 19 | 
            -
             | 
| 21 | 
            +
             | 
| 20 22 | 
             
                  if !force && manifest.v1_format? && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
         | 
| 21 | 
            -
                     | 
| 23 | 
            +
                    validation_error.add(ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum))
         | 
| 22 24 | 
             
                  end
         | 
| 23 | 
            -
             | 
| 24 | 
            -
             | 
| 25 | 
            -
             | 
| 26 | 
            -
                     | 
| 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 {  | 
| 39 | 
            -
                def  | 
| 40 | 
            -
                   | 
| 41 | 
            -
                   | 
| 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 | 
            -
                   | 
| 44 | 
            -
                   | 
| 45 | 
            -
             | 
| 46 | 
            -
             | 
| 47 | 
            -
             | 
| 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 | 
            -
             | 
| 50 | 
            -
             | 
| 51 | 
            -
             | 
| 52 | 
            -
             | 
| 53 | 
            -
             | 
| 54 | 
            -
                       | 
| 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 | 
            -
                     | 
| 80 | 
            -
             | 
| 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 | 
            -
             | 
| 98 | 
            -
             | 
| 99 | 
            -
                   | 
| 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
         | 
    
        data/lib/zonesync/version.rb
    CHANGED
    
    
    
        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. | 
| 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- | 
| 12 | 
            +
            date: 2025-10-19 00:00:00.000000000 Z
         | 
| 13 13 | 
             
            dependencies:
         | 
| 14 14 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 15 15 | 
             
              name: diff-lcs
         |