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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d8cd792f367a5afca6685d1739ba086f7922090bfca844c6802e7a8e551bc09d
4
+ data.tar.gz: 6b2b2bbce0fd2d6fc2046d3ac2c6e6f6802588b7b138d08564b92c292fd7b6d7
5
+ SHA512:
6
+ metadata.gz: 8a161dca28081a7f9fe94c9706692efdc28910796325409352c576d2342ed3b51ae68ad45b6f5412b8024a4e20585d18b2c69275014ea7f5c05fa96594bebe78
7
+ data.tar.gz: 901c4e8ea33b37b6cdd94fa6999fb994f2c95998a41c4d1a1bbc45914e7733637b5aac571b825472ca98b139db7739f0869d8120485f6193fb76f2c547881a32
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-12
4
+
5
+ Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # dns_json
2
+
3
+ Read and write DNS messages as [RFC 8427](https://www.rfc-editor.org/rfc/rfc8427) JSON, including malformed packets used for diagnostics. Ruby 3.3+; CRuby, JRuby and TruffleRuby. No dependency on the other JSON format gems.
4
+
5
+ ```ruby
6
+ require "dns_json"
7
+
8
+ message = DNSJSON.from_wire(packet_bytes, date: Time.now)
9
+ message.malformed? # true when wire diagnostics were collected
10
+ message.problems # DNSJSON::Problem objects with byte offset and reason
11
+ message.to_json(profile: DNSJSON::Profile::LOGGING)
12
+ DNSJSON.parse(message.to_json).to_wire == packet_bytes # true
13
+
14
+ query = DNSJSON::Message.new({
15
+ "ID" => 1042, "RD" => 1, "QNAME" => "service.test.",
16
+ "QTYPE" => 28, "QCLASS" => 1
17
+ })
18
+ query.to_wire
19
+ query.qname
20
+ ```
21
+
22
+ `Message` and `RR` wrap a Hash; `[]` accesses any member and `to_h` returns a deep copy. Unknown extension members survive the default JSON roundtrip. All RFC members are optional. `parse(..., strict: false)` retains invalid values and provides `warnings` with JSON Pointers; serialization validates strictly. JSON input and output use ASCII, with escaped Unicode in string values. Duplicate object keys are rejected on both json 2.x and 3.x.
23
+
24
+ | Supported | Scope |
25
+ | --- | --- |
26
+ | Wire decoding | Headers, questions and RR sections; compressed names; partial/malformed packets with diagnostics and original octets |
27
+ | Wire encoding | Original bytes when `messageOctetsHEX` exists; otherwise uncompressed structured reconstruction, including rrSet expansion |
28
+ | RDATA | Every member in RFC 8427 §2.3: A, AAAA, CNAME, DNAME, NS, PTR, TXT, CDNSKEY, CDS, CSYNC, DNSKEY, HIP, IPSECKEY, KEY, MX, NSEC, NSEC3, NSEC3PARAM, OPENPGPKEY, RRSIG, SMIMEA, SPF, SRV, SSHFP, TLSA; also DS |
29
+ | Other RR types | Numeric/IANA names and opaque `RDATAHEX` |
30
+ | Pairs and streams | §3 query/response pairs and §4 RFC 7464 JSON text sequences |
31
+ | Profiles | Member groups, mandatory/prohibited members, boolean or numeric one-bit values, privacy preset |
32
+ | Not included | DNS resolution, DNSSEC verification, IDN U-label conversion, pcap, DNS Update-specific JSON, semantic validation of cryptographic key material |
33
+
34
+ ## Wire reconstruction and names
35
+
36
+ `messageOctetsHEX` always wins, including for malformed packets. To construct an edited packet, obtain `to_h`, remove that member, make your changes, then create a new `Message`. Header flags default to zero; counts default to section lengths but explicitly supplied counts are retained. ID, each record's name/type/class, and answer TTL/RDATA must be provided. `NAMEHEX`/`QNAMEHEX` take precedence over display names and must contain an uncompressed whole name.
37
+
38
+ Structured `rdata*` takes precedence over `RDATAHEX` and recalculates RDLENGTH, avoiding stale compression offsets. With opaque RDATAHEX, explicit RDLENGTH is preserved. Unknown raw RDATA can contain compression offsets that cannot safely be relocated; retain the whole-message HEX for exact reproduction. Standalone `rrOctetsHEX`, header and section octets are diagnostics, not reconstruction overrides.
39
+
40
+ Names implement [verified erratum 5439](https://www.rfc-editor.org/errata/eid5439): literal dots and backslashes within labels use DNS backslash escaping before JSON encoding. Nonprintable/high octets use decimal DNS escapes. Ordinary JSON parsing therefore keeps label boundaries intact. The original RFC text's `\u002E` rule was ambiguous and is superseded by this correction. HEX remains available for exact inspection. Relative display names are accepted and emitted as absolute names; root is `.`. Pointer traversal accepts backward references only, rejects loops and limits expanded names to 255 octets.
41
+
42
+ TXT uses a single text string as specified in §2.3. Multiple wire character-strings are joined; individual boundaries remain available in the original HEX. Encoding splits text into chunks of at most 255 octets. Octets map to U+0000–U+00FF, escaped in JSON. SPF uses quoted DNS presentation strings. RDATA that cannot be decoded is retained as HEX with a diagnostic.
43
+
44
+ One-bit output defaults to numeric 0/1, matching §1.1 and the RFC examples. Boolean input is also accepted because §2.1 uses that terminology. TTL follows §2.2's signed 32-bit range. `dateSeconds` is nonnegative and emitted without an exponent. The section 5.2 example's inconsistent ANCOUNT is retained (erratum 5570 is held for update).
45
+
46
+ ## Profiles, pairs and streams
47
+
48
+ ```ruby
49
+ profile = DNSJSON::Profile.new(
50
+ include: %i[header question answer rdata hex],
51
+ required: %w[QNAME], prohibited: %w[comment], one_bit_as: :boolean
52
+ )
53
+ message.to_json(profile: profile)
54
+ message.to_json(profile: DNSJSON::Profile::PRIVACY)
55
+
56
+ pair = DNSJSON::Pair.new(query: query, response: message)
57
+ DNSJSON::Stream::Writer.new(io) << pair
58
+ DNSJSON::Stream::Reader.new(io).each { |entry| puts entry.to_json }
59
+ ```
60
+
61
+ Groups: `header`, `question`, `answer`, `authority`, `additional`, `rdata`, `hex`, `metadata`, `extensions`. `FULL` retains all groups; `LOGGING` omits HEX. `PRIVACY` omits all raw bytes, additional records (including EDNS Client Subnet), ID, metadata and extensions. Query names and answer addresses remain visible; this is not anonymization.
62
+
63
+ Sequence readers process input incrementally, skip malformed elements by default, and expose `problems` (element indexes, not byte offsets). `strict: true` stops on the first invalid element. Error storage is capped at `max_problems: 1000`, with excess counted in `dropped_problems`.
64
+
65
+ Limits are configurable on `parse`, `from_wire`, and model construction: `max_bytes: 65_535`, `max_records: 4096`, `max_pointers: 128`, `max_json_bytes: 4_194_304`, `max_depth: 32`. Limits raise `LimitError`; reaching the record or pointer bound while decoding wire data instead returns a malformed message and preserves the original bytes. A JSON sequence has a per-element size bound.
66
+
67
+ ## Development and release
68
+
69
+ ```sh
70
+ bundle install
71
+ bundle exec rake test
72
+ COVERAGE=1 bundle exec rake test
73
+ bundle exec rubocop
74
+ bundle exec rbs -I sig validate
75
+ bundle exec ruby script/benchmark.rb
76
+ python3 -m venv tmp/interop/venv
77
+ tmp/interop/venv/bin/python -m pip install --require-hashes -r script/interop-requirements.txt
78
+ bundle exec ruby script/interop.rb
79
+ ```
80
+
81
+ RFC text, extracted examples and notices are downloaded only into ignored `tmp/`. No RFC excerpts, fixtures or third-party implementation source are distributed in Git or the gem. `script/sources.json` pins SHA-256 checksums; fixture generation tests §5.1, §5.2 and its rrSet alternative. The malformed optional fragment examples in §5.1 are not standalone JSON documents; equivalent combinations are covered by independently authored tests. `data/*.yml` contains independently maintained property/RDATA metadata and IANA type identifier facts. Run `script/update_registry.rb` to check the pinned registry; audit a new source and update the checksum before `--write`.
82
+
83
+ Tests include all RDATA forms, a Ruby `resolv` oracle, pinned dnspython comparison, random packets and names, truncation, pointer loops, resource bounds, and JSON validation. CI covers supported CRuby versions plus head, JRuby and TruffleRuby on Ubuntu/macOS, json 2/3, coverage, lint, RBS and interoperability. dnspython and miekg/dns were reviewed as independent implementations; only dnspython is used in the external oracle.
84
+
85
+ A `v*` tag runs `.github/workflows/release.yml` with the GitHub environment `release`, validates/builds the gem, publishes through RubyGems Trusted Publishing, then creates the GitHub release from CHANGELOG. Initial RubyGems publication and Trusted Publishing setup require the owner's action before tagging.
86
+
87
+ MIT; see [LICENSE.txt](LICENSE.txt).
data/SECURITY.md ADDED
@@ -0,0 +1,9 @@
1
+ # Security
2
+
3
+ Report vulnerabilities privately at https://github.com/ydah/dns_json/security/advisories/new.
4
+
5
+ Treat captured DNS packets as untrusted. The reader bounds packet length, record count, expanded names, compression traversal, JSON nesting, and sequence buffers. It returns diagnostics for malformed packets. Input size violations raise `DNSJSON::LimitError`; malformed wire data within the limits is preserved byte for byte.
6
+
7
+ This library performs no network requests during use, DNS resolution, DNSSEC validation, or packet transmission. JSON fields can intentionally disagree. `messageOctetsHEX` takes precedence over all other wire representations. Do not authorize a packet using one representation and then transmit another.
8
+
9
+ The privacy profile excludes raw HEX, additional records (including EDNS Client Subnet), message identifiers, comments and unknown metadata. It retains query names and answer addresses and therefore is not an anonymizer. Apply your own retention and access policy.
@@ -0,0 +1,17 @@
1
+ # Independently maintained API metadata; all members are optional.
2
+ ID: {accessor: id, section: '2.1', minimum: 0, maximum: 65535}
3
+ Opcode: {accessor: opcode, section: '2.1', minimum: 0, maximum: 15}
4
+ RCODE: {accessor: rcode, section: '2.1', minimum: 0, maximum: 15}
5
+ QDCOUNT: {accessor: qdcount, section: '2.1', minimum: 0, maximum: 65535}
6
+ ANCOUNT: {accessor: ancount, section: '2.1', minimum: 0, maximum: 65535}
7
+ NSCOUNT: {accessor: nscount, section: '2.1', minimum: 0, maximum: 65535}
8
+ ARCOUNT: {accessor: arcount, section: '2.1', minimum: 0, maximum: 65535}
9
+ QNAME: {accessor: qname, section: '2.1'}
10
+ QTYPE: {accessor: qtype, section: '2.1', minimum: 0, maximum: 65535}
11
+ QCLASS: {accessor: qclass, section: '2.1', minimum: 0, maximum: 65535}
12
+ NAME: {accessor: name, section: '2.2'}
13
+ TYPE: {accessor: type, section: '2.2', minimum: 0, maximum: 65535}
14
+ CLASS: {accessor: klass, section: '2.2', minimum: 0, maximum: 65535}
15
+ TTL: {accessor: ttl, section: '2.2', minimum: -2147483648, maximum: 2147483647}
16
+ RDLENGTH: {accessor: rdlength, section: '2.2', minimum: 0, maximum: 65535}
17
+ length: {accessor: length, section: '2.1/2.2', minimum: 0, maximum: 65535}
@@ -0,0 +1,105 @@
1
+ ---
2
+ 1:
3
+ name: A
4
+ section: '2.3'
5
+ wire_rfc: 1035
6
+ 28:
7
+ name: AAAA
8
+ section: '2.3'
9
+ wire_rfc: 3596
10
+ 5:
11
+ name: CNAME
12
+ section: '2.3'
13
+ wire_rfc: 1035
14
+ 39:
15
+ name: DNAME
16
+ section: '2.3'
17
+ wire_rfc: 6672
18
+ 2:
19
+ name: NS
20
+ section: '2.3'
21
+ wire_rfc: 1035
22
+ 12:
23
+ name: PTR
24
+ section: '2.3'
25
+ wire_rfc: 1035
26
+ 16:
27
+ name: TXT
28
+ section: '2.3'
29
+ wire_rfc: 1035
30
+ 60:
31
+ name: CDNSKEY
32
+ section: '2.3'
33
+ wire_rfc: 7344
34
+ 59:
35
+ name: CDS
36
+ section: '2.3'
37
+ wire_rfc: 7344
38
+ 62:
39
+ name: CSYNC
40
+ section: '2.3'
41
+ wire_rfc: 7477
42
+ 48:
43
+ name: DNSKEY
44
+ section: '2.3'
45
+ wire_rfc: 4034
46
+ 55:
47
+ name: HIP
48
+ section: '2.3'
49
+ wire_rfc: 8005
50
+ 45:
51
+ name: IPSECKEY
52
+ section: '2.3'
53
+ wire_rfc: 4025
54
+ 25:
55
+ name: KEY
56
+ section: '2.3'
57
+ wire_rfc: 2535
58
+ 15:
59
+ name: MX
60
+ section: '2.3'
61
+ wire_rfc: 1035
62
+ 47:
63
+ name: NSEC
64
+ section: '2.3'
65
+ wire_rfc: 4034
66
+ 50:
67
+ name: NSEC3
68
+ section: '2.3'
69
+ wire_rfc: 5155
70
+ 51:
71
+ name: NSEC3PARAM
72
+ section: '2.3'
73
+ wire_rfc: 5155
74
+ 61:
75
+ name: OPENPGPKEY
76
+ section: '2.3'
77
+ wire_rfc: 7929
78
+ 46:
79
+ name: RRSIG
80
+ section: '2.3'
81
+ wire_rfc: 4034
82
+ 53:
83
+ name: SMIMEA
84
+ section: '2.3'
85
+ wire_rfc: 8162
86
+ 99:
87
+ name: SPF
88
+ section: '2.3'
89
+ wire_rfc: 4408
90
+ 33:
91
+ name: SRV
92
+ section: '2.3'
93
+ wire_rfc: 2782
94
+ 44:
95
+ name: SSHFP
96
+ section: '2.3'
97
+ wire_rfc: 4255
98
+ 52:
99
+ name: TLSA
100
+ section: '2.3'
101
+ wire_rfc: 6698
102
+ 43:
103
+ name: DS
104
+ section: '2.3'
105
+ wire_rfc: 4034
data/data/rr_types.yml ADDED
@@ -0,0 +1,99 @@
1
+ ---
2
+ 1: A
3
+ 2: NS
4
+ 3: MD
5
+ 4: MF
6
+ 5: CNAME
7
+ 6: SOA
8
+ 7: MB
9
+ 8: MG
10
+ 9: MR
11
+ 10: 'NULL'
12
+ 11: WKS
13
+ 12: PTR
14
+ 13: HINFO
15
+ 14: MINFO
16
+ 15: MX
17
+ 16: TXT
18
+ 17: RP
19
+ 18: AFSDB
20
+ 19: X25
21
+ 20: ISDN
22
+ 21: RT
23
+ 22: NSAP
24
+ 23: NSAP-PTR
25
+ 24: SIG
26
+ 25: KEY
27
+ 26: PX
28
+ 27: GPOS
29
+ 28: AAAA
30
+ 29: LOC
31
+ 30: NXT
32
+ 31: EID
33
+ 32: NIMLOC
34
+ 33: SRV
35
+ 34: ATMA
36
+ 35: NAPTR
37
+ 36: KX
38
+ 37: CERT
39
+ 38: A6
40
+ 39: DNAME
41
+ 40: SINK
42
+ 41: OPT
43
+ 42: APL
44
+ 43: DS
45
+ 44: SSHFP
46
+ 45: IPSECKEY
47
+ 46: RRSIG
48
+ 47: NSEC
49
+ 48: DNSKEY
50
+ 49: DHCID
51
+ 50: NSEC3
52
+ 51: NSEC3PARAM
53
+ 52: TLSA
54
+ 53: SMIMEA
55
+ 55: HIP
56
+ 56: NINFO
57
+ 57: RKEY
58
+ 58: TALINK
59
+ 59: CDS
60
+ 60: CDNSKEY
61
+ 61: OPENPGPKEY
62
+ 62: CSYNC
63
+ 63: ZONEMD
64
+ 64: SVCB
65
+ 65: HTTPS
66
+ 66: DSYNC
67
+ 67: HHIT
68
+ 68: BRID
69
+ 69: UNECE
70
+ 70: ISO
71
+ 99: SPF
72
+ 100: UINFO
73
+ 101: UID
74
+ 102: GID
75
+ 103: UNSPEC
76
+ 104: NID
77
+ 105: L32
78
+ 106: L64
79
+ 107: LP
80
+ 108: EUI48
81
+ 109: EUI64
82
+ 128: NXNAME
83
+ 249: TKEY
84
+ 250: TSIG
85
+ 251: IXFR
86
+ 252: AXFR
87
+ 253: MAILB
88
+ 254: MAILA
89
+ 256: URI
90
+ 257: CAA
91
+ 258: AVC
92
+ 259: DOA
93
+ 260: AMTRELAY
94
+ 261: RESINFO
95
+ 262: WALLET
96
+ 263: CLA
97
+ 264: IPN
98
+ 32768: TA
99
+ 32769: DLV
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DNSJSON
4
+ class Message
5
+ PROPERTIES = YAML.safe_load_file(File.expand_path("../../data/properties.yml", __dir__)).transform_values do |properties|
6
+ properties.transform_values(&:freeze).freeze
7
+ end.freeze
8
+ PROPERTIES.each { |name, spec| define_method(spec.fetch("accessor")) { self[name] } }
9
+ attr_reader :problems, :warnings
10
+
11
+ def initialize(data = {}, strict: true, profile: nil, problems: [], **limits)
12
+ raise ValidationError, "expected an object" unless data.is_a?(Hash)
13
+
14
+ @limits = DNSJSON.validate_limits(limits)
15
+ @data = DNSJSON.copy(data)
16
+ @problems = problems.freeze
17
+ @warnings = validate
18
+ @warnings.concat(profile.validate(@data)) if profile
19
+ raise @warnings.first if strict && !@warnings.empty?
20
+ end
21
+
22
+ def [](key) = @data[key.to_s]
23
+ def to_h = DNSJSON.copy(@data)
24
+ def malformed? = !@problems.empty?
25
+ def to_wire = Wire::Writer.new(@data, **@limits).write
26
+
27
+ def to_json(*_args, profile: Profile::FULL)
28
+ raise validate.first unless validate.empty?
29
+
30
+ output = profile.apply(@data)
31
+ errors = self.class.new(output, strict: false, **@limits).warnings + profile.validate(output)
32
+ raise errors.first unless errors.empty?
33
+
34
+ text = JSONWriter.generate(output)
35
+ raise LimitError, "JSON output exceeds max_json_bytes" if text.bytesize > @limits[:max_json_bytes]
36
+
37
+ text
38
+ end
39
+
40
+ def validate
41
+ errors = []
42
+ validate_value(@data, "", 0, errors)
43
+ errors
44
+ end
45
+
46
+ private
47
+
48
+ def error(errors, pointer, message) = errors << ValidationError.new(message, pointer: pointer)
49
+
50
+ def validate_value(value, pointer, depth, errors, known: true)
51
+ raise LimitError, "object exceeds max_depth" if depth > @limits[:max_depth]
52
+
53
+ case value
54
+ when Hash
55
+ value.each do |key, item|
56
+ raise ValidationError, "object keys must be strings" unless key.is_a?(String)
57
+
58
+ path = "#{pointer}/#{key.gsub("~", "~0").gsub("/", "~1")}"
59
+ validate_member(key, item, path, errors) if known
60
+ child_known = known && (%w[queryMessage responseMessage compressedNAME compressedQNAME rrSet].include?(key) || SECTIONS.any? { |s| key == "#{s}RRs" })
61
+ validate_value(item, path, depth + 1, errors, known: child_known)
62
+ end
63
+ when Array
64
+ raise LimitError, "array exceeds max_records" if value.length > @limits[:max_records]
65
+
66
+ value.each_with_index { |v, i| validate_value(v, "#{pointer}/#{i}", depth + 1, errors, known: known) }
67
+ when Float
68
+ error(errors, pointer, "number must be finite") unless value.finite?
69
+ when String, Integer, TrueClass, FalseClass, NilClass
70
+ nil
71
+ else
72
+ error(errors, pointer, "unsupported JSON value")
73
+ end
74
+ end
75
+
76
+ def validate_member(key, value, path, errors)
77
+ spec = PROPERTIES[key]
78
+ range = spec && spec["minimum"] && (spec["minimum"]..spec["maximum"])
79
+ error(errors, path, "integer out of range") if range && !(value.is_a?(Integer) && range.cover?(value))
80
+ error(errors, path, "expected boolean or integer 0/1") if (BITS.key?(key) || key == "isCompressed") && ![0, 1, true, false].include?(value)
81
+ if key.end_with?("HEX") && !(value.is_a?(String) && value.match?(/\A(?:[0-9A-F]{2})*\z/))
82
+ error(errors, path, "expected uppercase even-length hexadecimal")
83
+ end
84
+ error(errors, path, "expected an array of objects") if (key.end_with?("RRs") || key == "rrSet") && !(value.is_a?(Array) && value.all?(Hash))
85
+ error(errors, path, "expected an object") if %w[compressedNAME compressedQNAME queryMessage responseMessage].include?(key) && !value.is_a?(Hash)
86
+ if (key.start_with?("rdata") || %w[NAME QNAME TYPEname CLASSname QTYPEname QCLASSname dateString comment].include?(key)) && !value.is_a?(String)
87
+ error(errors, path, "expected a string")
88
+ end
89
+ error(errors, path, "expected finite nonnegative seconds") if key == "dateSeconds" && !(value.is_a?(Numeric) && value.finite? && value >= 0)
90
+ return unless key == "dateString" && value.is_a?(String)
91
+
92
+ begin
93
+ match = /\A(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:Z|[+-]([01]\d|2[0-3]):([0-5]\d))\z/.match(value)
94
+ raise ArgumentError unless match
95
+
96
+ year, month, day, hour, minute, second = match.captures.first(6).map(&:to_i)
97
+ raise ArgumentError unless Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60
98
+
99
+ Time.iso8601(value)
100
+ rescue ArgumentError
101
+ error(errors, path, "expected RFC 3339 timestamp")
102
+ end
103
+ end
104
+ end
105
+
106
+ class RR < Message; end
107
+
108
+ class Pair < Message
109
+ def initialize(data = {}, query: nil, response: nil, **)
110
+ data = data.merge("queryMessage" => query.to_h) if query
111
+ data = data.merge("responseMessage" => response.to_h) if response
112
+ super(data, **)
113
+ end
114
+
115
+ def query = self["queryMessage"] && Message.new(self["queryMessage"])
116
+ def response = self["responseMessage"] && Message.new(self["responseMessage"])
117
+ def to_wire = raise(ConversionError, "select the query or response before writing wire data")
118
+ end
119
+
120
+ module JSONWriter
121
+ def self.generate(value, key = nil)
122
+ case value
123
+ when Hash then "{#{value.map { |k, v| "#{JSON.generate(k, ascii_only: true)}:#{generate(v, k)}" }.join(",")}}"
124
+ when Array then "[#{value.map { |v| generate(v) }.join(",")}]"
125
+ when Float
126
+ # dateSeconds forbids exponent notation; 17 significant digits retain binary64 precision.
127
+ number = format("%.17g", value)
128
+ if key == "dateSeconds" && number.match?(/[eE]/)
129
+ mantissa, exponent = number.split(/[eE]/)
130
+ digits = mantissa.delete(".")
131
+ point = (mantissa.index(".") || mantissa.length) + exponent.to_i
132
+ number = if point <= 0 then "0.#{"0" * -point}#{digits}"
133
+ elsif point >= digits.length then digits + ("0" * (point - digits.length))
134
+ else "#{digits[0, point]}.#{digits[point..]}"
135
+ end
136
+ end
137
+ number += ".0" unless number.match?(/[.eE]/)
138
+ number
139
+ else JSON.generate(value, ascii_only: true)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DNSJSON
4
+ class Profile
5
+ HEADER = (%w[ID Opcode RCODE] + BITS.keys + COUNTS).freeze
6
+ GROUPS = %i[header question answer authority additional rdata hex metadata extensions].freeze
7
+ def initialize(include: GROUPS, required: [], prohibited: [], one_bit_as: :number, privacy: false)
8
+ raise ArgumentError, "unknown profile group" unless (include - GROUPS).empty?
9
+ raise ArgumentError, "one_bit_as must be :number or :boolean" unless %i[number boolean].include?(one_bit_as)
10
+
11
+ @include = include.dup.freeze
12
+ @required = required.map { |name| name.to_s.dup.freeze }.freeze
13
+ @prohibited = prohibited.map { |name| name.to_s.dup.freeze }.freeze
14
+ @one_bit_as = one_bit_as
15
+ @privacy = privacy
16
+ freeze
17
+ end
18
+
19
+ def validate(data)
20
+ @required.reject { |k| data.key?(k) }.map { |k| ValidationError.new("profile requires member", pointer: "/#{k}") } +
21
+ @prohibited.select { |k| data.key?(k) }.map { |k| ValidationError.new("profile prohibits member", pointer: "/#{k}") }
22
+ end
23
+
24
+ def apply(data)
25
+ data.each_with_object({}) do |(key, value), output|
26
+ next if @prohibited.include?(key) || (@privacy && %w[ID additionalRRs additionalOctetsHEX].include?(key))
27
+ next unless selected?(key)
28
+
29
+ nested = %w[queryMessage responseMessage compressedNAME compressedQNAME rrSet].include?(key) || SECTIONS.any? { |s| key == "#{s}RRs" }
30
+ unless nested
31
+ output[key] = DNSJSON.copy(value)
32
+ if BITS.key?(key) || key == "isCompressed"
33
+ bit = [true, 1].include?(value)
34
+ output[key] = if @one_bit_as == :boolean
35
+ bit
36
+ else
37
+ (bit ? 1 : 0)
38
+ end
39
+ end
40
+ next
41
+ end
42
+ output[key] = case value
43
+ when Hash then apply(value)
44
+ when Array then value.map { |v| v.is_a?(Hash) ? apply(v) : v }
45
+ else value
46
+ end
47
+ end
48
+ end
49
+
50
+ def selected?(key)
51
+ return true if %w[queryMessage responseMessage].include?(key)
52
+ return @include.include?(:hex) if key.end_with?("HEX")
53
+ return @include.include?(:header) if HEADER.include?(key)
54
+ return @include.include?(:question) if %w[QNAME QTYPE QTYPEname QCLASS QCLASSname compressedQNAME questionRRs].include?(key)
55
+ return @include.include?(:answer) if key == "answerRRs"
56
+ return @include.include?(:authority) if key == "authorityRRs"
57
+ return @include.include?(:additional) if key == "additionalRRs"
58
+ return @include.include?(:rdata) if RData::FORMATS.value?(key.delete_prefix("rdata")) && key.start_with?("rdata")
59
+ return @include.include?(:metadata) if %w[dateString dateSeconds comment].include?(key)
60
+ return true if %w[NAME TYPE TYPEname CLASS CLASSname TTL RDLENGTH rrSet compressedNAME isCompressed length].include?(key)
61
+
62
+ @include.include?(:extensions)
63
+ end
64
+ FULL = new
65
+ LOGGING = new(include: %i[header question answer authority additional rdata metadata])
66
+ PRIVACY = new(include: %i[header question answer authority rdata], privacy: true)
67
+ end
68
+ end