dns_json 0.1.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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DNSJSON
4
+ module Wire
5
+ class Writer
6
+ def initialize(data, **limits)
7
+ @data = data
8
+ @limits = DNSJSON.validate_limits(limits)
9
+ @record_count = 0
10
+ end
11
+
12
+ def write
13
+ Message.new(@data, **@limits)
14
+ return bounded(DNSJSON.unhex(@data["messageOctetsHEX"])) if @data.key?("messageOctetsHEX")
15
+
16
+ sections = SECTIONS.map { |section| expand(@data.fetch("#{section}RRs", [])) }
17
+ if sections[0].empty? && (@data.key?("QNAME") || @data.key?("QNAMEHEX"))
18
+ sections[0] = [@data.each_with_object({}) { |(k, v), h| h[k.delete_prefix("Q")] = v if k.start_with?("Q") && k != "QDCOUNT" }]
19
+ end
20
+ raise LimitError, "record count exceeds max_records" if sections.sum(&:length) > @limits[:max_records]
21
+
22
+ id = required(@data, "ID")
23
+ flags = BITS.sum { |key, shift| [true, 1].include?(@data.fetch(key, 0)) ? 1 << shift : 0 }
24
+ flags |= @data.fetch("Opcode", 0) << 11
25
+ flags |= @data.fetch("RCODE", 0)
26
+ counts = COUNTS.each_with_index.map { |key, i| @data.fetch(key, sections[i].length) }
27
+ result = [id, flags, *counts].pack("n6")
28
+ sections.each_with_index do |records, index|
29
+ records.each do |record|
30
+ result << encode_record(record, question: index.zero?)
31
+ bounded(result)
32
+ end
33
+ end
34
+ bounded(result)
35
+ end
36
+
37
+ private
38
+
39
+ def bounded(bytes)
40
+ raise LimitError, "wire output exceeds max_bytes" if bytes.bytesize > @limits[:max_bytes]
41
+
42
+ bytes
43
+ end
44
+
45
+ def required(data, key)
46
+ raise ConversionError, "missing #{key}" unless data.key?(key)
47
+
48
+ data[key]
49
+ end
50
+
51
+ def expand(records)
52
+ records.flat_map do |record|
53
+ count = record.key?("rrSet") ? record["rrSet"].length : 1
54
+ @record_count += count
55
+ raise LimitError, "record count exceeds max_records" if @record_count > @limits[:max_records]
56
+
57
+ record.key?("rrSet") ? record["rrSet"].map { |entry| record.except("rrSet").merge(entry) } : [record]
58
+ end
59
+ end
60
+
61
+ def code(record, key, kind)
62
+ record.key?(key) ? record[key] : Registry.number(required(record, "#{key}name"), kind)
63
+ end
64
+
65
+ def encode_record(record, question:)
66
+ name = if record.key?("NAMEHEX")
67
+ bytes = DNSJSON.unhex(record["NAMEHEX"])
68
+ labels, size = Name.read(bytes, 0, max_pointers: 0)
69
+ raise ConversionError, "excess NAMEHEX data" unless size == bytes.bytesize
70
+
71
+ Name.pack(labels)
72
+ else
73
+ Name.encode(required(record, "NAME"))
74
+ end
75
+ type = code(record, "TYPE", :type)
76
+ klass = code(record, "CLASS", :class)
77
+ result = name + [type, klass].pack("nn")
78
+ return result if question
79
+
80
+ ttl = required(record, "TTL")
81
+ # Prefer structured RDATA on reconstruction: captured RDATAHEX can contain obsolete compression offsets.
82
+ member = "rdata#{RData::FORMATS[type]}"
83
+ rdata = if record.key?(member)
84
+ RData.encode(type, record[member])
85
+ else
86
+ DNSJSON.unhex(required(record, "RDATAHEX"))
87
+ end
88
+ raise ConversionError, "RDATA exceeds 65535 bytes" if rdata.bytesize > 65_535
89
+
90
+ result + [ttl, record.key?(member) ? rdata.bytesize : record.fetch("RDLENGTH", rdata.bytesize)].pack("l>n") + rdata
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/dns_json.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "time"
6
+ require "date"
7
+ require "ipaddr"
8
+
9
+ require_relative "dns_json/version"
10
+
11
+ module DNSJSON
12
+ VERSION = DnsJson::VERSION
13
+ MEDIA_TYPE = "application/dns+json"
14
+ LIMITS = { max_bytes: 65_535, max_records: 4096, max_pointers: 128, max_json_bytes: 4_194_304, max_depth: 32 }.freeze
15
+ BITS = { "QR" => 15, "AA" => 10, "TC" => 9, "RD" => 8, "RA" => 7, "AD" => 5, "CD" => 4 }.freeze
16
+ SECTIONS = %w[question answer authority additional].freeze
17
+ COUNTS = %w[QDCOUNT ANCOUNT NSCOUNT ARCOUNT].freeze
18
+ class Error < StandardError; end
19
+ class ParseError < Error; end
20
+ class ConversionError < Error; end
21
+ class LimitError < ParseError; end
22
+
23
+ class ValidationError < Error
24
+ attr_reader :pointer
25
+
26
+ def initialize(message, pointer: "")
27
+ @pointer = pointer
28
+ super("#{pointer.empty? ? "/" : pointer}: #{message}")
29
+ end
30
+ end
31
+ Problem = Data.define(:offset, :reason)
32
+ class DecimalToken
33
+ attr_reader :source
34
+
35
+ def initialize(source)
36
+ @source = source
37
+ end
38
+ end
39
+
40
+ def self.validate_limits(options)
41
+ raise ArgumentError, "unknown limit" unless (options.keys - LIMITS.keys).empty?
42
+
43
+ limits = LIMITS.merge(options)
44
+ limits.each do |name, value|
45
+ minimum = name == :max_depth ? 1 : 0
46
+ raise ArgumentError, "#{name} must be an Integer >= #{minimum}" unless value.is_a?(Integer) && value >= minimum
47
+ end
48
+ limits
49
+ end
50
+
51
+ def self.normalize_decimals(value, errors, pointer = "", member = nil, known: true)
52
+ case value
53
+ when Hash
54
+ value.to_h do |key, item|
55
+ child_known = known && (%w[queryMessage responseMessage compressedNAME compressedQNAME rrSet].include?(key) || SECTIONS.any? { |s| key == "#{s}RRs" })
56
+ path = "#{pointer}/#{key.gsub("~", "~0").gsub("/", "~1")}"
57
+ [key, normalize_decimals(item, errors, path, known ? key : nil, known: child_known)]
58
+ end
59
+ when Array
60
+ value.each_with_index.map { |item, index| normalize_decimals(item, errors, "#{pointer}/#{index}", known: known) }
61
+ when DecimalToken
62
+ errors << ValidationError.new("dateSeconds forbids signs and exponents", pointer: pointer) if member == "dateSeconds" && value.source.match?(/[eE-]/)
63
+ Float(value.source)
64
+ else value
65
+ end
66
+ end
67
+
68
+ def self.hex(bytes) = bytes.unpack1("H*").upcase
69
+
70
+ def self.unhex(text)
71
+ raise ConversionError, "expected uppercase even-length hexadecimal" unless text.is_a?(String) && text.match?(/\A(?:[0-9A-F]{2})*\z/)
72
+
73
+ [text].pack("H*")
74
+ end
75
+
76
+ def self.copy(value, depth = 0)
77
+ raise LimitError, "object copy exceeds safe depth" if depth > 128
78
+
79
+ case value
80
+ when Hash then value.to_h { |k, v| [k.dup, copy(v, depth + 1)] }
81
+ when Array then value.map { |v| copy(v, depth + 1) }
82
+ when String then value.dup
83
+ else value
84
+ end
85
+ end
86
+
87
+ def self.parse(source, strict: true, profile: nil, **limits)
88
+ raise ParseError, "JSON source must be a String" unless source.is_a?(String)
89
+
90
+ limits = validate_limits(limits)
91
+ raise LimitError, "JSON exceeds max_json_bytes" if source.bytesize > limits[:max_json_bytes]
92
+ raise ParseError, "JSON source must contain only ASCII characters" unless source.ascii_only?
93
+
94
+ data = JSON.parse(source, allow_duplicate_key: false, decimal_class: DecimalToken, max_nesting: limits[:max_depth])
95
+ lexical_errors = []
96
+ data = normalize_decimals(data, lexical_errors)
97
+ raise ParseError, "expected a JSON object" unless data.is_a?(Hash)
98
+
99
+ type = data.key?("queryMessage") || data.key?("responseMessage") ? Pair : Message
100
+ raise lexical_errors.first if strict && !lexical_errors.empty?
101
+
102
+ result = type.new(data, strict: strict, profile: profile, **limits)
103
+ result.warnings.concat(lexical_errors)
104
+ result
105
+ rescue JSON::ParserError, EncodingError => e
106
+ raise ParseError, e.message
107
+ end
108
+
109
+ def self.from_wire(bytes, date: nil, **limits)
110
+ limits = validate_limits(limits)
111
+ Wire::Reader.new(bytes, **limits).read(date: date)
112
+ end
113
+ end
114
+
115
+ require_relative "dns_json/registry"
116
+ require_relative "dns_json/wire/name"
117
+ require_relative "dns_json/rdata"
118
+ require_relative "dns_json/model"
119
+ require_relative "dns_json/profile"
120
+ require_relative "dns_json/wire/reader"
121
+ require_relative "dns_json/wire/writer"
122
+ require_relative "dns_json/stream"
data/sig/dns_json.rbs ADDED
@@ -0,0 +1,89 @@
1
+ module DnsJson
2
+ VERSION: String
3
+ end
4
+ module DNSJSON
5
+ VERSION: String
6
+ MEDIA_TYPE: String
7
+ type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
8
+ type object = Hash[String, json]
9
+ class Error < StandardError
10
+ end
11
+ class ParseError < Error
12
+ end
13
+ class ConversionError < Error
14
+ end
15
+ class LimitError < ParseError
16
+ end
17
+ class ValidationError < Error
18
+ attr_reader pointer: String
19
+ def initialize: (String, ?pointer: String) -> void
20
+ end
21
+ class Problem < Data
22
+ attr_reader offset: Integer
23
+ attr_reader reason: String
24
+ end
25
+ def self.parse: (String, ?strict: bool, ?profile: Profile?, **Integer) -> Message
26
+ def self.from_wire: (String, ?date: Time?, **Integer) -> Message
27
+ class Message
28
+ attr_reader problems: Array[Problem]
29
+ attr_reader warnings: Array[ValidationError]
30
+ def initialize: (?object, ?strict: bool, ?profile: Profile?, ?problems: Array[Problem], **Integer) -> void
31
+ def []: (String | Symbol) -> json
32
+ def to_h: () -> object
33
+ def to_json: (*untyped, ?profile: Profile) -> String
34
+ def to_wire: () -> String
35
+ def malformed?: () -> bool
36
+ def validate: () -> Array[ValidationError]
37
+ def id: () -> Integer?
38
+ def qname: () -> String?
39
+ def qtype: () -> Integer?
40
+ def qclass: () -> Integer?
41
+ def name: () -> String?
42
+ def type: () -> Integer?
43
+ def klass: () -> Integer?
44
+ def ttl: () -> Integer?
45
+ def rdlength: () -> Integer?
46
+ def opcode: () -> Integer?
47
+ def rcode: () -> Integer?
48
+ def qdcount: () -> Integer?
49
+ def ancount: () -> Integer?
50
+ def nscount: () -> Integer?
51
+ def arcount: () -> Integer?
52
+ def length: () -> Integer?
53
+ end
54
+ class RR < Message
55
+ end
56
+ class Pair < Message
57
+ def initialize: (?object, ?query: Message?, ?response: Message?, **untyped) -> void
58
+ def query: () -> Message?
59
+ def response: () -> Message?
60
+ def to_wire: () -> bot
61
+ end
62
+ class Profile
63
+ FULL: Profile
64
+ LOGGING: Profile
65
+ PRIVACY: Profile
66
+ def initialize: (?include: Array[Symbol], ?required: Array[String | Symbol], ?prohibited: Array[String | Symbol], ?one_bit_as: Symbol, ?privacy: bool) -> void
67
+ def apply: (object) -> object
68
+ def validate: (object) -> Array[ValidationError]
69
+ end
70
+ module Stream
71
+ class Writer
72
+ def initialize: (untyped, ?profile: Profile) -> void
73
+ def <<: (Message) -> Writer
74
+ end
75
+ class Reader
76
+ include Enumerable[Message]
77
+ attr_reader problems: Array[Problem]
78
+ attr_reader dropped_problems: Integer
79
+ def initialize: (untyped, ?strict: bool, ?max_json_bytes: Integer, ?max_problems: Integer, **untyped) -> void
80
+ def each: () { (Message) -> void } -> Reader
81
+ | () -> Enumerator[Message, Reader]
82
+ end
83
+ end
84
+ module Registry
85
+ def self.type: (Integer) -> String
86
+ def self.klass: (Integer) -> String
87
+ def self.number: (String, ?Symbol) -> Integer
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dns_json
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.19.4
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 2.19.4
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ description: Bounded tolerant DNS wire parsing, RFC 8427 JSON conversion, RDATA codecs,
33
+ profiles, and JSON text sequences.
34
+ email:
35
+ - t.yudai92@gmail.com
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - CHANGELOG.md
41
+ - LICENSE.txt
42
+ - README.md
43
+ - SECURITY.md
44
+ - data/properties.yml
45
+ - data/rdata_formats.yml
46
+ - data/rr_types.yml
47
+ - lib/dns_json.rb
48
+ - lib/dns_json/model.rb
49
+ - lib/dns_json/profile.rb
50
+ - lib/dns_json/rdata.rb
51
+ - lib/dns_json/registry.rb
52
+ - lib/dns_json/stream.rb
53
+ - lib/dns_json/version.rb
54
+ - lib/dns_json/wire/name.rb
55
+ - lib/dns_json/wire/reader.rb
56
+ - lib/dns_json/wire/writer.rb
57
+ - sig/dns_json.rbs
58
+ homepage: https://github.com/ydah/dns_json
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ allowed_push_host: https://rubygems.org
63
+ source_code_uri: https://github.com/ydah/dns_json
64
+ changelog_uri: https://github.com/ydah/dns_json/blob/main/CHANGELOG.md
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.3.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.16
81
+ specification_version: 4
82
+ summary: Read and write DNS messages as RFC 8427 JSON
83
+ test_files: []