diff --git a/src/dns/dns.zig b/src/dns/dns.zig new file mode 100644 index 0000000..90cb9bc --- /dev/null +++ b/src/dns/dns.zig @@ -0,0 +1,11 @@ +//! Public root of the pure DNS wire-format module. The fuzz-target compile +//! imports this as a named module; file-level tests stay reachable through +//! `src/tests.zig`, which imports each file directly (`zig test` collects +//! tests only from the root module). +pub const types = @import("types.zig"); +pub const header = @import("header.zig"); +pub const name = @import("name.zig"); +pub const question = @import("question.zig"); +pub const record = @import("record.zig"); +pub const edns = @import("edns.zig"); +pub const packet = @import("packet.zig"); diff --git a/src/dns/edns.zig b/src/dns/edns.zig new file mode 100644 index 0000000..ca968ab --- /dev/null +++ b/src/dns/edns.zig @@ -0,0 +1,429 @@ +//! EDNS(0) OPT pseudo-record (RFC 6891) and the Client Subnet option +//! (RFC 7871). Pure: no allocation, no `std.Io` beyond writing encoded bytes +//! to a caller's writer. +//! +//! OPT reuses two record fields for other purposes: CLASS carries the +//! requestor's UDP payload size and TTL carries the extended RCODE, the EDNS +//! version and the DO bit. `record.Record` therefore keeps both as plain +//! integers, and this module reinterprets them. + +const std = @import("std"); +const types = @import("types.zig"); +const record = @import("record.zig"); +const Writer = std.Io.Writer; + +/// The EDNS Client Subnet option code (RFC 7871 §6). +pub const ecs_option_code: u16 = 8; + +pub const OptRecord = struct { + udp_payload_size: u16, + extended_rcode: u8, + version: u8, + do_bit: bool, + /// The option list, as a span into the packet the OPT record came from. + options: record.RdataSpan, +}; + +pub const ParseError = error{ + /// The record is not a well-formed OPT: wrong type, or an owner name other + /// than the root, which RFC 6891 §6.1.2 requires. + NotOpt, + /// An option header or body runs past the end of the option list. + BadOption, +}; + +/// Reinterprets an already-parsed record as an OPT record and checks that its +/// option list is structurally sound, so a returned `OptRecord` always +/// iterates without error. +pub fn parseOpt(packet: []const u8, rec: record.Record) ParseError!OptRecord { + if (rec.rtype != .opt) return error.NotOpt; + if (!rec.name.isRoot()) return error.NotOpt; + + const opt: OptRecord = .{ + .udp_payload_size = rec.class, + .extended_rcode = @truncate(rec.ttl >> 24), + .version = @truncate(rec.ttl >> 16), + .do_bit = rec.ttl & 0x8000 != 0, + .options = rec.rdata, + }; + + var it = options(packet, opt); + while (try it.next()) |_| {} + return opt; +} + +pub const Option = struct { + code: u16, + data: []const u8, +}; + +pub const OptionIterator = struct { + bytes: []const u8, + pos: usize, + + pub fn next(self: *OptionIterator) error{BadOption}!?Option { + if (self.pos == self.bytes.len) return null; + if (self.pos + 4 > self.bytes.len) return error.BadOption; + const code = std.mem.readInt(u16, self.bytes[self.pos..][0..2], .big); + const len: usize = std.mem.readInt(u16, self.bytes[self.pos + 2 ..][0..2], .big); + const data_start = self.pos + 4; + if (data_start + len > self.bytes.len) return error.BadOption; + self.pos = data_start + len; + return .{ .code = code, .data = self.bytes[data_start..][0..len] }; + } +}; + +pub fn options(packet: []const u8, opt: OptRecord) OptionIterator { + return .{ .bytes = opt.options.slice(packet), .pos = 0 }; +} + +/// Returns the first option carrying `code`, or null. An `OptRecord` from +/// `parseOpt` cannot fail here, but the error stays visible so that a +/// hand-built `OptRecord` cannot smuggle a malformed list past this. +pub fn findOption(packet: []const u8, opt: OptRecord, code: u16) error{BadOption}!?Option { + var it = options(packet, opt); + while (try it.next()) |o| { + if (o.code == code) return o; + } + return null; +} + +/// Address families in the EDNS Client Subnet option, from the IANA Address +/// Family Numbers registry. +pub const ecs_family_ipv4: u16 = 1; +pub const ecs_family_ipv6: u16 = 2; + +pub const Ecs = struct { + family: u16, + source_prefix: u8, + scope_prefix: u8, + /// The truncated address, `ceil(source_prefix / 8)` bytes, as a slice into + /// the option data. + address: []const u8, +}; + +pub const EcsError = error{BadEcs}; + +/// RFC 7871 §6: FAMILY, SOURCE PREFIX-LENGTH, SCOPE PREFIX-LENGTH, then only +/// as many address bytes as the source prefix covers. +pub fn parseEcs(data: []const u8) EcsError!Ecs { + if (data.len < 4) return error.BadEcs; + const family = std.mem.readInt(u16, data[0..2], .big); + const source_prefix = data[2]; + const scope_prefix = data[3]; + + const max_prefix: u16 = switch (family) { + ecs_family_ipv4 => 32, + ecs_family_ipv6 => 128, + // An unknown family has no known address width, so only the encoded + // length can be checked. + else => 255, + }; + if (source_prefix > max_prefix) return error.BadEcs; + if (scope_prefix > max_prefix) return error.BadEcs; + + // RFC 7871 §6 truncates the address to the source prefix and pads the last + // octet with zero bits, so a prefix of 0 carries no address bytes at all. + const address_len = (@as(usize, source_prefix) + 7) / 8; + if (data.len - 4 != address_len) return error.BadEcs; + + const significant_bits: u3 = @intCast(source_prefix % 8); + if (significant_bits != 0) { + const padding_mask = @as(u8, 0xff) >> significant_bits; + if (data[3 + address_len] & padding_mask != 0) return error.BadEcs; + } + + return .{ + .family = family, + .source_prefix = source_prefix, + .scope_prefix = scope_prefix, + .address = data[4..], + }; +} + +/// Writes the OPT record: root owner name, type OPT, the payload size in +/// CLASS, the flags in TTL, then the option list verbatim. +pub fn encodeOpt(opt: OptRecord, options_bytes: []const u8, w: *Writer) (Writer.Error || error{OptionsTooLong})!void { + if (options_bytes.len > std.math.maxInt(u16)) return error.OptionsTooLong; + try w.writeByte(0); + try w.writeInt(u16, @intFromEnum(types.Type.opt), .big); + try w.writeInt(u16, opt.udp_payload_size, .big); + try w.writeInt(u32, ttlFrom(opt), .big); + try w.writeInt(u16, @intCast(options_bytes.len), .big); + try w.writeAll(options_bytes); +} + +fn ttlFrom(opt: OptRecord) u32 { + return (@as(u32, opt.extended_rcode) << 24) | + (@as(u32, opt.version) << 16) | + (@as(u32, @intFromBool(opt.do_bit)) << 15); +} + +/// The full 12-bit RCODE (RFC 6891 §6.1.3): the OPT record supplies the upper +/// eight bits, the header the lower four. Without an OPT record the value is +/// just the header's four bits. +pub fn extendedRcode(header_rcode: types.Rcode, opt: ?OptRecord) u12 { + const low: u12 = @intFromEnum(header_rcode); + const o = opt orelse return low; + return (@as(u12, o.extended_rcode) << 4) | low; +} + +const testing = std.testing; + +/// An OPT record with a 4096-byte payload size, DO set, and no options. +const opt_do = "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00"; + +test "parseOpt reads payload size, version and the DO bit" { + const r = try record.parse(opt_do, 0); + const opt = try parseOpt(opt_do, r.record); + try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size); + try testing.expectEqual(@as(u8, 0), opt.extended_rcode); + try testing.expectEqual(@as(u8, 0), opt.version); + try testing.expectEqual(true, opt.do_bit); + try testing.expectEqual(@as(usize, 0), opt.options.len); +} + +test "parseOpt reads a cleared DO bit and an extended rcode" { + // TTL 0x01_00_0000: extended rcode 1, version 0, DO clear. + const packet = "\x00\x00\x29\x02\x00\x01\x00\x00\x00\x00\x00"; + const r = try record.parse(packet, 0); + const opt = try parseOpt(packet, r.record); + try testing.expectEqual(@as(u16, 512), opt.udp_payload_size); + try testing.expectEqual(@as(u8, 1), opt.extended_rcode); + try testing.expectEqual(false, opt.do_bit); +} + +test "parseOpt keeps an unknown EDNS version" { + const packet = "\x00\x00\x29\x10\x00\x00\x01\x00\x00\x00\x00"; + const r = try record.parse(packet, 0); + const opt = try parseOpt(packet, r.record); + try testing.expectEqual(@as(u8, 1), opt.version); +} + +test "parseOpt rejects the wrong type and a non-root owner name" { + const not_opt = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04"; + const r = try record.parse(not_opt, 0); + try testing.expectError(error.NotOpt, parseOpt(not_opt, r.record)); + + const named = "\x03com\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + const rn = try record.parse(named, 0); + try testing.expectEqual(types.Type.opt, rn.record.rtype); + try testing.expectError(error.NotOpt, parseOpt(named, rn.record)); +} + +test "option iterator yields every option" { + // Two options: ECS with 7 bytes, code 12 (padding) with 2 bytes. + const packet = "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x11" ++ + "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++ + "\x00\x0c\x00\x02\x00\x00"; + const r = try record.parse(packet, 0); + const opt = try parseOpt(packet, r.record); + try testing.expectEqual(@as(usize, 17), opt.options.len); + + var it = options(packet, opt); + const first = (try it.next()).?; + try testing.expectEqual(ecs_option_code, first.code); + try testing.expectEqualSlices(u8, "\x00\x01\x18\x00\xc0\x00\x02", first.data); + + const second = (try it.next()).?; + try testing.expectEqual(@as(u16, 12), second.code); + try testing.expectEqualSlices(u8, "\x00\x00", second.data); + + try testing.expectEqual(@as(?Option, null), try it.next()); + + const found = (try findOption(packet, opt, ecs_option_code)).?; + try testing.expectEqualSlices(u8, first.data, found.data); + try testing.expectEqual(@as(?Option, null), try findOption(packet, opt, 99)); +} + +test "option iterator accepts a zero-length option body" { + const packet = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x04\x00\x0c\x00\x00"; + const r = try record.parse(packet, 0); + const opt = try parseOpt(packet, r.record); + var it = options(packet, opt); + const only = (try it.next()).?; + try testing.expectEqual(@as(u16, 12), only.code); + try testing.expectEqual(@as(usize, 0), only.data.len); + try testing.expectEqual(@as(?Option, null), try it.next()); +} + +test "parseOpt rejects a malformed option list" { + // Option length claims 8 bytes but only 4 follow. + const overrun = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x08" ++ + "\x00\x08\x00\x08\x00\x01\x18\x00"; + const ro = try record.parse(overrun, 0); + try testing.expectError(error.BadOption, parseOpt(overrun, ro.record)); + + // A trailing partial option header (three bytes, not four). + const partial = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x03\x00\x08\x00"; + const rp = try record.parse(partial, 0); + try testing.expectError(error.BadOption, parseOpt(partial, rp.record)); +} + +test "parseEcs reads an IPv4 subnet" { + const ecs = try parseEcs("\x00\x01\x18\x00\xc0\x00\x02"); + try testing.expectEqual(ecs_family_ipv4, ecs.family); + try testing.expectEqual(@as(u8, 24), ecs.source_prefix); + try testing.expectEqual(@as(u8, 0), ecs.scope_prefix); + try testing.expectEqualSlices(u8, "\xc0\x00\x02", ecs.address); +} + +test "parseEcs reads an IPv6 subnet" { + const ecs = try parseEcs("\x00\x02\x38\x38\x20\x01\x0d\xb8\x00\x00\x00"); + try testing.expectEqual(ecs_family_ipv6, ecs.family); + try testing.expectEqual(@as(u8, 56), ecs.source_prefix); + try testing.expectEqual(@as(u8, 56), ecs.scope_prefix); + try testing.expectEqual(@as(usize, 7), ecs.address.len); +} + +test "parseEcs reads a zero-length prefix" { + const ecs = try parseEcs("\x00\x01\x00\x00"); + try testing.expectEqual(@as(u8, 0), ecs.source_prefix); + try testing.expectEqual(@as(usize, 0), ecs.address.len); + + // A prefix of 0 covers no address byte, so any address byte is a length + // mismatch. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\x00")); + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\xc0\x00\x02\x00")); +} + +test "parseEcs rejects nonzero padding bits past the source prefix" { + // IPv4 /25: the low seven bits of the fourth address byte must be zero. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x01")); + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\xff")); + + const zero_padded = try parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x80"); + try testing.expectEqual(@as(u8, 25), zero_padded.source_prefix); + try testing.expectEqualSlices(u8, "\xc0\x00\x02\x80", zero_padded.address); + + // IPv4 /20: the low four bits of the third address byte must be zero. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x14\x00\xc0\x00\x0f")); + try testing.expectEqualSlices(u8, "\xc0\x00\x00", (try parseEcs("\x00\x01\x14\x00\xc0\x00\x00")).address); + + // IPv6 /57: the low seven bits of the eighth address byte must be zero. + try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x7f")); + try testing.expectEqualSlices( + u8, + "\x20\x01\x0d\xb8\x00\x00\x00\x80", + (try parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x80")).address, + ); + + // An unknown family uses the same encoding, so the rule holds there too. + try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x03\x00\xff")); + try testing.expectEqualSlices(u8, "\xe0", (try parseEcs("\x12\x34\x03\x00\xe0")).address); +} + +test "parseEcs rejects malformed options" { + // Shorter than the fixed fields. + try testing.expectError(error.BadEcs, parseEcs("")); + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18")); + // Prefix wider than the family allows. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x21" ++ "\x00\x00\x00\x00\x00")); + try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x81" ++ "\x00" ** 17)); + // Scope wider than the family allows. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x21\xc0\x00\x02")); + // Address shorter than the prefix needs. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00")); + // Address longer than the prefix needs. + try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00\x02\x00")); +} + +test "parseEcs accepts an unknown family with a consistent length" { + const ecs = try parseEcs("\x12\x34\x08\x00\xff"); + try testing.expectEqual(@as(u16, 0x1234), ecs.family); + try testing.expectEqualSlices(u8, "\xff", ecs.address); + try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x08\x00\xff\xff")); +} + +test "encodeOpt round-trips through record parse and parseOpt" { + const original: OptRecord = .{ + .udp_payload_size = 1232, + .extended_rcode = 0x10, + .version = 0, + .do_bit = true, + .options = .{ .offset = 0, .len = 0 }, + }; + const options_bytes = "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02"; + + var buf: [64]u8 = undefined; + var w = Writer.fixed(&buf); + try encodeOpt(original, options_bytes, &w); + const bytes = w.buffered(); + + const r = try record.parse(bytes, 0); + try testing.expectEqual(@as(usize, bytes.len), r.end); + const opt = try parseOpt(bytes, r.record); + try testing.expectEqual(original.udp_payload_size, opt.udp_payload_size); + try testing.expectEqual(original.extended_rcode, opt.extended_rcode); + try testing.expectEqual(original.version, opt.version); + try testing.expectEqual(original.do_bit, opt.do_bit); + try testing.expectEqualSlices(u8, options_bytes, opt.options.slice(bytes)); + + const ecs = try parseEcs((try findOption(bytes, opt, ecs_option_code)).?.data); + try testing.expectEqual(@as(u8, 24), ecs.source_prefix); +} + +test "encodeOpt round-trips both DO states" { + for ([_]bool{ false, true }) |do_bit| { + const opt: OptRecord = .{ + .udp_payload_size = 4096, + .extended_rcode = 0, + .version = 0, + .do_bit = do_bit, + .options = .{ .offset = 0, .len = 0 }, + }; + var buf: [32]u8 = undefined; + var w = Writer.fixed(&buf); + try encodeOpt(opt, "", &w); + const bytes = w.buffered(); + try testing.expectEqualSlices(u8, opt_do[0..3], bytes[0..3]); + const r = try record.parse(bytes, 0); + try testing.expectEqual(do_bit, (try parseOpt(bytes, r.record)).do_bit); + } +} + +test "encodeOpt leaves the Z bits clear" { + const opt: OptRecord = .{ + .udp_payload_size = 512, + .extended_rcode = 0, + .version = 0, + .do_bit = true, + .options = .{ .offset = 0, .len = 0 }, + }; + try testing.expectEqual(@as(u32, 0x0000_8000), ttlFrom(opt)); +} + +test "encodeOpt reports a short buffer" { + const opt: OptRecord = .{ + .udp_payload_size = 512, + .extended_rcode = 0, + .version = 0, + .do_bit = false, + .options = .{ .offset = 0, .len = 0 }, + }; + var buf: [8]u8 = undefined; + var w = Writer.fixed(&buf); + try testing.expectError(error.WriteFailed, encodeOpt(opt, "", &w)); +} + +test "extendedRcode composes the twelve bits" { + try testing.expectEqual(@as(u12, 3), extendedRcode(.nx_domain, null)); + + const opt: OptRecord = .{ + .udp_payload_size = 4096, + .extended_rcode = 1, + .version = 0, + .do_bit = false, + .options = .{ .offset = 0, .len = 0 }, + }; + // Extended rcode 1 over header rcode 0 is BADVERS (16). + try testing.expectEqual(@as(u12, 16), extendedRcode(.no_error, opt)); + + var not_auth = opt; + not_auth.extended_rcode = 0; + try testing.expectEqual(@as(u12, 9), extendedRcode(.not_auth, not_auth)); + + var high = opt; + high.extended_rcode = 0xff; + try testing.expectEqual(@as(u12, 0xfff), extendedRcode(@enumFromInt(15), high)); +} diff --git a/src/dns/header.zig b/src/dns/header.zig new file mode 100644 index 0000000..c598ded --- /dev/null +++ b/src/dns/header.zig @@ -0,0 +1,244 @@ +//! The 12-byte DNS message header (RFC 1035 §4.1.1). Pure: no allocation, +//! no `std.Io`. +//! +//! Encode convention for the whole `dns/` module: fixed-size items encode into +//! a caller-provided buffer; variable-size items encode through a +//! `*std.Io.Writer`. The header is fixed-size, so it takes a `*[12]u8`. + +const std = @import("std"); +const types = @import("types.zig"); + +/// The second 16-bit word of the header, laid out from the least significant +/// bit up. `z` carries the three reserved bits (RFC 1035 §4.1.1; bits later +/// claimed as AD and CD by RFC 4035) so that an unknown bit pattern survives a +/// parse/encode round-trip unchanged. +pub const Flags = packed struct(u16) { + rcode: types.Rcode, + z: u3, + ra: bool, + rd: bool, + tc: bool, + aa: bool, + opcode: types.Opcode, + qr: bool, + + pub fn fromInt(value: u16) Flags { + return @bitCast(value); + } + + pub fn toInt(self: Flags) u16 { + return @bitCast(self); + } +}; + +pub const ParseError = error{Truncated}; + +pub const Header = struct { + id: u16, + flags: Flags, + qdcount: u16, + ancount: u16, + nscount: u16, + arcount: u16, +}; + +/// Reads the first `types.header_len` bytes. Trailing bytes are ignored. +pub fn parse(bytes: []const u8) ParseError!Header { + if (bytes.len < types.header_len) return error.Truncated; + const b = bytes[0..types.header_len]; + return .{ + .id = std.mem.readInt(u16, b[0..2], .big), + .flags = Flags.fromInt(std.mem.readInt(u16, b[2..4], .big)), + .qdcount = std.mem.readInt(u16, b[4..6], .big), + .ancount = std.mem.readInt(u16, b[6..8], .big), + .nscount = std.mem.readInt(u16, b[8..10], .big), + .arcount = std.mem.readInt(u16, b[10..12], .big), + }; +} + +pub fn encode(h: Header, out: *[types.header_len]u8) void { + std.mem.writeInt(u16, out[0..2], h.id, .big); + std.mem.writeInt(u16, out[2..4], h.flags.toInt(), .big); + std.mem.writeInt(u16, out[4..6], h.qdcount, .big); + std.mem.writeInt(u16, out[6..8], h.ancount, .big); + std.mem.writeInt(u16, out[8..10], h.nscount, .big); + std.mem.writeInt(u16, out[10..12], h.arcount, .big); +} + +const testing = std.testing; + +test "flags bit positions follow RFC 1035" { + try testing.expectEqual(@as(u16, 0x8000), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = false, + .tc = false, + .aa = false, + .opcode = .query, + .qr = true, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x2800), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = false, + .tc = false, + .aa = false, + .opcode = .update, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0400), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = false, + .tc = false, + .aa = true, + .opcode = .query, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0200), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = false, + .tc = true, + .aa = false, + .opcode = .query, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0100), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = true, + .tc = false, + .aa = false, + .opcode = .query, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0080), (Flags{ + .rcode = .no_error, + .z = 0, + .ra = true, + .rd = false, + .tc = false, + .aa = false, + .opcode = .query, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0070), (Flags{ + .rcode = .no_error, + .z = 7, + .ra = false, + .rd = false, + .tc = false, + .aa = false, + .opcode = .query, + .qr = false, + }).toInt()); + + try testing.expectEqual(@as(u16, 0x0003), (Flags{ + .rcode = .nx_domain, + .z = 0, + .ra = false, + .rd = false, + .tc = false, + .aa = false, + .opcode = .query, + .qr = false, + }).toInt()); +} + +test "every flags word round-trips, reserved bits included" { + var value: u32 = 0; + while (value <= std.math.maxInt(u16)) : (value += 1) { + const word: u16 = @intCast(value); + try testing.expectEqual(word, Flags.fromInt(word).toInt()); + } +} + +test "header parse decodes a standard query" { + const bytes = [_]u8{ + 0xab, 0xcd, // id + 0x01, 0x20, // flags: RD set, z = 2 + 0x00, 0x01, // qdcount + 0x00, 0x00, // ancount + 0x00, 0x00, // nscount + 0x00, 0x01, // arcount + }; + const h = try parse(&bytes); + try testing.expectEqual(@as(u16, 0xabcd), h.id); + try testing.expectEqual(false, h.flags.qr); + try testing.expectEqual(types.Opcode.query, h.flags.opcode); + try testing.expectEqual(true, h.flags.rd); + try testing.expectEqual(false, h.flags.ra); + try testing.expectEqual(@as(u3, 2), h.flags.z); + try testing.expectEqual(types.Rcode.no_error, h.flags.rcode); + try testing.expectEqual(@as(u16, 1), h.qdcount); + try testing.expectEqual(@as(u16, 0), h.ancount); + try testing.expectEqual(@as(u16, 0), h.nscount); + try testing.expectEqual(@as(u16, 1), h.arcount); +} + +test "header round-trips through encode, unknown opcode and rcode included" { + const original: Header = .{ + .id = 0x1234, + .flags = .{ + .rcode = @enumFromInt(15), + .z = 5, + .ra = true, + .rd = true, + .tc = true, + .aa = true, + .opcode = @enumFromInt(3), + .qr = true, + }, + .qdcount = 1, + .ancount = 2, + .nscount = 3, + .arcount = 4, + }; + + var buf: [types.header_len]u8 = undefined; + encode(original, &buf); + const parsed = try parse(&buf); + try testing.expectEqual(original, parsed); +} + +test "parse ignores bytes past the header" { + const bytes = [_]u8{0xff} ** (types.header_len + 8); + const h = try parse(&bytes); + try testing.expectEqual(@as(u16, 0xffff), h.arcount); +} + +test "truncated input" { + var i: usize = 0; + while (i < types.header_len) : (i += 1) { + const bytes = [_]u8{0} ** types.header_len; + try testing.expectError(error.Truncated, parse(bytes[0..i])); + } +} + +test "encode writes big-endian counts" { + const h: Header = .{ + .id = 0x0102, + .flags = Flags.fromInt(0x8180), + .qdcount = 0x0304, + .ancount = 0x0506, + .nscount = 0x0708, + .arcount = 0x090a, + }; + var buf: [types.header_len]u8 = undefined; + encode(h, &buf); + try testing.expectEqualSlices(u8, &.{ + 0x01, 0x02, 0x81, 0x80, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + }, &buf); +} diff --git a/src/dns/name.zig b/src/dns/name.zig new file mode 100644 index 0000000..3e370b0 --- /dev/null +++ b/src/dns/name.zig @@ -0,0 +1,394 @@ +//! Domain names in wire form (RFC 1035 §3.1, §4.1.4). Pure: no allocation, +//! no `std.Io` beyond writing encoded bytes to a caller's writer. +//! +//! A `Name` always holds the decoded, uncompressed form: a sequence of +//! length-prefixed labels ended by a zero byte, which `len` counts. + +const std = @import("std"); +const types = @import("types.zig"); +const Writer = std.Io.Writer; + +pub const Name = struct { + bytes: [types.max_name_len]u8, + len: u8, + + /// The uncompressed wire bytes, terminating zero included. + pub fn wire(self: *const Name) []const u8 { + return self.bytes[0..self.len]; + } + + pub fn isRoot(self: Name) bool { + return self.len == 1 and self.bytes[0] == 0; + } + + /// Labels excluding the root. The root name has zero labels. + pub fn labelCount(self: Name) usize { + var count: usize = 0; + var i: usize = 0; + while (i < self.len and self.bytes[i] != 0) : (i += 1 + self.bytes[i]) count += 1; + return count; + } +}; + +pub const ParseError = error{ + Truncated, + LabelTooLong, + NameTooLong, + BadPointer, + TooManyJumps, +}; + +pub const Parsed = struct { + name: Name, + /// Offset just past the name as it appears at `offset`: past the first + /// compression pointer, or past the terminating zero when there is none. + end: usize, +}; + +/// Decodes the name at `offset`, following compression pointers within +/// `packet`. Every pointer must target an offset strictly lower than the +/// pointer's own offset, which makes a chain strictly decreasing and therefore +/// finite; `types.max_compression_jumps` bounds the work regardless. +pub fn parse(packet: []const u8, offset: usize) ParseError!Parsed { + var name: Name = .{ .bytes = undefined, .len = 0 }; + var pos = offset; + var end: ?usize = null; + var jumps: usize = 0; + + while (true) { + if (pos >= packet.len) return error.Truncated; + const control = packet[pos]; + switch (control & 0xc0) { + 0x00 => { + if (control == 0) { + std.debug.assert(name.len < types.max_name_len); + name.bytes[name.len] = 0; + name.len += 1; + return .{ .name = name, .end = end orelse pos + 1 }; + } + const label_len: usize = control; + if (pos + 1 + label_len > packet.len) return error.Truncated; + // The terminating zero still has to fit. + if (name.len + 1 + label_len + 1 > types.max_name_len) return error.NameTooLong; + name.bytes[name.len] = control; + @memcpy(name.bytes[name.len + 1 ..][0..label_len], packet[pos + 1 ..][0..label_len]); + name.len += @intCast(1 + label_len); + pos += 1 + label_len; + }, + 0xc0 => { + if (pos + 1 >= packet.len) return error.Truncated; + const target = (@as(usize, control & 0x3f) << 8) | packet[pos + 1]; + if (end == null) end = pos + 2; + if (target >= pos) return error.BadPointer; + jumps += 1; + if (jumps > types.max_compression_jumps) return error.TooManyJumps; + pos = target; + }, + // 0x40 and 0x80 are reserved label types (RFC 6891 §3 retired the + // only assignment). A label of 64 bytes or more encodes into this + // range, which is the case worth naming. + else => return error.LabelTooLong, + } + } +} + +/// Writes the name uncompressed. Compression on encode is out of scope: at +/// household query volumes the saved bytes do not pay for the offset +/// bookkeeping, and uncompressed output is always valid (RFC 1035 §4.1.4). +pub fn encode(name: Name, w: *Writer) Writer.Error!void { + try w.writeAll(name.wire()); +} + +pub const FromTextError = error{ + EmptyLabel, + LabelTooLong, + NameTooLong, +}; + +/// Parses presentation form. A single trailing dot is optional; "" and "." +/// both denote the root. Bytes inside labels pass through opaquely, so no +/// escape sequences and no punycode. +pub fn fromText(text: []const u8) FromTextError!Name { + var name: Name = .{ .bytes = undefined, .len = 0 }; + + var rest = text; + if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; + if (rest.len == 0) { + name.bytes[0] = 0; + name.len = 1; + return name; + } + + var it = std.mem.splitScalar(u8, rest, '.'); + while (it.next()) |label| { + if (label.len == 0) return error.EmptyLabel; + if (label.len > types.max_label_len) return error.LabelTooLong; + if (name.len + 1 + label.len + 1 > types.max_name_len) return error.NameTooLong; + name.bytes[name.len] = @intCast(label.len); + @memcpy(name.bytes[name.len + 1 ..][0..label.len], label); + name.len += @intCast(1 + label.len); + } + + name.bytes[name.len] = 0; + name.len += 1; + return name; +} + +/// Writes presentation form: labels joined by dots, no trailing dot. The root +/// name writes as ".". +pub fn formatText(name: Name, w: *Writer) Writer.Error!void { + if (name.isRoot()) { + try w.writeByte('.'); + return; + } + var i: usize = 0; + var first = true; + while (i < name.len and name.bytes[i] != 0) { + const label_len = name.bytes[i]; + if (!first) try w.writeByte('.'); + try w.writeAll(name.bytes[i + 1 ..][0..label_len]); + first = false; + i += 1 + @as(usize, label_len); + } +} + +/// Names compare case-insensitively over ASCII only (RFC 1035 §2.3.3); bytes +/// outside A-Z and a-z compare exactly. +pub fn eqlIgnoreCase(a: Name, b: Name) bool { + if (a.len != b.len) return false; + var i: usize = 0; + while (i < a.len and a.bytes[i] != 0) { + const label_len = a.bytes[i]; + if (b.bytes[i] != label_len) return false; + const end = i + 1 + @as(usize, label_len); + if (!std.ascii.eqlIgnoreCase(a.bytes[i + 1 .. end], b.bytes[i + 1 .. end])) return false; + i = end; + } + return i < b.len and b.bytes[i] == 0; +} + +const testing = std.testing; + +fn expectText(expected: []const u8, name: Name) !void { + var buf: [512]u8 = undefined; + var w = Writer.fixed(&buf); + try formatText(name, &w); + try testing.expectEqualStrings(expected, w.buffered()); +} + +fn expectWire(expected: []const u8, name: Name) !void { + var buf: [512]u8 = undefined; + var w = Writer.fixed(&buf); + try encode(name, &w); + try testing.expectEqualStrings(expected, w.buffered()); +} + +test "fromText builds wire form" { + const n = try fromText("example.com."); + try expectWire("\x07example\x03com\x00", n); + try testing.expectEqual(@as(u8, 13), n.len); + try testing.expectEqual(@as(usize, 2), n.labelCount()); + try testing.expect(!n.isRoot()); +} + +test "fromText accepts a missing trailing dot" { + const with_dot = try fromText("example.com."); + const without_dot = try fromText("example.com"); + try testing.expectEqualSlices(u8, with_dot.wire(), without_dot.wire()); +} + +test "root name" { + for ([_][]const u8{ "", "." }) |text| { + const n = try fromText(text); + try testing.expect(n.isRoot()); + try testing.expectEqual(@as(usize, 0), n.labelCount()); + try expectWire("\x00", n); + try expectText(".", n); + } +} + +test "text round-trips" { + for ([_][]const u8{ ".", "com", "example.com", "a.b.c.d.e.f" }) |text| { + try expectText(text, try fromText(text)); + } +} + +test "fromText rejects empty labels" { + try testing.expectError(error.EmptyLabel, fromText("example..com")); + try testing.expectError(error.EmptyLabel, fromText(".example.com")); + try testing.expectError(error.EmptyLabel, fromText("..")); +} + +test "fromText rejects an oversize label" { + const ok = "a" ** types.max_label_len; + _ = try fromText(ok ++ ".com"); + const too_long = "a" ** (types.max_label_len + 1); + try testing.expectError(error.LabelTooLong, fromText(too_long ++ ".com")); +} + +test "fromText rejects an oversize name" { + // Four labels of 63 bytes encode to 4 * 64 + 1 = 257 bytes. + const label = "a" ** types.max_label_len; + try testing.expectError( + error.NameTooLong, + fromText(label ++ "." ++ label ++ "." ++ label ++ "." ++ label), + ); + // Three of those (3 * 64 = 192) plus a 61-byte label (62) plus the + // terminating zero reach exactly 255. + const last = "b" ** 61; + const max = try fromText(label ++ "." ++ label ++ "." ++ label ++ "." ++ last); + try testing.expectEqual(@as(u8, types.max_name_len), max.len); + + const one_over = "b" ** 62; + try testing.expectError( + error.NameTooLong, + fromText(label ++ "." ++ label ++ "." ++ label ++ "." ++ one_over), + ); +} + +test "parse decodes an uncompressed name" { + const packet = "\x07example\x03com\x00"; + const r = try parse(packet, 0); + try expectText("example.com", r.name); + try testing.expectEqual(@as(usize, 13), r.end); +} + +test "parse follows a compression pointer chain" { + // 0: "com" root at the top, then "example" pointing at it, then "www" + // pointing at "example.com". + const packet = + "\x03com\x00" ++ // offset 0, ends at 5 + "\x07example\xc0\x00" ++ // offset 5, ends at 15 + "\x03www\xc0\x05"; // offset 15, ends at 21 + + const com = try parse(packet, 0); + try expectText("com", com.name); + try testing.expectEqual(@as(usize, 5), com.end); + + const example = try parse(packet, 5); + try expectText("example.com", example.name); + try testing.expectEqual(@as(usize, 15), example.end); + + const www = try parse(packet, 15); + try expectText("www.example.com", www.name); + try testing.expectEqual(@as(usize, 21), www.end); + try testing.expectEqualSlices(u8, (try fromText("www.example.com")).wire(), www.name.wire()); +} + +test "parse rejects a pointer to itself" { + const packet = "\xc0\x00"; + try testing.expectError(error.BadPointer, parse(packet, 0)); +} + +test "parse rejects a two-pointer loop" { + // The pointer at offset 2 targets 0, which points forward to 2. + const packet = "\xc0\x02\xc0\x00"; + try testing.expectError(error.BadPointer, parse(packet, 0)); + try testing.expectError(error.BadPointer, parse(packet, 2)); +} + +test "parse rejects a forward pointer" { + const packet = "\xc0\x04\x00\x00\x03com\x00"; + try testing.expectError(error.BadPointer, parse(packet, 0)); +} + +test "parse rejects a pointer to its own offset" { + const packet = "\x00\x00\xc0\x02"; + try testing.expectError(error.BadPointer, parse(packet, 2)); +} + +test "parse caps the jump count" { + // A descending chain of two-byte pointers: each entry at offset 2*i points + // to 2*(i-1). Entry 0 is the root label, so a chain of n entries costs n-1 + // jumps from the last one. + const chain_len = types.max_compression_jumps + 2; + var packet: [chain_len * 2]u8 = undefined; + packet[0] = 0; + packet[1] = 0; + var i: usize = 1; + while (i < chain_len) : (i += 1) { + packet[i * 2] = 0xc0; + packet[i * 2 + 1] = @intCast((i - 1) * 2); + } + + const at_limit = try parse(&packet, (types.max_compression_jumps) * 2); + try testing.expect(at_limit.name.isRoot()); + + try testing.expectError( + error.TooManyJumps, + parse(&packet, (types.max_compression_jumps + 1) * 2), + ); +} + +test "parse rejects a reserved label type" { + try testing.expectError(error.LabelTooLong, parse("\x40abc\x00", 0)); + try testing.expectError(error.LabelTooLong, parse("\x80abc\x00", 0)); +} + +test "parse rejects truncation" { + // No terminating zero at all. + try testing.expectError(error.Truncated, parse("\x03com", 0)); + // Label claims more bytes than the packet holds. + try testing.expectError(error.Truncated, parse("\x07exa", 0)); + // Pointer's second byte is missing. + try testing.expectError(error.Truncated, parse("\x00\xc0", 1)); + // Offset past the end. + try testing.expectError(error.Truncated, parse("\x00", 1)); + try testing.expectError(error.Truncated, parse("", 0)); +} + +test "parse rejects a name longer than the limit" { + // Five 63-byte labels through pointers would exceed 255 bytes. + const label = "\x3f" ++ "a" ** types.max_label_len; + const packet = label ++ label ++ label ++ label ++ label ++ "\x00"; + try testing.expectError(error.NameTooLong, parse(packet, 0)); +} + +test "parse round-trips the maximum length name" { + const label = "\x3f" ++ "a" ** types.max_label_len; + const last = "\x3d" ++ "b" ** 61; + const packet = label ++ label ++ label ++ last ++ "\x00"; + const r = try parse(packet, 0); + try testing.expectEqual(@as(u8, types.max_name_len), r.name.len); + try testing.expectEqual(@as(usize, packet.len), r.end); + try expectWire(packet, r.name); +} + +test "parse then encode round-trips through a pointer" { + const packet = "\x03com\x00\x07example\xc0\x00"; + const r = try parse(packet, 5); + try expectWire("\x07example\x03com\x00", r.name); +} + +test "eqlIgnoreCase folds ASCII only" { + const lower = try fromText("example.com"); + const upper = try fromText("EXAMPLE.COM"); + const mixed = try fromText("ExAmPlE.cOm"); + try testing.expect(eqlIgnoreCase(lower, upper)); + try testing.expect(eqlIgnoreCase(lower, mixed)); + try testing.expect(eqlIgnoreCase(lower, lower)); + + const other = try fromText("example.net"); + try testing.expect(!eqlIgnoreCase(lower, other)); + + const shorter = try fromText("com"); + try testing.expect(!eqlIgnoreCase(lower, shorter)); + + const root = try fromText("."); + try testing.expect(eqlIgnoreCase(root, try fromText(""))); + try testing.expect(!eqlIgnoreCase(root, lower)); + + // Same length, different label split: "aa.b" versus "a.ab". + try testing.expect(!eqlIgnoreCase(try fromText("aa.b"), try fromText("a.ab"))); + + // Byte 0xc0 is not an ASCII letter and must compare exactly. + const high_a = try fromText("\xc0"); + const high_b = try fromText("\xe0"); + try testing.expect(!eqlIgnoreCase(high_a, high_b)); +} + +test "labelCount" { + try testing.expectEqual(@as(usize, 0), (try fromText(".")).labelCount()); + try testing.expectEqual(@as(usize, 1), (try fromText("com")).labelCount()); + try testing.expectEqual(@as(usize, 3), (try fromText("www.example.com")).labelCount()); +} diff --git a/src/dns/packet.zig b/src/dns/packet.zig new file mode 100644 index 0000000..e2a7266 --- /dev/null +++ b/src/dns/packet.zig @@ -0,0 +1,868 @@ +//! Whole DNS messages (RFC 1035 §4.1): structural validation, section +//! iteration, in-place mutation of a raw buffer, and a builder for synthesized +//! replies. Pure: no allocation, no `std.Io` beyond writing encoded bytes to a +//! caller's buffer. +//! +//! `parse` separates two failure classes because the server answers them +//! differently (PLAN §6.1): +//! +//! - `error.Truncated` — fewer than the 12 header bytes arrived. There is no +//! ID and no question to echo, so no reply is possible: the caller drops +//! the datagram silently. +//! - `WalkError` — the header is intact but a section is malformed. The +//! caller can echo the ID and answer FORMERR. +//! +//! `name.ParseError.Truncated` therefore cannot pass through unchanged: a name +//! that runs off the end of a packet is a malformed section, not a short +//! header, and mapping it onto `error.Truncated` would turn a FORMERR into a +//! silent drop. `mapNameError` performs that translation once. + +const std = @import("std"); +const types = @import("types.zig"); +const header = @import("header.zig"); +const name = @import("name.zig"); +const question = @import("question.zig"); +const record = @import("record.zig"); +const edns = @import("edns.zig"); +const Writer = std.Io.Writer; + +/// A malformed section in a packet whose header is intact. +pub const WalkError = error{ + /// A name is undecodable: a bad compression pointer, an over-long label or + /// name, or too many pointer jumps. + BadName, + /// A question or record runs past the end of the packet. A count field + /// larger than the records actually present lands here too, because the + /// walk then reads past the last record. + SectionOverrun, + /// Bytes follow the last counted record. RFC 1035 §4.1 gives a message no + /// padding outside its four sections, and EDNS padding (RFC 7830) lives + /// inside the OPT record's option list, so nothing legitimate lands here. + TrailingBytes, + /// More than one OPT record. RFC 6891 §6.1.1 allows at most one per + /// message and requires FORMERR for the rest. + MultipleOptRecords, +}; + +pub const ParseError = error{Truncated} || WalkError; + +fn mapNameError(err: name.ParseError) WalkError { + return switch (err) { + error.Truncated => error.SectionOverrun, + error.LabelTooLong, error.NameTooLong, error.BadPointer, error.TooManyJumps => error.BadName, + }; +} + +/// A validated view of a message: the bytes plus the decoded header. Sections +/// are not stored — iterators re-walk the bytes on demand, which keeps the +/// struct small enough to pass by value and free of pointers into itself. +pub const Packet = struct { + bytes: []const u8, + header: header.Header, +}; + +/// Decodes the header and walks every section once, bounds-checking each name +/// and record. The walk must land exactly on the end of the buffer and must +/// meet at most one OPT record, so a `Packet` from here has no bytes outside +/// its sections and at most one OPT. +pub fn parse(bytes: []const u8) ParseError!Packet { + const h = header.parse(bytes) catch |err| switch (err) { + error.Truncated => return error.Truncated, + }; + + var pos: usize = types.header_len; + var q: u16 = 0; + while (q < h.qdcount) : (q += 1) { + const parsed = question.parse(bytes, pos) catch |err| return mapNameError(err); + pos = parsed.end; + } + + const record_count = recordCount(h); + var opt_seen = false; + var r: u32 = 0; + while (r < record_count) : (r += 1) { + const parsed = record.parse(bytes, pos) catch |err| return mapNameError(err); + pos = parsed.end; + if (parsed.record.rtype == .opt) { + if (opt_seen) return error.MultipleOptRecords; + opt_seen = true; + } + } + + if (pos != bytes.len) return error.TrailingBytes; + return .{ .bytes = bytes, .header = h }; +} + +fn recordCount(h: header.Header) u32 { + return @as(u32, h.ancount) + @as(u32, h.nscount) + @as(u32, h.arcount); +} + +pub const QuestionIterator = struct { + packet: []const u8, + pos: usize, + remaining: u16, + + pub fn next(self: *QuestionIterator) WalkError!?question.Question { + if (self.remaining == 0) return null; + const parsed = question.parse(self.packet, self.pos) catch |err| return mapNameError(err); + self.pos = parsed.end; + self.remaining -= 1; + return parsed.question; + } +}; + +/// Records of one section. The section's first byte is only reachable by +/// walking everything before it, so the first `next` call does that walk; the +/// iterator is otherwise inert. +pub const RecordIterator = struct { + packet: []const u8, + skip_questions: u16, + skip_records: u32, + remaining: u16, + pos: usize, + positioned: bool, + + pub fn next(self: *RecordIterator) WalkError!?record.Record { + if (!self.positioned) try self.position(); + if (self.remaining == 0) return null; + const parsed = record.parse(self.packet, self.pos) catch |err| return mapNameError(err); + self.pos = parsed.end; + self.remaining -= 1; + return parsed.record; + } + + fn position(self: *RecordIterator) WalkError!void { + var pos: usize = types.header_len; + var q: u16 = 0; + while (q < self.skip_questions) : (q += 1) { + const parsed = question.parse(self.packet, pos) catch |err| return mapNameError(err); + pos = parsed.end; + } + var r: u32 = 0; + while (r < self.skip_records) : (r += 1) { + const parsed = record.parse(self.packet, pos) catch |err| return mapNameError(err); + pos = parsed.end; + } + self.pos = pos; + self.positioned = true; + } +}; + +pub fn questions(p: Packet) QuestionIterator { + return .{ .packet = p.bytes, .pos = types.header_len, .remaining = p.header.qdcount }; +} + +pub fn answers(p: Packet) RecordIterator { + return recordIterator(p, 0, p.header.ancount); +} + +pub fn authorities(p: Packet) RecordIterator { + return recordIterator(p, p.header.ancount, p.header.nscount); +} + +pub fn additionals(p: Packet) RecordIterator { + return recordIterator(p, recordCount(p.header) - p.header.arcount, p.header.arcount); +} + +fn recordIterator(p: Packet, skip_records: u32, remaining: u16) RecordIterator { + return .{ + .packet = p.bytes, + .skip_questions = p.header.qdcount, + .skip_records = skip_records, + .remaining = remaining, + .pos = types.header_len, + .positioned = false, + }; +} + +/// The first question, or null when there is none. A `Packet` from `parse` has +/// a decodable question section, so the parse below only fails for a `Packet` +/// assembled by hand around unvalidated bytes. +pub fn firstQuestion(p: Packet) ?question.Question { + var it = questions(p); + return it.next() catch null; +} + +/// The OPT record from the additional section, or null when there is none. A +/// `Packet` from `parse` carries at most one, so the search below finds either +/// nothing or that one record. The signature also accepts a `Packet` assembled +/// by hand around unvalidated bytes; for those the last OPT wins, which is what +/// a sequential walk of the section yields. +pub fn findOptRecord(p: Packet) ?record.Record { + var it = additionals(p); + var found: ?record.Record = null; + while (it.next() catch return found) |rec| { + if (rec.rtype == .opt) found = rec; + } + return found; +} + +/// Overwrites the message ID in place. Used on the cache hit path, where a +/// stored response answers a new query. +pub fn setId(bytes: []u8, id: u16) void { + std.debug.assert(bytes.len >= types.header_len); + std.mem.writeInt(u16, bytes[0..2], id, .big); +} + +/// TTL sits four bytes into the fixed fields and RDLENGTH's two bytes follow +/// it, so the TTL starts six bytes before the RDATA. +const ttl_bytes_before_rdata = 6; + +/// Ages every record in place by `elapsed_seconds`, saturating at zero, and +/// returns the smallest resulting TTL — or null when the message carries no +/// record whose TTL means anything. The caller decides what a small or absent +/// TTL means; this function only does the arithmetic. +/// +/// OPT records are skipped: RFC 6891 §6.1.3 reuses their TTL field for the +/// extended RCODE, the EDNS version and the DO bit, so subtracting from it +/// would corrupt the flags. +/// +/// A TTL with its top bit set is a wire-level oddity that RFC 2181 §8 says to +/// treat as zero. That is a caching decision, so it stays with the caller and +/// this function ages such a value like any other. +/// +/// A full structural validation runs before any byte changes, so malformed +/// input leaves the buffer exactly as it arrived. Aging and validating in one +/// pass would age the records before a malformed one and then report an error. +/// +/// One case still ends mid-way: an owner name may be a compression pointer into +/// an earlier record's TTL field, and aging that TTL can make the name +/// undecodable. The walk below therefore keeps checking each record instead of +/// trusting the validation above. On any error the buffer holds a partly aged +/// message that no longer parses, so a caller that gets an error must discard +/// the buffer rather than send it. +pub fn decrementTtls(bytes: []u8, elapsed_seconds: u32) ParseError!?u32 { + const p = try parse(bytes); + + var pos: usize = types.header_len; + var q: u16 = 0; + while (q < p.header.qdcount) : (q += 1) { + const parsed = question.parse(bytes, pos) catch |err| return mapNameError(err); + pos = parsed.end; + } + + var minimum: ?u32 = null; + const record_count = recordCount(p.header); + var r: u32 = 0; + while (r < record_count) : (r += 1) { + const parsed = record.parse(bytes, pos) catch |err| return mapNameError(err); + pos = parsed.end; + if (parsed.record.rtype == .opt) continue; + + const aged = parsed.record.ttl -| elapsed_seconds; + const ttl_offset = parsed.record.rdata.offset - ttl_bytes_before_rdata; + std.mem.writeInt(u32, bytes[ttl_offset..][0..4], aged, .big); + minimum = if (minimum) |m| @min(m, aged) else aged; + } + return minimum; +} + +/// Encodes a reply into a caller buffer. Mechanism only: which RCODE to set +/// and which answers to add is the caller's policy. +/// +/// Sections must be filled in wire order, so every `addAnswer` call has to +/// precede `addOptEcho` — the OPT record belongs to the additional section, and +/// an answer written after it would land in the wrong section. `addOptEcho` +/// also runs at most once, because RFC 6891 §6.1.1 allows one OPT record per +/// message. Both rules are programmer errors, so both are assertions. +pub const ResponseBuilder = struct { + writer: Writer, + header: header.Header, + opt_added: bool, + + pub const Error = Writer.Error || error{RdataTooLong}; + + /// Copies the request's ID, opcode and RD bit, marks the message a + /// response, and advertises recursion. The question is echoed when given, + /// as RFC 1035 §4.1.2 expects of a reply. + pub fn init(buf: []u8, request: header.Header, q: ?question.Question) Error!ResponseBuilder { + // A DNS message carries a two-byte length prefix over TCP + // (RFC 1035 §4.2.2), so 65535 bytes is the protocol maximum. Holding + // the buffer to it keeps the section counters from overflowing: the + // smallest possible record is 11 bytes. + std.debug.assert(buf.len <= std.math.maxInt(u16)); + + var self: ResponseBuilder = .{ + .writer = Writer.fixed(buf), + .header = .{ + .id = request.id, + .flags = .{ + .rcode = .no_error, + .z = 0, + .ra = true, + .rd = request.flags.rd, + .tc = false, + .aa = false, + .opcode = request.flags.opcode, + .qr = true, + }, + .qdcount = 0, + .ancount = 0, + .nscount = 0, + .arcount = 0, + }, + .opt_added = false, + }; + + var placeholder: [types.header_len]u8 = undefined; + header.encode(self.header, &placeholder); + try self.writer.writeAll(&placeholder); + + if (q) |echoed| { + try question.encode(echoed, &self.writer); + self.header.qdcount = 1; + } + return self; + } + + pub fn setRcode(self: *ResponseBuilder, rcode: types.Rcode) void { + self.header.flags.rcode = rcode; + } + + pub fn setAuthoritative(self: *ResponseBuilder, aa: bool) void { + self.header.flags.aa = aa; + } + + /// `rdata` is written verbatim, so it must hold no compression pointers. + pub fn addAnswer( + self: *ResponseBuilder, + owner: name.Name, + rtype: types.Type, + class: types.Class, + ttl: u32, + rdata: []const u8, + ) Error!void { + std.debug.assert(!self.opt_added); + const rec: record.Record = .{ + .name = owner, + .rtype = rtype, + .class = @intFromEnum(class), + .ttl = ttl, + .rdata = .{ .offset = 0, .len = 0 }, + }; + try record.encode(rec, rdata, &self.writer); + self.header.ancount += 1; + } + + /// Answers an EDNS query with an EDNS reply: the requestor's payload size + /// comes back unchanged and the DO bit passes through. No options are + /// echoed — nxdns implements none of them. + pub fn addOptEcho(self: *ResponseBuilder, request_opt: edns.OptRecord, do_bit: bool) Error!void { + std.debug.assert(!self.opt_added); + const opt: edns.OptRecord = .{ + .udp_payload_size = request_opt.udp_payload_size, + .extended_rcode = 0, + .version = 0, + .do_bit = do_bit, + .options = .{ .offset = 0, .len = 0 }, + }; + edns.encodeOpt(opt, &.{}, &self.writer) catch |err| switch (err) { + error.OptionsTooLong => unreachable, // the option list is empty + error.WriteFailed => return error.WriteFailed, + }; + self.header.arcount += 1; + self.opt_added = true; + } + + /// Patches the counts into the reserved header bytes and returns the + /// finished message, a prefix of the caller's buffer. + pub fn finish(self: *ResponseBuilder) []u8 { + const bytes = self.writer.buffered(); + header.encode(self.header, bytes[0..types.header_len]); + return bytes; + } +}; + +const testing = std.testing; + +/// A query for example.com A with an EDNS(0) OPT record advertising 4096 +/// bytes: id 0x1234, RD set, one question, one additional. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ // header + "\x07example\x03com\x00\x00\x01\x00\x01" ++ // question at 12, ends at 29 + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; // OPT at 29, ends at 40 + +/// The matching response: a CNAME to www.example.com and its A record, both +/// with compressed owner names, plus the echoed OPT record. +/// 12 question, 29 CNAME (rdata at 41, ttl at 35), +/// 47 A (rdata at 59, ttl at 53), 63 OPT, 74 end. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x01" ++ // header + "\x07example\x03com\x00\x00\x01\x00\x01" ++ // question at 12 + "\xc0\x0c\x00\x05\x00\x01\x00\x00\x01\x2c\x00\x06\x03www\xc0\x0c" ++ // CNAME, ttl 300 + "\xc0\x29\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x5d\xb8\xd8\x22" ++ // A, ttl 60 + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; // OPT at 63 + +test "fixtures have the documented layout" { + try testing.expectEqual(@as(usize, 40), query_bytes.len); + try testing.expectEqual(@as(usize, 74), response_bytes.len); +} + +test "parse a query" { + const p = try parse(query_bytes); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(false, p.header.flags.qr); + try testing.expectEqual(true, p.header.flags.rd); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expectEqual(@as(u16, 1), p.header.arcount); + + const q = firstQuestion(p).?; + try testing.expectEqualSlices(u8, (try name.fromText("example.com")).wire(), q.name.wire()); + try testing.expectEqual(types.Type.a, q.qtype); + try testing.expectEqual(types.Class.in, q.qclass); +} + +test "parse a response and walk every section" { + const p = try parse(response_bytes); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(@as(u16, 2), p.header.ancount); + + var qit = questions(p); + const q = (try qit.next()).?; + try testing.expectEqual(types.Type.a, q.qtype); + try testing.expectEqual(@as(?question.Question, null), try qit.next()); + + var ait = answers(p); + const cname = (try ait.next()).?; + try testing.expectEqual(types.Type.cname, cname.rtype); + try testing.expectEqual(@as(u32, 300), cname.ttl); + try testing.expectEqualSlices( + u8, + (try name.fromText("example.com")).wire(), + cname.name.wire(), + ); + try testing.expectEqualSlices( + u8, + (try name.fromText("www.example.com")).wire(), + (try record.rdataCname(response_bytes, cname)).wire(), + ); + + const a = (try ait.next()).?; + try testing.expectEqual(types.Type.a, a.rtype); + try testing.expectEqual(@as(u32, 60), a.ttl); + try testing.expectEqualSlices( + u8, + (try name.fromText("www.example.com")).wire(), + a.name.wire(), + ); + try testing.expectEqual([4]u8{ 93, 184, 216, 34 }, try record.rdataA(response_bytes, a)); + try testing.expectEqual(@as(?record.Record, null), try ait.next()); + + var nit = authorities(p); + try testing.expectEqual(@as(?record.Record, null), try nit.next()); + + var dit = additionals(p); + const opt_rec = (try dit.next()).?; + try testing.expectEqual(types.Type.opt, opt_rec.rtype); + try testing.expectEqual(@as(?record.Record, null), try dit.next()); +} + +test "iterators are independent and re-walk on demand" { + const p = try parse(response_bytes); + var first = answers(p); + var second = answers(p); + const a1 = (try first.next()).?; + const a2 = (try second.next()).?; + try testing.expectEqual(a1.rdata.offset, a2.rdata.offset); + _ = try first.next(); + const a2_second = (try second.next()).?; + try testing.expectEqual(types.Type.a, a2_second.rtype); +} + +test "authorities and additionals skip the sections before them" { + // One question, one answer, one authority, one additional, all with root + // owner names and 4-byte A rdata. + const rec = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04"; + const bytes = "\x00\x01\x81\x80\x00\x01\x00\x01\x00\x01\x00\x01" ++ + "\x00\x00\x01\x00\x01" ++ // question: root A IN + rec ++ "\x01\x01\x01\x01" ++ + rec ++ "\x02\x02\x02\x02" ++ + rec ++ "\x03\x03\x03\x03"; + + const p = try parse(bytes); + var ait = answers(p); + try testing.expectEqual([4]u8{ 1, 1, 1, 1 }, try record.rdataA(bytes, (try ait.next()).?)); + try testing.expectEqual(@as(?record.Record, null), try ait.next()); + + var nit = authorities(p); + try testing.expectEqual([4]u8{ 2, 2, 2, 2 }, try record.rdataA(bytes, (try nit.next()).?)); + try testing.expectEqual(@as(?record.Record, null), try nit.next()); + + var dit = additionals(p); + try testing.expectEqual([4]u8{ 3, 3, 3, 3 }, try record.rdataA(bytes, (try dit.next()).?)); + try testing.expectEqual(@as(?record.Record, null), try dit.next()); +} + +test "parse reports a short header as truncated" { + var i: usize = 0; + while (i < types.header_len) : (i += 1) { + try testing.expectError(error.Truncated, parse(query_bytes[0..i])); + } + // Exactly a header with no sections is well-formed. + const empty = try parse("\x00\x01\x81\x83" ++ "\x00" ** 8); + try testing.expectEqual(@as(u16, 0), empty.header.qdcount); + try testing.expectEqual(@as(?question.Question, null), firstQuestion(empty)); +} + +test "parse reports a truncated section as an overrun, not as truncation" { + // The header promises a question that is not there. + try testing.expectError( + error.SectionOverrun, + parse("\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"), + ); + // Every prefix that keeps the header but cuts a section. + var i: usize = types.header_len; + while (i < query_bytes.len) : (i += 1) { + try testing.expectError(error.SectionOverrun, parse(query_bytes[0..i])); + } +} + +test "parse rejects a count larger than the records present" { + // ancount claims two answers, one follows. + const bytes = "\x00\x01\x81\x80\x00\x00\x00\x02\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04"; + try testing.expectError(error.SectionOverrun, parse(bytes)); + + // qdcount claims two questions, one follows. + const two_questions = "\x00\x01\x01\x00\x00\x02\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01"; + try testing.expectError(error.SectionOverrun, parse(two_questions)); +} + +test "parse rejects an rdlength that overruns the packet" { + const bytes = "\x00\x01\x81\x80\x00\x00\x00\x01\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x40\x01\x02\x03\x04"; + try testing.expectError(error.SectionOverrun, parse(bytes)); +} + +test "parse rejects a bad name in the question section" { + // A pointer to itself: no chain can terminate. + const loop = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x0c\x00\x01\x00\x01"; + try testing.expectError(error.BadName, parse(loop)); + + // A forward pointer. + const forward = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x12\x00\x01\x00\x01\x03com\x00"; + try testing.expectError(error.BadName, parse(forward)); + + // A reserved label type, which is also what a 64-byte label looks like. + const reserved = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x40abc\x00\x00\x01\x00\x01"; + try testing.expectError(error.BadName, parse(reserved)); +} + +test "parse rejects a bad name in a record" { + const bytes = "\x00\x01\x81\x80\x00\x00\x00\x01\x00\x00\x00\x00" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04"; + try testing.expectError(error.BadName, parse(bytes)); +} + +test "parse rejects trailing bytes after the last record" { + try testing.expectError(error.TrailingBytes, parse(query_bytes ++ "\xff\xff\xff")); + // A single trailing byte counts too. + try testing.expectError(error.TrailingBytes, parse(query_bytes ++ "\x00")); + // A header-only message with a byte after it. + try testing.expectError(error.TrailingBytes, parse("\x00\x01\x81\x83" ++ "\x00" ** 9)); +} + +test "parse rejects a second OPT record" { + // Two OPT records in the additional section, payload sizes 512 and 1232. + const two_opts = "\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x02" ++ + "\x00\x00\x29\x02\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x29\x04\xd0\x00\x00\x00\x00\x00\x00"; + try testing.expectError(error.MultipleOptRecords, parse(two_opts)); + + // One OPT in the answer section and one in the additional section: still + // two OPT records in one message. + const split = "\x00\x01\x01\x00\x00\x00\x00\x01\x00\x00\x00\x01" ++ + "\x00\x00\x29\x02\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x29\x04\xd0\x00\x00\x00\x00\x00\x00"; + try testing.expectError(error.MultipleOptRecords, parse(split)); + + // One OPT beside a non-OPT additional record stays acceptable. + const single = "\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x02" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + try testing.expectEqual(@as(u16, 2), (try parse(single)).header.arcount); +} + +test "findOptRecord finds the OPT record and reads it" { + const p = try parse(query_bytes); + const rec = findOptRecord(p).?; + const opt = try edns.parseOpt(query_bytes, rec); + try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size); + try testing.expectEqual(false, opt.do_bit); + + const no_opt = try parse("\x00\x01\x01\x00" ++ "\x00" ** 8); + try testing.expectEqual(@as(?record.Record, null), findOptRecord(no_opt)); +} + +test "findOptRecord skips a non-OPT additional record" { + const bytes = "\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x02" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + const p = try parse(bytes); + const opt = try edns.parseOpt(bytes, findOptRecord(p).?); + try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size); +} + +test "setId overwrites the id and nothing else" { + var bytes: [query_bytes.len]u8 = query_bytes.*; + setId(&bytes, 0xbeef); + try testing.expectEqual(@as(u16, 0xbeef), (try parse(&bytes)).header.id); + try testing.expectEqualSlices(u8, query_bytes[2..], bytes[2..]); +} + +test "decrementTtls ages every record and returns the minimum" { + var bytes: [response_bytes.len]u8 = response_bytes.*; + const minimum = (try decrementTtls(&bytes, 10)).?; + try testing.expectEqual(@as(u32, 50), minimum); + + const p = try parse(&bytes); + var it = answers(p); + try testing.expectEqual(@as(u32, 290), (try it.next()).?.ttl); + try testing.expectEqual(@as(u32, 50), (try it.next()).?.ttl); +} + +test "decrementTtls saturates at zero" { + var bytes: [response_bytes.len]u8 = response_bytes.*; + try testing.expectEqual(@as(?u32, 0), try decrementTtls(&bytes, 1_000_000)); + + const p = try parse(&bytes); + var it = answers(p); + try testing.expectEqual(@as(u32, 0), (try it.next()).?.ttl); + try testing.expectEqual(@as(u32, 0), (try it.next()).?.ttl); +} + +test "decrementTtls leaves the OPT flags alone" { + // OPT with DO set and a 4096-byte payload size, so its TTL word is + // 0x0000_8000 — a plain subtraction would clear the DO bit. + const bytes_const = "\x12\x34\x81\x80\x00\x00\x00\x01\x00\x00\x00\x01" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x01\x02\x03\x04" ++ + "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00"; + var bytes: [bytes_const.len]u8 = bytes_const.*; + + try testing.expectEqual(@as(?u32, 40), try decrementTtls(&bytes, 60)); + + const p = try parse(&bytes); + const opt = try edns.parseOpt(&bytes, findOptRecord(p).?); + try testing.expectEqual(true, opt.do_bit); + try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size); + try testing.expectEqual(@as(u8, 0), opt.extended_rcode); +} + +test "decrementTtls ages authority and additional records too" { + const rec = "\x00\x00\x01\x00\x01"; + const bytes_const = "\x00\x01\x81\x80\x00\x00\x00\x01\x00\x01\x00\x01" ++ + rec ++ "\x00\x00\x00\x64\x00\x04\x01\x01\x01\x01" ++ // ttl 100 + rec ++ "\x00\x00\x00\x1e\x00\x04\x02\x02\x02\x02" ++ // ttl 30 + rec ++ "\x00\x00\x00\x50\x00\x04\x03\x03\x03\x03"; // ttl 80 + var bytes: [bytes_const.len]u8 = bytes_const.*; + + try testing.expectEqual(@as(?u32, 10), try decrementTtls(&bytes, 20)); + + const p = try parse(&bytes); + var ait = answers(p); + try testing.expectEqual(@as(u32, 80), (try ait.next()).?.ttl); + var nit = authorities(p); + try testing.expectEqual(@as(u32, 10), (try nit.next()).?.ttl); + var dit = additionals(p); + try testing.expectEqual(@as(u32, 60), (try dit.next()).?.ttl); +} + +test "decrementTtls reports no minimum when nothing carries a ttl" { + var query: [query_bytes.len]u8 = query_bytes.*; + // The only record is the OPT, which is skipped. + try testing.expectEqual(@as(?u32, null), try decrementTtls(&query, 5)); + try testing.expectEqualSlices(u8, query_bytes, &query); +} + +test "decrementTtls propagates structural errors" { + var short = [_]u8{0} ** 8; + try testing.expectError(error.Truncated, decrementTtls(&short, 1)); + + var overrun = "\x00\x01\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00".*; + try testing.expectError(error.SectionOverrun, decrementTtls(&overrun, 1)); + + var bad_name = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\xc0\x0c\x00\x01\x00\x01".*; + try testing.expectError(error.BadName, decrementTtls(&bad_name, 1)); +} + +test "decrementTtls leaves the buffer untouched when a later record is malformed" { + // Two answers: a well-formed A record with TTL 100, then a record whose + // owner name is a forward pointer. + const bytes_const = "\x00\x01\x81\x80\x00\x00\x00\x02\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x01\x02\x03\x04" ++ + "\xc0\x40\x00\x01\x00\x01\x00\x00\x00\x64\x00\x00"; + var bytes: [bytes_const.len]u8 = bytes_const.*; + + try testing.expectError(error.BadName, decrementTtls(&bytes, 60)); + try testing.expectEqualSlices(u8, bytes_const, &bytes); +} + +test "decrementTtls reports the packet that aging itself breaks" { + // The first record's TTL word is 0x02_68_69_00, which also reads as the + // name "hi." at offset 17. The second record's owner name points there, so + // the packet parses — until aging rewrites those four bytes. + const bytes_const = "\x00\x01\x81\x80\x00\x00\x00\x02\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x02\x68\x69\x00\x00\x04\x01\x02\x03\x04" ++ + "\xc0\x11\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x05\x06\x07\x08"; + var bytes: [bytes_const.len]u8 = bytes_const.*; + + const p = try parse(&bytes); + var it = answers(p); + _ = (try it.next()).?; + try testing.expectEqualSlices(u8, (try name.fromText("hi")).wire(), (try it.next()).?.name.wire()); + + // Aging the first TTL turns the second owner name into a label that runs + // off the end. The error is reported, not a panic, and the buffer is then + // a partly aged message the caller must discard. + try testing.expectError(error.SectionOverrun, decrementTtls(&bytes, 1000)); + try testing.expectError(error.SectionOverrun, parse(&bytes)); +} + +test "ResponseBuilder builds a reply that re-parses" { + const request = try parse(query_bytes); + const q = firstQuestion(request).?; + const request_opt = try edns.parseOpt(query_bytes, findOptRecord(request).?); + + var buf: [512]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request.header, q); + try b.addAnswer(q.name, .a, .in, 60, "\x0a\x00\x00\x01"); + try b.addOptEcho(request_opt, false); + const bytes = b.finish(); + + const p = try parse(bytes); + try testing.expectEqual(request.header.id, p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(true, p.header.flags.rd); + try testing.expectEqual(true, p.header.flags.ra); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expectEqual(@as(u16, 1), p.header.ancount); + try testing.expectEqual(@as(u16, 0), p.header.nscount); + try testing.expectEqual(@as(u16, 1), p.header.arcount); + + const echoed = firstQuestion(p).?; + try testing.expectEqualSlices(u8, q.name.wire(), echoed.name.wire()); + try testing.expectEqual(q.qtype, echoed.qtype); + + var it = answers(p); + const a = (try it.next()).?; + try testing.expectEqual(types.Type.a, a.rtype); + try testing.expectEqual(@as(u32, 60), a.ttl); + try testing.expectEqual([4]u8{ 10, 0, 0, 1 }, try record.rdataA(bytes, a)); + + const opt = try edns.parseOpt(bytes, findOptRecord(p).?); + try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size); + try testing.expectEqual(false, opt.do_bit); +} + +test "a built reply decrements its ttls" { + const request = try parse(query_bytes); + const q = firstQuestion(request).?; + + var buf: [512]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request.header, q); + try b.addAnswer(q.name, .a, .in, 90, "\x0a\x00\x00\x01"); + try b.addAnswer(q.name, .a, .in, 30, "\x0a\x00\x00\x02"); + const bytes = b.finish(); + + try testing.expectEqual(@as(?u32, 5), try decrementTtls(bytes, 25)); + const p = try parse(bytes); + var it = answers(p); + try testing.expectEqual(@as(u32, 65), (try it.next()).?.ttl); + try testing.expectEqual(@as(u32, 5), (try it.next()).?.ttl); +} + +test "ResponseBuilder passes the DO bit through" { + const request = try parse(query_bytes); + const request_opt = try edns.parseOpt(query_bytes, findOptRecord(request).?); + + for ([_]bool{ false, true }) |do_bit| { + var buf: [128]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request.header, firstQuestion(request).?); + try b.addOptEcho(request_opt, do_bit); + const bytes = b.finish(); + const p = try parse(bytes); + const opt = try edns.parseOpt(bytes, findOptRecord(p).?); + try testing.expectEqual(do_bit, opt.do_bit); + } +} + +test "ResponseBuilder sets an rcode and an empty answer section" { + const request = try parse(query_bytes); + + var buf: [128]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request.header, firstQuestion(request).?); + b.setRcode(.nx_domain); + b.setAuthoritative(true); + const bytes = b.finish(); + + const p = try parse(bytes); + try testing.expectEqual(types.Rcode.nx_domain, p.header.flags.rcode); + try testing.expectEqual(true, p.header.flags.aa); + try testing.expectEqual(@as(u16, 0), p.header.ancount); + try testing.expectEqual(@as(usize, types.header_len + 17), bytes.len); +} + +test "ResponseBuilder works without a question" { + const request = try parse(query_bytes); + + var buf: [64]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request.header, null); + b.setRcode(.form_err); + const bytes = b.finish(); + + try testing.expectEqual(@as(usize, types.header_len), bytes.len); + const p = try parse(bytes); + try testing.expectEqual(@as(u16, 0), p.header.qdcount); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); +} + +test "ResponseBuilder keeps the opcode and a cleared RD bit" { + const request: header.Header = .{ + .id = 0x4321, + .flags = .{ + .rcode = .no_error, + .z = 0, + .ra = false, + .rd = false, + .tc = false, + .aa = false, + .opcode = .status, + .qr = false, + }, + .qdcount = 0, + .ancount = 0, + .nscount = 0, + .arcount = 0, + }; + + var buf: [64]u8 = undefined; + var b = try ResponseBuilder.init(&buf, request, null); + const p = try parse(b.finish()); + try testing.expectEqual(@as(u16, 0x4321), p.header.id); + try testing.expectEqual(types.Opcode.status, p.header.flags.opcode); + try testing.expectEqual(false, p.header.flags.rd); + try testing.expectEqual(@as(u3, 0), p.header.flags.z); +} + +test "ResponseBuilder reports a buffer that is too small" { + const request = try parse(query_bytes); + const q = firstQuestion(request).?; + + var tiny: [8]u8 = undefined; + try testing.expectError(error.WriteFailed, ResponseBuilder.init(&tiny, request.header, q)); + + var no_room_for_question: [16]u8 = undefined; + try testing.expectError( + error.WriteFailed, + ResponseBuilder.init(&no_room_for_question, request.header, q), + ); + + var no_room_for_answer: [32]u8 = undefined; + var b = try ResponseBuilder.init(&no_room_for_answer, request.header, q); + try testing.expectError(error.WriteFailed, b.addAnswer(q.name, .a, .in, 60, "\x01\x02\x03\x04")); +} diff --git a/src/dns/question.zig b/src/dns/question.zig new file mode 100644 index 0000000..026d442 --- /dev/null +++ b/src/dns/question.zig @@ -0,0 +1,138 @@ +//! Question section entries (RFC 1035 §4.1.2). Pure: no allocation, no +//! `std.Io` beyond writing encoded bytes to a caller's writer. + +const std = @import("std"); +const types = @import("types.zig"); +const name = @import("name.zig"); +const Writer = std.Io.Writer; + +pub const Question = struct { + name: name.Name, + qtype: types.Type, + qclass: types.Class, +}; + +pub const ParseError = name.ParseError; + +pub const Parsed = struct { + question: Question, + /// Offset just past the question as it appears at `offset`; follows the + /// same convention as `name.Parsed.end` for a compressed name. + end: usize, +}; + +pub fn parse(packet: []const u8, offset: usize) ParseError!Parsed { + const parsed_name = try name.parse(packet, offset); + const fixed = parsed_name.end; + if (fixed + 4 > packet.len) return error.Truncated; + return .{ + .question = .{ + .name = parsed_name.name, + .qtype = @enumFromInt(std.mem.readInt(u16, packet[fixed..][0..2], .big)), + .qclass = @enumFromInt(std.mem.readInt(u16, packet[fixed + 2 ..][0..2], .big)), + }, + .end = fixed + 4, + }; +} + +pub fn encode(q: Question, w: *Writer) Writer.Error!void { + try name.encode(q.name, w); + try w.writeInt(u16, @intFromEnum(q.qtype), .big); + try w.writeInt(u16, @intFromEnum(q.qclass), .big); +} + +const testing = std.testing; + +test "parse an uncompressed question" { + const packet = "\x07example\x03com\x00\x00\x01\x00\x01"; + const r = try parse(packet, 0); + try testing.expectEqualSlices( + u8, + (try name.fromText("example.com")).wire(), + r.question.name.wire(), + ); + try testing.expectEqual(types.Type.a, r.question.qtype); + try testing.expectEqual(types.Class.in, r.question.qclass); + try testing.expectEqual(@as(usize, 17), r.end); +} + +test "parse a question whose name is compressed" { + // "com" at offset 0, then a question at offset 5 naming "example.com" + // through a pointer. + const packet = "\x03com\x00" ++ "\x07example\xc0\x00\x00\x1c\x00\x01"; + const r = try parse(packet, 5); + try testing.expectEqualSlices( + u8, + (try name.fromText("example.com")).wire(), + r.question.name.wire(), + ); + try testing.expectEqual(types.Type.aaaa, r.question.qtype); + try testing.expectEqual(types.Class.in, r.question.qclass); + try testing.expectEqual(@as(usize, 19), r.end); +} + +test "parse keeps unknown type and class values" { + const packet = "\x00\x12\x34\x56\x78"; + const r = try parse(packet, 0); + try testing.expect(r.question.name.isRoot()); + try testing.expectEqual(@as(u16, 0x1234), @intFromEnum(r.question.qtype)); + try testing.expectEqual(@as(u16, 0x5678), @intFromEnum(r.question.qclass)); + try testing.expectEqual(@as(usize, 5), r.end); +} + +test "parse rejects a truncated fixed field" { + const full = "\x07example\x03com\x00\x00\x01\x00\x01"; + var i: usize = 13; + while (i < full.len) : (i += 1) { + try testing.expectError(error.Truncated, parse(full[0..i], 0)); + } +} + +test "parse propagates a name error" { + try testing.expectError(error.BadPointer, parse("\xc0\x00\x00\x01\x00\x01", 0)); + try testing.expectError(error.Truncated, parse("\x07exa", 0)); + try testing.expectError(error.LabelTooLong, parse("\x40abc\x00\x00\x01\x00\x01", 0)); +} + +test "encode round-trips" { + const original: Question = .{ + .name = try name.fromText("www.example.com"), + .qtype = .aaaa, + .qclass = .in, + }; + + var buf: [512]u8 = undefined; + var w = Writer.fixed(&buf); + try encode(original, &w); + const bytes = w.buffered(); + try testing.expectEqual(@as(usize, 21), bytes.len); + + const r = try parse(bytes, 0); + try testing.expectEqualSlices(u8, original.name.wire(), r.question.name.wire()); + try testing.expectEqual(original.qtype, r.question.qtype); + try testing.expectEqual(original.qclass, r.question.qclass); + try testing.expectEqual(bytes.len, r.end); +} + +test "encode writes big-endian fixed fields" { + const q: Question = .{ + .name = try name.fromText("."), + .qtype = @enumFromInt(0x1234), + .qclass = @enumFromInt(0x5678), + }; + var buf: [16]u8 = undefined; + var w = Writer.fixed(&buf); + try encode(q, &w); + try testing.expectEqualSlices(u8, "\x00\x12\x34\x56\x78", w.buffered()); +} + +test "encode reports a short buffer" { + const q: Question = .{ + .name = try name.fromText("example.com"), + .qtype = .a, + .qclass = .in, + }; + var buf: [16]u8 = undefined; + var w = Writer.fixed(&buf); + try testing.expectError(error.WriteFailed, encode(q, &w)); +} diff --git a/src/dns/record.zig b/src/dns/record.zig new file mode 100644 index 0000000..9dc02d3 --- /dev/null +++ b/src/dns/record.zig @@ -0,0 +1,313 @@ +//! Resource records (RFC 1035 §4.1.3). Pure: no allocation, no `std.Io` +//! beyond writing encoded bytes to a caller's writer. + +const std = @import("std"); +const types = @import("types.zig"); +const name = @import("name.zig"); +const Writer = std.Io.Writer; + +/// Where the RDATA sits inside the packet that produced the record. The bytes +/// stay in the packet: RDATA of NS, CNAME, PTR, MX and SOA can hold names with +/// compression pointers that target any earlier offset in the message, so the +/// span alone does not decode. Every typed accessor therefore takes the whole +/// packet as well as the record. +pub const RdataSpan = struct { + offset: usize, + len: usize, + + /// The raw bytes. `packet` must be the packet the record came from. + pub fn slice(self: RdataSpan, packet: []const u8) []const u8 { + return packet[self.offset..][0..self.len]; + } +}; + +pub const Record = struct { + name: name.Name, + rtype: types.Type, + /// Left as a plain `u16`: OPT reuses this field as the requestor's UDP + /// payload size (RFC 6891 §6.1.2), so it is not always a class. + class: u16, + /// Left as a plain `u32` for the same reason: OPT reuses it as flags. + ttl: u32, + rdata: RdataSpan, +}; + +pub const ParseError = name.ParseError; + +pub const Parsed = struct { + record: Record, + /// Offset just past the record. The name follows the `name.Parsed.end` + /// convention, so a compressed owner name costs two bytes here. + end: usize, +}; + +/// Fixed fields between the owner name and the RDATA: TYPE, CLASS, TTL, +/// RDLENGTH. +const fixed_len = 2 + 2 + 4 + 2; + +pub fn parse(packet: []const u8, offset: usize) ParseError!Parsed { + const parsed_name = try name.parse(packet, offset); + const fixed = parsed_name.end; + if (fixed + fixed_len > packet.len) return error.Truncated; + + const rdlength: usize = std.mem.readInt(u16, packet[fixed + 8 ..][0..2], .big); + const rdata_offset = fixed + fixed_len; + if (rdata_offset + rdlength > packet.len) return error.Truncated; + + return .{ + .record = .{ + .name = parsed_name.name, + .rtype = @enumFromInt(std.mem.readInt(u16, packet[fixed..][0..2], .big)), + .class = std.mem.readInt(u16, packet[fixed + 2 ..][0..2], .big), + .ttl = std.mem.readInt(u32, packet[fixed + 4 ..][0..4], .big), + .rdata = .{ .offset = rdata_offset, .len = rdlength }, + }, + .end = rdata_offset + rdlength, + }; +} + +/// Writes the record with its owner name uncompressed. `rdata_bytes` is copied +/// verbatim, so it must not contain compression pointers: this path serves +/// synthesized records, whose RDATA nxdns builds itself. +pub fn encode(rec: Record, rdata_bytes: []const u8, w: *Writer) (Writer.Error || error{RdataTooLong})!void { + if (rdata_bytes.len > std.math.maxInt(u16)) return error.RdataTooLong; + try name.encode(rec.name, w); + try w.writeInt(u16, @intFromEnum(rec.rtype), .big); + try w.writeInt(u16, rec.class, .big); + try w.writeInt(u32, rec.ttl, .big); + try w.writeInt(u16, @intCast(rdata_bytes.len), .big); + try w.writeAll(rdata_bytes); +} + +pub const RdataError = name.ParseError || error{ + WrongType, + BadRdata, +}; + +pub fn rdataA(packet: []const u8, rec: Record) RdataError![4]u8 { + if (rec.rtype != .a) return error.WrongType; + if (rec.rdata.len != 4) return error.BadRdata; + return rec.rdata.slice(packet)[0..4].*; +} + +pub fn rdataAaaa(packet: []const u8, rec: Record) RdataError![16]u8 { + if (rec.rtype != .aaaa) return error.WrongType; + if (rec.rdata.len != 16) return error.BadRdata; + return rec.rdata.slice(packet)[0..16].*; +} + +/// Decodes the single name in CNAME, NS or PTR RDATA, following compression +/// pointers into the rest of the packet. +pub fn rdataCname(packet: []const u8, rec: Record) RdataError!name.Name { + switch (rec.rtype) { + .cname, .ns, .ptr => {}, + else => return error.WrongType, + } + const parsed = try name.parse(packet, rec.rdata.offset); + if (parsed.end != rec.rdata.offset + rec.rdata.len) return error.BadRdata; + return parsed.name; +} + +/// The MINIMUM field of SOA RDATA (RFC 1035 §3.3.13), which RFC 2308 §4 makes +/// the ceiling for negative caching. MNAME and RNAME precede the five fixed +/// 32-bit fields and may both be compressed, so they have to be walked. +pub fn rdataSoaMinimumTtl(packet: []const u8, rec: Record) RdataError!u32 { + if (rec.rtype != .soa) return error.WrongType; + const rdata_end = rec.rdata.offset + rec.rdata.len; + + const mname = try name.parse(packet, rec.rdata.offset); + if (mname.end > rdata_end) return error.BadRdata; + const rname = try name.parse(packet, mname.end); + if (rname.end > rdata_end) return error.BadRdata; + + // SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM. + if (rname.end + 20 != rdata_end) return error.BadRdata; + return std.mem.readInt(u32, packet[rname.end + 16 ..][0..4], .big); +} + +const testing = std.testing; + +/// An answer for "example.com A 1.2.3.4" preceded by a 12-byte header stand-in +/// and the question name it compresses against. +const a_packet = + "\x00" ** 12 ++ // header stand-in + "\x07example\x03com\x00\x00\x01\x00\x01" ++ // question, offset 12 + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x0e\x10\x00\x04\x01\x02\x03\x04"; + +test "parse an A record with a compressed owner name" { + const r = try parse(a_packet, 29); + try testing.expectEqualSlices( + u8, + (try name.fromText("example.com")).wire(), + r.record.name.wire(), + ); + try testing.expectEqual(types.Type.a, r.record.rtype); + try testing.expectEqual(@as(u16, 1), r.record.class); + try testing.expectEqual(@as(u32, 3600), r.record.ttl); + try testing.expectEqual(@as(usize, 4), r.record.rdata.len); + try testing.expectEqual(@as(usize, a_packet.len), r.end); + try testing.expectEqualSlices(u8, "\x01\x02\x03\x04", r.record.rdata.slice(a_packet)); + try testing.expectEqual([4]u8{ 1, 2, 3, 4 }, try rdataA(a_packet, r.record)); +} + +test "parse an AAAA record" { + const packet = "\x00\x00\x1c\x00\x01\x00\x00\x00\x3c\x00\x10" ++ + "\x20\x01\x0d\xb8" ++ "\x00" ** 11 ++ "\x01"; + const r = try parse(packet, 0); + try testing.expectEqual(types.Type.aaaa, r.record.rtype); + try testing.expectEqual(@as(u32, 60), r.record.ttl); + const addr = try rdataAaaa(packet, r.record); + try testing.expectEqual(@as(u8, 0x20), addr[0]); + try testing.expectEqual(@as(u8, 0x01), addr[15]); +} + +test "parse a CNAME whose target is compressed" { + // "example.com" at offset 0; the CNAME RDATA is "www" plus a pointer to it. + const packet = "\x07example\x03com\x00" ++ + "\x03www\xc0\x00\x00\x05\x00\x01\x00\x00\x01\x2c\x00\x02\xc0\x00"; + const r = try parse(packet, 13); + try testing.expectEqual(types.Type.cname, r.record.rtype); + try testing.expectEqual(@as(usize, 2), r.record.rdata.len); + const target = try rdataCname(packet, r.record); + try testing.expectEqualSlices(u8, (try name.fromText("example.com")).wire(), target.wire()); + try testing.expectEqual(@as(usize, packet.len), r.end); +} + +test "rdataCname accepts NS and PTR, rejects other types" { + const packet = "\x03com\x00" ++ "\x00\x00\x02\x00\x01\x00\x00\x00\x0a\x00\x02\xc0\x00"; + const r = try parse(packet, 5); + try testing.expectEqual(types.Type.ns, r.record.rtype); + try testing.expectEqualSlices( + u8, + (try name.fromText("com")).wire(), + (try rdataCname(packet, r.record)).wire(), + ); + + var wrong = r.record; + wrong.rtype = .mx; + try testing.expectError(error.WrongType, rdataCname(packet, wrong)); +} + +test "rdataCname rejects a name that does not fill the rdata" { + // RDLENGTH claims 4 bytes but the name uses 2. + const packet = "\x03com\x00" ++ "\x00\x00\x05\x00\x01\x00\x00\x00\x0a\x00\x04\xc0\x00\x00\x00"; + const r = try parse(packet, 5); + try testing.expectError(error.BadRdata, rdataCname(packet, r.record)); +} + +test "rdataA and rdataAaaa reject the wrong type and the wrong length" { + const short = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x03\x01\x02\x03"; + const r = try parse(short, 0); + try testing.expectError(error.BadRdata, rdataA(short, r.record)); + try testing.expectError(error.WrongType, rdataAaaa(short, r.record)); +} + +test "SOA minimum ttl extraction with compressed MNAME and RNAME" { + // Offset 0 holds "example.com"; the owner name and both RDATA names are + // pointers to it. + const rdata = "\x03ns1\xc0\x00" ++ // MNAME: ns1.example.com, 6 bytes + "\x0ahostmaster\xc0\x00" ++ // RNAME: hostmaster.example.com, 13 bytes + "\x00\x00\x00\x01" ++ // serial + "\x00\x00\x1c\x20" ++ // refresh + "\x00\x00\x0e\x10" ++ // retry + "\x00\x36\xee\x80" ++ // expire + "\x00\x00\x02\x58"; // minimum = 600 + const soa = "\x07example\x03com\x00" ++ + "\xc0\x00\x00\x06\x00\x01\x00\x00\x0e\x10" ++ + "\x00\x27" ++ // rdlength = 6 + 13 + 20 = 39 + rdata; + try testing.expectEqual(@as(usize, 39), rdata.len); + + const r = try parse(soa, 13); + try testing.expectEqual(types.Type.soa, r.record.rtype); + try testing.expectEqual(@as(u32, 600), try rdataSoaMinimumTtl(soa, r.record)); + try testing.expectEqual(@as(usize, soa.len), r.end); +} + +test "SOA minimum ttl rejects a short or long rdata" { + const rdata = "\x00\x00" ++ // root MNAME and RNAME + "\x00\x00\x00\x01\x00\x00\x1c\x20\x00\x00\x0e\x10\x00\x36\xee\x80\x00\x00\x02\x58"; + const head = "\x00\x00\x06\x00\x01\x00\x00\x0e\x10"; + + const good = head ++ "\x00\x16" ++ rdata; + const r = try parse(good, 0); + try testing.expectEqual(@as(u32, 600), try rdataSoaMinimumTtl(good, r.record)); + + // One fixed field short. + const short = head ++ "\x00\x12" ++ rdata[0..18]; + const rs = try parse(short, 0); + try testing.expectError(error.BadRdata, rdataSoaMinimumTtl(short, rs.record)); + + // One byte of slack past MINIMUM. + const long = head ++ "\x00\x17" ++ rdata ++ "\x00"; + const rl = try parse(long, 0); + try testing.expectError(error.BadRdata, rdataSoaMinimumTtl(long, rl.record)); + + var wrong = r.record; + wrong.rtype = .a; + try testing.expectError(error.WrongType, rdataSoaMinimumTtl(good, wrong)); +} + +test "parse rejects an rdlength that overruns the packet" { + const packet = "\x00\x00\x01\x00\x01\x00\x00\x0e\x10\x00\x08\x01\x02\x03\x04"; + try testing.expectError(error.Truncated, parse(packet, 0)); +} + +test "parse rejects truncated fixed fields" { + const full = "\x00\x00\x01\x00\x01\x00\x00\x0e\x10\x00\x04\x01\x02\x03\x04"; + var i: usize = 1; + while (i < full.len) : (i += 1) { + try testing.expectError(error.Truncated, parse(full[0..i], 0)); + } + _ = try parse(full, 0); +} + +test "parse propagates a name error" { + try testing.expectError(error.BadPointer, parse("\xc0\x00" ++ "\x00" ** 11, 0)); + try testing.expectError(error.LabelTooLong, parse("\x40ab" ++ "\x00" ** 12, 0)); +} + +test "parse accepts an empty rdata" { + const packet = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + const r = try parse(packet, 0); + try testing.expectEqual(types.Type.opt, r.record.rtype); + try testing.expectEqual(@as(usize, 0), r.record.rdata.len); + try testing.expectEqual(@as(usize, packet.len), r.end); + try testing.expectEqualSlices(u8, "", r.record.rdata.slice(packet)); +} + +test "encode round-trips through parse" { + const original: Record = .{ + .name = try name.fromText("www.example.com"), + .rtype = .a, + .class = 1, + .ttl = 0x0000_0e10, + .rdata = .{ .offset = 0, .len = 0 }, + }; + + var buf: [512]u8 = undefined; + var w = Writer.fixed(&buf); + try encode(original, "\x0a\x00\x00\x01", &w); + const bytes = w.buffered(); + + const r = try parse(bytes, 0); + try testing.expectEqualSlices(u8, original.name.wire(), r.record.name.wire()); + try testing.expectEqual(original.rtype, r.record.rtype); + try testing.expectEqual(original.class, r.record.class); + try testing.expectEqual(original.ttl, r.record.ttl); + try testing.expectEqual(@as(usize, bytes.len), r.end); + try testing.expectEqual([4]u8{ 10, 0, 0, 1 }, try rdataA(bytes, r.record)); +} + +test "encode reports a short buffer" { + const rec: Record = .{ + .name = try name.fromText("example.com"), + .rtype = .a, + .class = 1, + .ttl = 60, + .rdata = .{ .offset = 0, .len = 0 }, + }; + var buf: [16]u8 = undefined; + var w = Writer.fixed(&buf); + try testing.expectError(error.WriteFailed, encode(rec, "\x01\x02\x03\x04", &w)); +} diff --git a/src/dns/types.zig b/src/dns/types.zig new file mode 100644 index 0000000..1f33aac --- /dev/null +++ b/src/dns/types.zig @@ -0,0 +1,121 @@ +//! DNS wire-format enumerations and protocol limits (RFC 1035 §3.2, §4.1.1). +//! Pure: no allocation, no `std.Io`. + +const std = @import("std"); + +/// RR TYPE / QTYPE. Non-exhaustive: unknown types pass through opaquely. +pub const Type = enum(u16) { + a = 1, + ns = 2, + cname = 5, + soa = 6, + ptr = 12, + mx = 15, + txt = 16, + aaaa = 28, + srv = 33, + opt = 41, + svcb = 64, + https = 65, + any = 255, + _, +}; + +/// RR CLASS / QCLASS. Non-exhaustive. +pub const Class = enum(u16) { + in = 1, + ch = 3, + hs = 4, + any = 255, + _, +}; + +/// The 4-bit header RCODE. EDNS(0) extends this to 12 bits; the upper 8 bits +/// live in the OPT record and are handled in edns.zig. +pub const Rcode = enum(u4) { + no_error = 0, + form_err = 1, + serv_fail = 2, + nx_domain = 3, + not_imp = 4, + refused = 5, + yx_domain = 6, + yx_rr_set = 7, + nx_rr_set = 8, + not_auth = 9, + not_zone = 10, + _, +}; + +/// The 4-bit header OPCODE. +pub const Opcode = enum(u4) { + query = 0, + iquery = 1, + status = 2, + notify = 4, + update = 5, + _, +}; + +/// Maximum length of a name in uncompressed wire form, terminating zero included. +pub const max_name_len = 255; + +/// Maximum length of a single label's data, length byte excluded. +pub const max_label_len = 63; + +/// Compression pointers must always target a strictly lower offset, so a chain +/// terminates on its own. This cap bounds the work a single name can cost. +pub const max_compression_jumps = 32; + +/// Pre-EDNS UDP payload limit (RFC 1035 §4.2.1). +pub const max_udp_payload = 512; + +/// Fixed size of the DNS message header. +pub const header_len = 12; + +test "type values match the wire numbers" { + try std.testing.expectEqual(@as(u16, 1), @intFromEnum(Type.a)); + try std.testing.expectEqual(@as(u16, 28), @intFromEnum(Type.aaaa)); + try std.testing.expectEqual(@as(u16, 41), @intFromEnum(Type.opt)); + try std.testing.expectEqual(@as(u16, 64), @intFromEnum(Type.svcb)); + try std.testing.expectEqual(@as(u16, 65), @intFromEnum(Type.https)); + try std.testing.expectEqual(@as(u16, 255), @intFromEnum(Type.any)); +} + +test "class values match the wire numbers" { + try std.testing.expectEqual(@as(u16, 1), @intFromEnum(Class.in)); + try std.testing.expectEqual(@as(u16, 3), @intFromEnum(Class.ch)); + try std.testing.expectEqual(@as(u16, 4), @intFromEnum(Class.hs)); + try std.testing.expectEqual(@as(u16, 255), @intFromEnum(Class.any)); +} + +test "rcode and opcode values match the wire numbers" { + try std.testing.expectEqual(@as(u4, 0), @intFromEnum(Rcode.no_error)); + try std.testing.expectEqual(@as(u4, 3), @intFromEnum(Rcode.nx_domain)); + try std.testing.expectEqual(@as(u4, 5), @intFromEnum(Rcode.refused)); + try std.testing.expectEqual(@as(u4, 0), @intFromEnum(Opcode.query)); + try std.testing.expectEqual(@as(u4, 4), @intFromEnum(Opcode.notify)); + try std.testing.expectEqual(@as(u4, 5), @intFromEnum(Opcode.update)); +} + +test "unknown enum values round-trip" { + const unknown_type: Type = @enumFromInt(9999); + try std.testing.expectEqual(@as(u16, 9999), @intFromEnum(unknown_type)); + + const unknown_class: Class = @enumFromInt(1234); + try std.testing.expectEqual(@as(u16, 1234), @intFromEnum(unknown_class)); + + const unknown_rcode: Rcode = @enumFromInt(15); + try std.testing.expectEqual(@as(u4, 15), @intFromEnum(unknown_rcode)); + + const unknown_opcode: Opcode = @enumFromInt(3); + try std.testing.expectEqual(@as(u4, 3), @intFromEnum(unknown_opcode)); +} + +test "limits" { + try std.testing.expectEqual(255, max_name_len); + try std.testing.expectEqual(63, max_label_len); + try std.testing.expectEqual(32, max_compression_jumps); + try std.testing.expectEqual(512, max_udp_payload); + try std.testing.expectEqual(12, header_len); +} diff --git a/src/tests.zig b/src/tests.zig index 3fc4cd0..6b7a484 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -3,6 +3,13 @@ const std = @import("std"); comptime { _ = @import("main.zig"); _ = @import("version.zig"); + _ = @import("dns/types.zig"); + _ = @import("dns/header.zig"); + _ = @import("dns/name.zig"); + _ = @import("dns/question.zig"); + _ = @import("dns/record.zig"); + _ = @import("dns/edns.zig"); + _ = @import("dns/packet.zig"); _ = @import("platform/address.zig"); _ = @import("platform/tls_client.zig"); _ = @import("platform/tls_client_integration_test.zig");