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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +87 -0
- data/SECURITY.md +9 -0
- data/data/properties.yml +17 -0
- data/data/rdata_formats.yml +105 -0
- data/data/rr_types.yml +99 -0
- data/lib/dns_json/model.rb +143 -0
- data/lib/dns_json/profile.rb +68 -0
- data/lib/dns_json/rdata.rb +312 -0
- data/lib/dns_json/registry.rb +18 -0
- data/lib/dns_json/stream.rb +90 -0
- data/lib/dns_json/version.rb +5 -0
- data/lib/dns_json/wire/name.rb +107 -0
- data/lib/dns_json/wire/reader.rb +115 -0
- data/lib/dns_json/wire/writer.rb +94 -0
- data/lib/dns_json.rb +122 -0
- data/sig/dns_json.rbs +89 -0
- metadata +83 -0
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
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.
|
data/data/properties.yml
ADDED
|
@@ -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
|