#! /usr/bin/perl
use 5.006;
use strict;
use warnings;
use File::Temp qw(tempfile);

use Test::More;
BEGIN { use_ok("Test::Command::Simple"); }

unless (*run{CODE})
{
	done_testing;
	exit(0);
}

my $srcdir = $ENV{srcdir};
my @e;

sub write_temp_zone
{
	my ($fh, $name) = tempfile("validns-test-XXXXXXXX",
		TMPDIR => 1, SUFFIX => ".zone", UNLINK => 1);
	print $fh @_;
	close $fh or die "cannot write $name: $!";
	return $name;
}

# Detect if SHA-1 is disabled by system crypto policy (e.g., CentOS 9/RHEL 9)
# by attempting to parse a zone with RSASHA1 signatures
run('./validns', '-t1501789043', $srcdir . '/t/zones/example.sec.signed');
my $sha1_disabled = stderr =~ /disabled by system crypto policy/;
if ($sha1_disabled) {
	like(stderr, qr/RSASHA1 signatures cannot be validated: SHA-1 is disabled by system crypto policy/,
		'SHA-1 disabled: error message is correct');
}

# Detect if Ed448 is unavailable (notably LibreSSL builds, which lack Ed448
# but do support Ed25519) by attempting to parse the issue-90 Ed448 zone.
run('./validns', '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/16.example.com.signed');
my $libressl_no_ed448 = stderr =~ /ED448 signatures cannot be validated/;

for my $threads ("", qw(-n2 -n4 -n6 -n8)) {
my @threads;
push @threads, $threads if $threads;
run('./validns', @threads, $srcdir . '/t/zones/galaxyplus.org');
is(rc, 0, 'valid zone parses ok');

SKIP: {
skip "SHA-1 disabled by system crypto policy", 1 if $sha1_disabled;
run('./validns', @threads, '-t1501789043', $srcdir . '/t/zones/example.sec.signed');
is(rc, 0, 'valid signed zone parses ok');
}

# WALLET record tests
run('./validns', @threads, $srcdir . '/t/zones/wallet.zone');
is(rc, 0, 'WALLET records parse correctly');

# SVCB/HTTPS record tests
run('./validns', @threads, $srcdir . '/t/zones/svcb.zone');
is(rc, 0, 'SVCB AliasMode records parse correctly');

run('./validns', @threads, '-t1778025600',  # 2026-05-15 12:00:00 UTC
    $srcdir . '/t/zones/svcb.zone.signed');
is(rc, 0, 'SVCB/HTTPS signatures verify');

run('./validns', @threads, '-v', $srcdir . '/t/zones/svcb.zone');
is(rc, 0, 'SVCB verbose parse OK');
like(stderr, qr/SVCB 0 svc\.example\./,
    'SVCB AliasMode renders priority and target');
like(stderr, qr/SVCB 1 \..*port=443/,
    'port renders as decimal');
like(stderr, qr/HTTPS 1 \..*ohttp/,
    'ohttp renders as bare key');
like(stderr, qr/SVCB 1 \..*alpn=h2,h3/,
    'alpn comma-list renders');
like(stderr, qr/SVCB 1 \..*alpn=.*h2\\,foo,h3/,
    'alpn preserves escaped comma');
like(stderr, qr/SVCB 1 \..*mandatory=alpn,port.*alpn=.*port=443/,
    'mandatory renders before alpn and port (ascending order)');
like(stderr, qr/SVCB 1 \..*key65500=/,
    'generic keyN renders');
like(stderr, qr/SVCB 1 \..*dohpath=\/dns-query\{\?dns\}/,
    'dohpath renders');
like(stderr, qr/SVCB 1 \..*ech=AEX\+DQ/,
    'ech renders as base64');
like(stderr, qr/SVCB 1 \..*ipv4hint=192\.0\.2\.1,192\.0\.2\.2/,
    'ipv4hint comma-list renders');
like(stderr, qr/SVCB 1 \..*ipv6hint=2001:db8::1/,
    'ipv6hint renders');

run('./validns', @threads, '-v',
    $srcdir . '/t/issues/65-caa-validation/example.sec.zone');
is(rc, 0, 'CAA verbose parse OK');
like(stderr, qr|CAA 0 issue "ca1\.example\.com"|,
    'CAA issue rendered');
like(stderr, qr|CAA 0 issue "ca2\.example\.com ; policy=ev account=12345"|,
    'CAA issue+params rendered');
like(stderr, qr|CAA 0 issuewild ";"|,
    'CAA issuewild rendered');
like(stderr, qr|CAA 128 iodef "mailto:abuse\@example\.sec"|,
    'CAA iodef mailto rendered');
like(stderr, qr|CAA 0 iodef "https://example\.sec/iodef"|,
    'CAA iodef https rendered');
like(stderr, qr|CAA 0 contactemail "ops\@example\.sec"|,
    'CAA contactemail rendered');
like(stderr, qr|CAA 0 contactphone "\+1 555 1212"|,
    'CAA contactphone rendered');

run('./validns', @threads, '-v', $srcdir . '/t/zones/eui.zone');
is(rc, 0, 'EUI48: zone with EUI48 + generic form parses');
like(stderr, qr/EUI48 00-00-5e-00-53-2a/, 'EUI48 canonical lowercase render');
like(stderr, qr/EUI48 00-00-5e-00-53-2b/, 'EUI48 mixed-case input normalized to lowercase');
like(stderr, qr/EUI64 00-00-5e-ef-10-00-00-2a/, 'EUI64 canonical lowercase render');
like(stderr, qr/EUI64 00-00-5e-ef-10-00-00-2b/, 'EUI64 mixed-case input normalized to lowercase');

# EUI human-form round-trip: -v output must re-parse back to valid records.
run('./validns', @threads, '-v', $srcdir . '/t/zones/eui.zone');
is(rc, 0, 'EUI round-trip source zone parses');
my @eui_lines = (stderr =~ /^->[^\n]*?\s((?:EUI48|EUI64)\s.*)$/mg);
ok(@eui_lines >= 4, 'EUI round-trip emitted at least 4 EUI lines');

my $euizone = write_temp_zone(
	"\$ORIGIN example.\n\$TTL 300\n",
	"\@ IN SOA ns.example. admin.example. ( 1 3600 600 86400 300 )\n",
	"  IN NS ns.example.\n",
	"ns IN A 192.0.2.1\n",
	(map { "h IN $_\n" } @eui_lines),
);

run('./validns', @threads, $euizone);
is(rc, 0, 'EUI round-trip rebuilt zone parses clean');

# CAA human-form round-trip: -v output must re-parse back to valid records.
run('./validns', @threads, '-v', $srcdir . '/t/zones/caa-roundtrip.zone');
is(rc, 0, 'CAA round-trip source zone parses');
my @caa_lines = (stderr =~ /^->[^\n]*?\s(CAA\s.*)$/mg);
ok(@caa_lines >= 6, 'CAA round-trip emitted at least 6 CAA lines');

my $tmpzone = write_temp_zone(
	"\$ORIGIN example.\n\$TTL 300\n",
	"\@ IN SOA ns.example. admin.example. ( 1 3600 600 86400 300 )\n",
	"  IN NS ns.example.\n",
	"ns IN A 192.0.2.1\n",
	(map { "\@ IN $_\n" } @caa_lines),
);

run('./validns', @threads, $tmpzone);
is(rc, 0, 'CAA round-trip rebuilt zone parses clean');

# LOC render: exact RFC 1876 presentation form.
run('./validns', @threads, '-v', $srcdir . '/t/zones/loc-roundtrip.zone');
is(rc, 0, 'LOC verbose zone parses OK');
like(stderr, qr/LOC 55 40 15\.258 N 12 41 56\.378 E 9\.57m 10m 10000m 10m/,
    'LOC N/E all-fields render, trailing zeros trimmed');
like(stderr, qr/LOC 42 21 54\.500 N 71 6 18\.300 W -24m 30m 10000m 10m/,
    'LOC N/W negative altitude render');
like(stderr, qr/LOC 33 51 31\.000 S 151 12 51\.000 E -0\.5m 1m 10000m 10m/,
    'LOC S/E negative sub-meter altitude, explicit sign');
like(stderr, qr/LOC 0 0 0\.000 N 0 0 0\.000 E 0m 1m 10000m 10m/,
    'LOC origin, zero altitude, defaulted vertical precision');
like(stderr, qr/LOC 0 0 0\.000 N 0 0 0\.000 E 0m 90000000m 90000000m 90000000m/,
    'LOC max-magnitude size/precision (64-bit path)');
like(stderr, qr/LOC 1 2 3\.000 N 4 5 6\.000 E 10m 1m 10000m 10m/,
    'LOC altitude-only fills size/hp/vp defaults');
like(stderr, qr/LOC 1 2 3\.000 N 4 5 6\.000 E 10m 2m 10000m 10m/,
    'LOC altitude+size fills hp/vp defaults');

# LOC human-form round-trip: -v output must re-parse back to valid records.
run('./validns', @threads, '-v', $srcdir . '/t/zones/loc-roundtrip.zone');
is(rc, 0, 'LOC round-trip source zone parses');
my @loc_lines = (stderr =~ /^->[^\n]*?\s(LOC\s.*)$/mg);
ok(@loc_lines >= 7, 'LOC round-trip emitted at least 7 LOC lines');

my $loczone = write_temp_zone(
	"\$ORIGIN example.\n\$TTL 300\n",
	"\@ IN SOA ns.example. admin.example. ( 1 3600 600 86400 300 )\n",
	"  IN NS ns.example.\n",
	"ns IN A 192.0.2.1\n",
	(map { "h IN $_\n" } @loc_lines),
);

run('./validns', @threads, $loczone);
is(rc, 0, 'LOC round-trip rebuilt zone parses clean');

# NSEC render (regression guard for the shared type-bitmap renderer)
run('./validns', @threads, '-v', '-t1501789043', $srcdir . '/t/zones/example.sec.signed');
like(stderr, qr/delegation2\.example\.sec\. IN 300 NSEC delegation3\.example\.sec\. NS RRSIG NSEC/,
    'NSEC render: next domain + type bitmap');

# NSEC type-bitmap render overflow regression: rdata too large for the
# 1024-byte render buffer must not corrupt the stack; a faithful rendering
# is impossible, so the verbose line is printed with no rdata at all.
run('./validns', @threads, '-v', $srcdir . '/t/zones/nsec-bitmap-overflow.zone');
like(stderr, qr/a\.example\.com\. IN \d+ NSEC$/m,
    'NSEC large type bitmap renders as no rdata');
like(stderr, qr/c\.example\.com\. IN \d+ NSEC$/m,
    'NSEC with oversized next_domain renders as no rdata');

# Same policy for RRSIG: a signature near the parser's 4095-char base64
# bound re-encodes to more than the 4096-byte render buffer holds, so the
# verbose line is printed with no rdata instead of a truncated one.
{
	my $tmp = write_temp_zone(
		"\$ORIGIN example.com.\n\$TTL 300\n",
		"\@ IN SOA ns.example.com. admin.example.com. ( 1 3600 600 86400 300 )\n",
		"  IN NS ns.example.com.\n",
		"rsig IN RRSIG ( A 8 3 300 20270101000000 20260101000000 12345 example.com.\n",
		(map { ("  ", 'A' x 2046, "\n") } 1 .. 2),
		")\n",
	);

	run('./validns', @threads, '-v', $tmp);
	like(stderr, qr/rsig\.example\.com\. IN \d+ RRSIG$/m,
	    'RRSIG signature too large to render prints no rdata');
}

run('./validns', @threads, $srcdir . '/t/zones/svcb-errors.zone');
isnt(rc, 0, 'svcb-errors.zone is rejected');
like(stderr, qr/duplicate SvcParamKey/, 'duplicate SvcParamKey detected');
like(stderr, qr/AliasMode SVCB must have no SvcParams/,
    'AliasMode with params rejected');
like(stderr, qr/AliasMode target must not be the root/,
    'AliasMode root target rejected');
like(stderr, qr/mandatory may not list itself/,
    'mandatory listing itself rejected');
like(stderr, qr/mandatory may not list no-default-alpn/,
    'mandatory listing no-default-alpn rejected');
like(stderr, qr/mandatory references key 99 which is not present/,
    'mandatory listing absent key rejected');
like(stderr, qr/no-default-alpn requires alpn/,
    'no-default-alpn without alpn rejected');
like(stderr, qr/priority range is not valid/,
    'priority out of range rejected');
like(stderr, qr/port value out of range/,
    'port out of range rejected');
like(stderr, qr/alpn value expected/,
    'empty alpn list rejected');
like(stderr, qr/ipv4hint address "not-an-address" is not valid/,
    'malformed ipv4 hint rejected');
like(stderr, qr/ipv6hint address "192\.0\.2\.1" is not valid/,
    'ipv4 literal in ipv6hint rejected');
like(stderr, qr/invalid character in SvcParamKey name/,
    'invalid generic key name rejected');
like(stderr, qr/(error decoding base64 ech|ech expected)/,
    'malformed ech base64 rejected');

# Root zone test - TLDs should belong to the root zone
run('./validns', @threads, '-z.', $srcdir . '/t/zones/root-zone');
is(rc, 0, 'root zone with TLDs parses correctly');

# Issue #86: KSK check with no DNSKEY records should not crash
run('./validns', @threads, '-p', 'ksk-exists', $srcdir . '/t/zones/dnssec-no-dnskey.zone');
isnt(rc, 11, 'ksk-exists check with no DNSKEY does not crash');
like(stderr, qr/No DNSKEY found/, 'ksk-exists check reports missing DNSKEY');

# Issue #86: NSEC3PARAM not-apex check with invalid record should not crash
run('./validns', @threads, '-p', 'nsec3param-not-apex', $srcdir . '/t/zones/nsec3param-not-in-zone.zone');
isnt(rc, 11, 'nsec3param-not-apex check with bad record does not crash');
like(stderr, qr/does not belong to zone/, 'nsec3param-not-apex reports zone membership error');

# SPF many segments test (>20)
run('./validns', @threads, $srcdir . '/t/zones/spf-many-segments.zone');
is(rc, 0, 'SPF records handle >20 segments');

# Generic record verbose output test
run('./validns', @threads, '-v', $srcdir . '/t/zones/generic-verbose.zone');
is(rc, 0, 'generic verbose zone parses OK');
like(stderr, qr/TYPE262\s+\\#\s+10\s+00112233445566778899/,
	"Generic record shows full hex data in verbose output");
like(stderr, qr/TYPE262\s+\\#\s+1\s+ff\b/,
	"1-byte generic record displays correctly");
like(stderr, qr/TYPE262\s+\\#\s+5\s+0000000000\b/,
	"All-zeros generic record displays correctly");
like(stderr, qr/TYPE262\s+\\#\s+5\s+ffffffffff\b/,
	"All-FFs generic record displays lowercase hex");
like(stderr, qr/TYPE262\s+\\#\s+200\s+000102030405060708090a0b/,
	"Large (200-byte) generic record displays correctly");

SKIP: {
skip "SHA-1 disabled by system crypto policy", 2 if $sha1_disabled;
run('./validns', @threads, '-t1303720010', $srcdir . '/t/zones/example.sec.signed');
isnt(rc, 0, 'valid signed zone with timestamps in the future');
@e = split /\n/, stderr;
like(shift @e, qr/signature is too new/, "signature is too new");
}

SKIP: {
skip "SHA-1 disabled by system crypto policy", 2 if $sha1_disabled;
run('./validns', @threads, '-t1561789043', $srcdir . '/t/zones/example.sec.signed');
isnt(rc, 0, 'valid signed zone with timestamps in the past');
@e = split /\n/, stderr;
like(shift @e, qr/signature is too old/, "signature is too old");
}

run('./validns', @threads, '-s', '-pall', $srcdir . '/t/zones/manyerrors.zone');
isnt(rc, 0, 'bad zone returns an error');
# Filter out crypto policy warnings (e.g., SHA-1 disabled on CentOS 9)
@e = grep { !/disabled by system crypto policy/ } split /\n/, stderr;

# main.c
like(shift @e, qr/unrecognized directive: \$FUNNYDIRECTIVE/, "unrecognized directive 1");
like(shift @e, qr/unrecognized directive: \$ORIGINBUTNOTREALLY/, "unrecognized directive 2");
like(shift @e, qr/bad \$ORIGIN format/, "not really an origin");
like(shift @e, qr/\$ORIGIN value expected/, "empty origin");
like(shift @e, qr/garbage after valid \$ORIGIN/, "bad origin");
like(shift @e, qr/unrecognized directive: \$TTLAST/, "unrecognized directive 3");
like(shift @e, qr/bad \$TTL format/, "not really a TTL");
like(shift @e, qr/\$TTL value expected/, "empty TTL");
like(shift @e, qr/\$TTL value expected/, "funny TTL");
like(shift @e, qr/\$TTL value is not valid/, "bad TTL");
like(shift @e, qr/\$TTL value is not valid/, "bad TTL take 2");
like(shift @e, qr/garbage after valid \$TTL/, "bad TTL take 3");
like(shift @e, qr/unrecognized directive: \$INCLUDESSIMO/, "unrecognized directive 4");
like(shift @e, qr/bad \$INCLUDE format/, "not really an include");
like(shift @e, qr/unrecognized directive: \$/, "unrecognized directive 5");
like(shift @e, qr/unrecognized directive: \$/, "unrecognized directive 6");
# TODO once INCLUDE is implemented, add more tests
## TODO continue main.c at "cannot assume previous name"

like(shift @e, qr/class or type expected/, "nonsense line");
like(shift @e, qr/the first record in the zone must be an SOA record/, "non-SOA 1");
like(shift @e, qr/the first record in the zone must be an SOA record/, "non-SOA 2");
like(shift @e, qr/serial is out of range/, "out of range serial");
like(shift @e, qr/there could only be one SOA in a zone/, "another SOA");
like(shift @e, qr/name server domain name expected/, "empty NS");
like(shift @e, qr/garbage after valid NS/, "bad NS");
like(shift @e, qr/IPv4 address is not valid/, "empty A");
like(shift @e, qr/garbage after valid A data/, "bad A");
like(shift @e, qr/cannot parse IPv4 address/, "bad A IP");
like(shift @e, qr/IPv4 address is not valid/, "not an IP in A");
like(shift @e, qr/IPv6 address is not valid/, "empty AAAA");
like(shift @e, qr/garbage after valid AAAA data/, "bad AAAA");
like(shift @e, qr/IPv6 address is not valid/, "bad AAAA IP");
like(shift @e, qr/IPv6 address is not valid/, "not an IP in AAAA");
like(shift @e, qr/MX preference expected/, "empty MX");
like(shift @e, qr/MX exchange expected/, "MX without exchange");
like(shift @e, qr/garbage after valid MX data/, "bad MX");

like(shift @e, qr/bad SHA-256 hash length/, "TLSA SHA-256");
like(shift @e, qr/bad SHA-512 hash length/, "TLSA SHA-512");
like(shift @e, qr/certificate association data: hex data does not represent whole number of bytes/, "TLSA nibbles");

like(shift @e, qr/bad certificate usage field/, "TLSA certificate usage");
like(shift @e, qr/TTL is not valid/, "TLSA certificate usage fallout");
like(shift @e, qr/certificate usage field expected/, "TLSA certificate usage");
like(shift @e, qr/TTL is not valid/, "TLSA certificate usage fallout");

like(shift @e, qr/bad selector field/, "TLSA selector");
like(shift @e, qr/TTL is not valid/, "TLSA selector fallout");
like(shift @e, qr/selector field expected/, "TLSA selector");
like(shift @e, qr/TTL is not valid/, "TLSA selector fallout");

like(shift @e, qr/bad matching type field/, "TLSA matching type");
like(shift @e, qr/TTL is not valid/, "TLSA matching type fallout");
like(shift @e, qr/matching type field expected/, "TLSA matching type");
like(shift @e, qr/TTL is not valid/, "TLSA matching type fallout");

like(shift @e, qr/outside.org. does not belong to zone galaxyplus.org./, "outsider");
like(shift @e, qr/long.outside.org. does not belong to zone galaxyplus.org./, "long outsider");
like(shift @e, qr/outsidegalaxyplus.org. does not belong to zone galaxyplus.org./, "tricky outsider");
like(shift @e, qr/bad algorithm 177/, "bad CERT algorithm");
like(shift @e, qr/bad or unsupported algorithm meow/, "bad CERT algorithm mnemonic");
like(shift @e, qr/bad certificate type 100000/, "bad CERT type");
like(shift @e, qr/is reserved by IANA/, "reserved CERT type");
like(shift @e, qr/certificate type 700 is unassigned/, "unassigned CERT type");
like(shift @e, qr/bad certificate type meow/, "bad CERT type");
like(shift @e, qr/bad key tag/, "bad key tag");
like(shift @e, qr/certificate expected/, "bad base64");
like(shift @e, qr/ECDSAP256SHA256 signature has wrong length/, "ECDSA RRSIG bad length");
like(shift @e, qr/there could only be one SOA in a zone/, "another SOA at the end");
like(shift @e, qr/record name is not valid/, "wildcard is the middle");
like(shift @e, qr/record name: bad wildcard/, "bad wildcard");
like(shift @e, qr/name cannot start with a dot/, "dot-something");
like(shift @e, qr/name cannot start with a dot/, "dot-dot");
like(shift @e, qr/garbage after valid DNAME data/, "DNAME garbage");

like(shift @e, qr/CAA flags expected/, "CAA without a flag");
like(shift @e, qr/CAA tag expected/, "CAA without a tag");
like(shift @e, qr/CAA unrecognized flags value/, "CAA with bad flags");
like(shift @e, qr/CAA unrecognized tag name/, "CAA with bad tag");
like(shift @e, qr/CAA tag is not valid/, "CAA with bad chars in tag");
like(shift @e, qr/CAA missing tag value/, "CAA issue without a value");
like(shift @e, qr/garbage after valid CAA/, "CAA issue + garbage after \";\"");
like(shift @e, qr/CAA issue value is not a valid domain or parameter list/, "CAA issue bad domain");
like(shift @e, qr/CAA issue parameter is missing a value/, "CAA issue param missing value");
like(shift @e, qr/CAA issue parameter is missing a tag/, "CAA issue param missing tag");
like(shift @e, qr/CAA issue parameter contains a forbidden character/, "CAA issue param forbidden char");
like(shift @e, qr/CAA issuewild value is not a valid domain or parameter list/, "CAA issuewild bad domain");
like(shift @e, qr/CAA issuewild parameter is missing a value/, "CAA issuewild param missing value");
like(shift @e, qr/CAA issuewild parameter is missing a tag/, "CAA issuewild param missing tag");
like(shift @e, qr/CAA iodef value is not a URI/, "CAA iodef bare word");
like(shift @e, qr/CAA iodef value is not a URI/, "CAA iodef scheme-only");
like(shift @e, qr/CAA contactemail value is not a valid email address/, "CAA contactemail no \@");
like(shift @e, qr/CAA contactemail value is not a valid email address/, "CAA contactemail double \@");
like(shift @e, qr/CAA contactphone value is not a valid global number/, "CAA contactphone no plus");
like(shift @e, qr/CAA contactphone value is not a valid global number/, "CAA contactphone empty");
like(shift @e, qr/EUI48 rdata is not valid/, "EUI48 too few octets");
like(shift @e, qr/EUI48 rdata is not valid/, "EUI48 single-digit group");
like(shift @e, qr/EUI48 rdata is not valid/, "EUI48 non-hex digit");
like(shift @e, qr/garbage after valid EUI48 data/, "EUI48 trailing garbage");
like(shift @e, qr/EUI64 rdata is not valid/, "EUI64 too few octets");
like(shift @e, qr/EUI64 rdata is not valid/, "EUI64 single-digit group");
like(shift @e, qr/EUI64 rdata is not valid/, "EUI64 non-hex digit");
like(shift @e, qr/garbage after valid EUI64 data/, "EUI64 trailing garbage");

like(shift @e, qr/bad \\DDD escape/, "TXT short \\DDD escape");
like(shift @e, qr/bad \\DDD escape/, "TXT \\DDD escape value too large");

## actual validations done after parsing
like(shift @e, qr/CNAME and other data/, "CNAME+CNAME");
like(shift @e, qr/CNAME and other data/, "CNAME+something else");
like(shift @e, qr/there should be at least two NS records/, "NS limit");
like(shift @e, qr/not a proper domain name for an SMIMEA record/, "SMIMEA host 1");
like(shift @e, qr/not a proper prefixed DNS domain name/, "TLSA host 1");
like(shift @e, qr/not a proper domain name for an SMIMEA record/, "SMIMEA host 2");
like(shift @e, qr/not a proper domain name for an SMIMEA record/, "SMIMEA host 3");
like(shift @e, qr/not a proper prefixed DNS domain name/, "TLSA host 2");

like(shift @e, qr/TTL values differ within an RR set/, "TTL conflict");
like(shift @e, qr/multiple DNAMEs/, "Multiple DNAMEs");
like(shift @e, qr/DNAME must not have any children \(but something.zzzz3.galaxyplus.org. exists\)/, "DNAME with children");
like(shift @e, qr/CNAME and other data/, "DNAME+CNAME");
like(shift @e, qr/DNAME must not have any children \(but z.zzzz5.galaxyplus.org. exists\)/, "DNAME with children 2");

is(+@e, 0, "no unaccounted errors");
#like(stdout, qr/validation errors: XX/, "error count");

SKIP: {
skip "SHA-1 disabled by system crypto policy", 25 if $sha1_disabled;
run('./validns', @threads, '-s', '-t1320094109', $srcdir . '/t/zones/example.sec.signed.with-errors');
isnt(rc, 0, 'bad signed zone returns an error');
@e = split /\n/, stderr;

like(shift @e, qr/wrong GOST .* digest length/, "wrong GOST digest length");
like(shift @e, qr/MX exists, but NSEC does not mention it/, "NSEC incomplete");
like(shift @e, qr/NSEC mentions SRV, but no such record found/, "NSEC lists too much");
like(shift @e, qr/RRSIG exists for non-existing type NAPTR/, "RRSIG for absent");
like(shift @e, qr/RRSIG's original TTL differs from corresponding record's/, "RRSIG orig ttl bad");
like(shift @e, qr/RRSIG\(NSEC\): cannot find a signer key/, "unknown signer");
like(shift @e, qr/NSEC says mail.example.sec. comes after example.sec., but ghost.example.sec. does/, "NSEC chain error");
like(shift @e, qr/NSEC says ns1.example.sec. comes after mail.example.sec., but nosuch.example.sec. does/, "NSEC chain error");
like(shift @e, qr/NSEC says ns2.example.sec. comes after ns1.example.sec., but ns122.example.sec. does/, "NSEC chain error");
like(shift @e, qr/NSEC says www.example.sec. is the last name, but zzz.example.sec. exists/, "NSEC chain not the last");
like(shift @e, qr/NSEC says zzzz.example.sec. comes after zzz.example.sec., but nothing does/, "NSEC chain unexpected last");
like(shift @e, qr/ns122.example.sec. A: no covering RRSIG in a signed zone/, "no covering RRSIG for ns122 A");
like(shift @e, qr/zzz.example.sec. CNAME: no covering RRSIG in a signed zone/, "no covering RRSIG for zzz CNAME");
like(shift @e, qr/zzz.example.sec. NSEC: no covering RRSIG in a signed zone/, "no covering RRSIG for zzz NSEC");
like(shift @e, qr/RRSIG\(NSEC\): cannot verify signature/, "NSEC incomplete fallout") for 1..4;
like(shift @e, qr/RRSIG\(NSEC\): cannot verify signature/, "NSEC lists too much fallout") for 1..4;

is(+@e, 0, "no unaccounted errors");
}

# RFC 2181 policy checks
run('./validns', @threads, '-p', 'all', '-z', 'example1.jp', $srcdir . '/t/zones/mx-ns-alias');
is(rc, 0, 'parses OK if we cannot determine the fact of aliasing');
run('./validns', @threads, '-p', 'all', '-z', 'example.jp', $srcdir . '/t/zones/mx-ns-alias');
isnt(rc, 0, 'RFC 2181 policy checks are active');
@e = split /\n/, stderr;
like(shift @e, qr/NS data is an alias/, "NS data is an alias");
like(shift @e, qr/MX exchange is an alias/, "MX exchange is an alias");
is(+@e, 0, "no unaccounted errors for 2181 policy checks");

run('./validns', @threads, '-p', 'mx-alias', '-p', 'ns-alias', '-z', 'example.jp', $srcdir . '/t/zones/mx-ns-alias');
isnt(rc, 0, 'RFC 2181 policy checks are active (individually activated)');
@e = split /\n/, stderr;
like(shift @e, qr/NS data is an alias/, "NS data is an alias");
like(shift @e, qr/MX exchange is an alias/, "MX exchange is an alias");
is(+@e, 0, "no unaccounted errors for individually activated checks");

run('./validns', @threads, '-p', 'mx-alias', '-z', 'example.jp', $srcdir . '/t/zones/mx-ns-alias');
isnt(rc, 0, 'mx-alias policy check');
@e = split /\n/, stderr;
like(shift @e, qr/MX exchange is an alias/, "MX exchange is an alias");
is(+@e, 0, "no unaccounted errors for mx-alias check");

run('./validns', @threads, '-p', 'ns-alias', '-z', 'example.jp', $srcdir . '/t/zones/mx-ns-alias');
isnt(rc, 0, 'ns-alias policy check');
@e = split /\n/, stderr;
like(shift @e, qr/NS data is an alias/, "NS data is an alias");
is(+@e, 0, "no unaccounted errors for ns-alias check");

# RP policy
run('./validns', @threads, '-p', 'all', '-z', 'example.jp', $srcdir . '/t/zones/rp-policy');
isnt(rc, 0, 'RP policy check is active');
@e = split /\n/, stderr;
like(shift @e, qr/RP TXT.*?does not exist/, "RP TXT is not there");
is(+@e, 0, "no unaccounted errors for RP policy checks");

run('./validns', @threads, '-z', 'example.jp', $srcdir . '/t/zones/rp-policy');
is(rc, 0, 'RP policy check is inactive');

run('./validns', @threads, '-v', $srcdir . '/t/zones/ttl-regression.zone');
is(rc, 0, 'ttl regression parses OK');
like(stderr, qr/ns\.example\.com\.\s+IN\s+600\s+A\s+192\.0\.2\.1/,
	"Default TTL changes correctly");

run('./validns', @threads, '-v', $srcdir . '/t/zones/misc-regression.zone');
is(rc, 0, 'misc regression parses OK');
like(stderr, qr/"alias"/, "We parse \\nnn in text correctly");
like(stderr, qr/"\\";"/, "We parse \\\" in text correctly");

run('./validns', @threads, '-v', $srcdir . '/t/zones/ttl.zone');
is(rc, 0, 'ttl test parses OK');
like(stderr, qr/ns\.example\.com\.\s+IN\s+600\s+A\s+192\.0\.2\.1/,
	"Default TTL changes correctly");
like(stderr, qr/\s+example\.com\.\s+IN\s+200\s+NS\s+ns\.example\.com\./,
	"TTL without default picked up correctly");

# DNSKEY extra checks
run('./validns', @threads, $srcdir . '/t/zones/dnskey-exponent.zone');
is(rc, 0, 'dnskey parses OK without policy checks');
run('./validns', @threads, '-p', 'all', $srcdir . '/t/zones/dnskey-exponent.zone');
isnt(rc, 0, 'dnskey extra checks fail');
@e = split /\n/, stderr;
like(shift @e, qr/leading zero octets in public key exponent/, "leading zeroes in exponent 1");
like(shift @e, qr/leading zero octets in public key exponent/, "leading zeroes in exponent 2");
is(+@e, 0, "no unaccounted errors for DNSKEY policy checks");

# issue 36: https://github.com/tobez/validns/issues/36 - $include implementation
run('./validns', @threads, $srcdir . '/t/issues/36-include/empty-include.zone');
isnt(rc, 0, 'empty include detected');
@e = split /\n/, stderr;
like(shift @e, qr/\bINCLUDE directive with empty file name\b/, "Expected error with empty INCLUDE");
is(+@e, 0, "no unaccounted errors for empty include");

run('./validns', @threads, $srcdir . '/t/issues/36-include/missing-include.zone');
isnt(rc, 0, 'missing include detected');
@e = split /\n/, stderr;
like(shift @e, qr/\bNo such file or directory\b/, "Expected error with missing INCLUDE file");
is(+@e, 0, "no unaccounted errors for missing include");

run('./validns', @threads, '-v', $srcdir . '/t/issues/36-include/include.zone');
is(rc, 0, 'zone with nested includes parses ok');
@e = split /\n/, stderr;
for my $rx ((qr/\d:\s+example\.com\.\s+IN\s+\d+\s+NS\s+ns\.example\.com\./,
			 qr/\d:\s+inc1\.example\.com\.\s+IN\s+\d+\s+A\s+11\.11\.11\.11/,
			 qr/\d:\s+inc2\.inc1\.example\.com\.\s+IN\s+\d+\s+A\s+55\.55\.55\.55/,
			 qr/\d:\s+inc1\.example\.com\.\s+IN\s+\d+\s+AAAA\s+1111::1111/,
			 qr/\d:\s+example\.com\.\s+IN\s+\d+\s+A\s+99\.99\.99\.99/))
{
	my $ok = 0;
	for my $e (@e) {
		$ok = 1 if $e =~ $rx;
	}
	is($ok, 1, "found expected record with correct ORIGIN tracked across INCLUDEs");
}

# issue 122: https://codeberg.org/DNS-OARC/validns/issues/122 - two-argument $INCLUDE
run('./validns', @threads, '-v', $srcdir . '/t/issues/122-include-2arg/parent.zone');
is(rc, 0, 'zone with two-arg $INCLUDE parses ok');
@e = split /\n/, stderr;
for my $rx ((qr/\d:\s+example\.com\.\s+IN\s+\d+\s+NS\s+ns\.example\.com\./,
			 qr/\d:\s+abs-sub\.example\.com\.\s+IN\s+\d+\s+A\s+100\.0\.0\.1\b/,
			 qr/\d:\s+inside\.abs-sub\.example\.com\.\s+IN\s+\d+\s+A\s+100\.0\.0\.2\b/,
			 qr/\d:\s+after-abs\.example\.com\.\s+IN\s+\d+\s+A\s+11\.11\.11\.11\b/,
			 qr/\d:\s+relsub\.example\.com\.\s+IN\s+\d+\s+A\s+200\.0\.0\.1\b/,
			 qr/\d:\s+inside\.relsub\.example\.com\.\s+IN\s+\d+\s+A\s+200\.0\.0\.2\b/,
			 qr/\d:\s+after-rel\.example\.com\.\s+IN\s+\d+\s+A\s+22\.22\.22\.22\b/,
			 qr/\d:\s+commented-test\.example\.com\.\s+IN\s+\d+\s+A\s+240\.0\.0\.1\b/,
			 qr/\d:\s+inside\.commented-test\.example\.com\.\s+IN\s+\d+\s+A\s+240\.0\.0\.2\b/,
			 qr/\d:\s+after-comment\.example\.com\.\s+IN\s+\d+\s+A\s+33\.33\.33\.33\b/))
{
	my $ok = 0;
	for my $e (@e) {
		$ok = 1 if $e =~ $rx;
	}
	is($ok, 1, "two-arg \$INCLUDE: matched $rx");
}

run('./validns', @threads, $srcdir . '/t/issues/122-include-2arg/garbage.zone');
isnt(rc, 0, 'two-arg $INCLUDE with trailing garbage rejected');
@e = split /\n/, stderr;
like(shift @e, qr/garbage after valid \$INCLUDE directive/, "Expected error with trailing garbage after \$INCLUDE origin");
is(+@e, 0, "no unaccounted errors for two-arg \$INCLUDE garbage case");

# issue 21: https://github.com/tobez/validns/issues/21
run('./validns', @threads, '-t1345815800', $srcdir . '/t/issues/21-nsec3-without-corresponding/example.sec.signed');
is(rc, 0, 'issue 21 did not come back');

# Stage 3: NSEC3 render must include next-hashed-owner and type bitmap
run('./validns', @threads, '-v', '-t1345815800',
    $srcdir . '/t/issues/21-nsec3-without-corresponding/example.sec.signed');
like(stderr, qr/NSEC3 1 0 1 94CD 1F02V227102AEBUICGPQNPFSMSQ56UNC$/m,
    'NSEC3 render: next-hashed-owner, empty type bitmap');
like(stderr, qr/NSEC3 1 0 1 94CD B23STANUG8J6J7Q86S978IDEOHR9SKOK NS SOA RRSIG DNSKEY NSEC3PARAM/,
    'NSEC3 render: next-hashed-owner + type bitmap');

# issue 24: https://github.com/tobez/validns/issues/24
run('./validns', @threads, '-t1345815800', $srcdir . '/t/issues/24-delegated-nsec3/example.sec.signed');
is(rc, 0, 'issue 24 did not come back');

# issue 25: https://github.com/tobez/validns/issues/25
run('./validns', @threads, '-t1345815800', $srcdir . '/t/issues/25-nsec/example.sec.signed');
is(rc, 0, 'issue 25 did not come back');

# issue 41: https://github.com/tobez/validns/issues/41
run('./validns', @threads, '-t1345815800', '-pksk-exists', $srcdir . '/t/issues/25-nsec/example.sec.signed');
isnt(rc, 0, 'KSK policy check fails');
@e = split /\n/, stderr;
like(shift @e, qr/\bNo KSK found\b/, "KSK policy check produces expected error output");
is(+@e, 0, "no unaccounted errors for KSK policy check");

SKIP: {
skip "SHA-1 disabled by system crypto policy", 1 if $sha1_disabled;
run('./validns', @threads, '-t1435671103', '-pksk-exists', $srcdir . '/t/issues/41-ksk-policy-check/example.sec.signed');
is(rc, 0, 'signed zone with KSK parses ok when KSK policy check is active');
}

run('./validns', @threads, '-pksk-exists', $srcdir . '/t/zones/galaxyplus.org');
is(rc, 0, 'unsigned zone ignores KSK policy checks');

# issue 110: https://codeberg.org/DNS-OARC/validns/issues/110
run('./validns', @threads, '-t1778407200', '-pksk-exists', $srcdir . '/t/issues/110-csk-ksk-exists/csk.zone');
is(rc, 0, 'CSK (single key with SEP bit) satisfies ksk-exists policy');

# Codeberg issue 89: RRSIG inception/expiration timestamps past the
# 2038-01-19 03:14:07 UTC boundary must parse and validate correctly.
# The zone parser previously rejected any year > 2037 outright.
run('./validns', @threads, '-t1778407200', $srcdir . '/t/issues/89-year-2038-timestamp/example.sec.signed.across-2038');
is(rc, 0, 'issue 89: RRSIG window crossing 2038-01-19 validates');
run('./validns', @threads, '-t2220480000', $srcdir . '/t/issues/89-year-2038-timestamp/example.sec.signed.after-2038');
is(rc, 0, 'issue 89: RRSIG window fully past 2038-01-19 validates');

# Issue 89 follow-up: -t must reject values that don't fit a uint32_t
# (RFC 4034 §3.1.5 timeline) rather than silently clamping.  With the
# old strtol() path big values clamped to LONG_MAX on 32-bit hosts
# (breaking post-2038 RRSIG validation) and truncated to UINT32_MAX on
# 64-bit hosts (silently mis-interpreting the value).  Use an unsigned
# zone so any "validation passed" path would mean -t was silently
# accepted instead of rejected at the CLI level.
run('./validns', @threads, '-t99999999999', $srcdir . '/t/zones/galaxyplus.org');
isnt(rc, 0, 'issue 89: -t out-of-range value is rejected (not silently clamped)');
like(stderr, qr/-t value must be an unsigned 32-bit epoch/,
    'issue 89: -t out-of-range emits a specific error message');

# issue 42: https://codeberg.org/DNS-OARC/validns/issues/42 - NSEC3PARAM/NSEC3 chain consistency
run('./validns', @threads, '-t1800057600', $srcdir . '/t/issues/42-nsec3-consistency/example.sec.signed');
is(rc, 0, 'issue 42: consistent NSEC3 chain validates clean');

run('./validns', @threads, '-t1800057600', $srcdir . '/t/issues/42-nsec3-consistency/flags-mismatch.signed');
isnt(rc, 0, 'issue 42: opt-out flag mismatch is detected');
@e = split /\n/, stderr;
like(shift @e, qr/RRSIG\(NSEC3\): cannot verify signature/, "issue 42 flags: corrupted NSEC3 fails its RRSIG");
like(shift @e, qr/inconsistent NSEC3 flags/, "issue 42: inconsistent NSEC3 flags reported");
is(+@e, 0, "issue 42 flags: no unaccounted errors");

run('./validns', @threads, '-t1800057600', $srcdir . '/t/issues/42-nsec3-consistency/iterations-mismatch.signed');
isnt(rc, 0, 'issue 42: iterations mismatch is detected');
@e = split /\n/, stderr;
like(shift @e, qr/RRSIG\(NSEC3\): cannot verify signature/, "issue 42 iterations: corrupted NSEC3 fails its RRSIG");
like(shift @e, qr/inconsistent NSEC3 iterations/, "issue 42: inconsistent NSEC3 iterations reported");
is(+@e, 0, "issue 42 iterations: no unaccounted errors");

run('./validns', @threads, '-t1800057600', $srcdir . '/t/issues/42-nsec3-consistency/salt-mismatch.signed');
isnt(rc, 0, 'issue 42: salt mismatch is detected');
@e = split /\n/, stderr;
like(shift @e, qr/RRSIG\(NSEC3\): cannot verify signature/, "issue 42 salt: corrupted NSEC3 fails its RRSIG");
like(shift @e, qr/inconsistent NSEC3 salt/, "issue 42: inconsistent NSEC3 salt reported");
is(+@e, 0, "issue 42 salt: no unaccounted errors");

run('./validns', @threads, '-t1800057600', $srcdir . '/t/issues/42-nsec3-consistency/salt-content-mismatch.signed');
isnt(rc, 0, 'issue 42: equal-length salt content mismatch is detected');
@e = split /\n/, stderr;
like(shift @e, qr/RRSIG\(NSEC3\): cannot verify signature/, "issue 42 salt-content: corrupted NSEC3 fails its RRSIG");
like(shift @e, qr/inconsistent NSEC3 salt/, "issue 42: equal-length salt mismatch reported (memcmp path)");
is(+@e, 0, "issue 42 salt-content: no unaccounted errors");

# Codeberg issue 65: CAA tag-value validation must not break wire encoding.
# The signed zone carries a rich CAA RRset; any encoding regression would
# invalidate the RRSIG and fail this test.
run('./validns', @threads, '-t1800057600',
    $srcdir . '/t/issues/65-caa-validation/example.sec.signed');
is(rc, 0, 'issue 65: CAA-rich signed zone verifies (wiredata round-trip via RRSIG)');

# Codeberg issue 126: EUI48/EUI64 wire encoding must round-trip through RRSIG.
run('./validns', @threads, '-t1800057600',
    $srcdir . '/t/issues/126-eui-types/example.sec.signed');
is(rc, 0, 'issue 126: EUI signed zone verifies (wiredata round-trip via RRSIG)');

# RRSIG human-readable rendering: -v output must show all RRSIG fields,
# including the full signature, so that the output re-parses to a record
# with identical wire format (round-trip fixed point).  Timestamps are
# RFC 4034 presentation form, rendered in UTC regardless of local TZ.
{
    local $ENV{TZ} = 'America/Los_Angeles';
    run('./validns', @threads, '-v', '-t1800057600',
        $srcdir . '/t/issues/65-caa-validation/example.sec.signed');
    is(rc, 0, 'RRSIG verbose parse OK');
    like(stderr, qr/RRSIG SOA 8 2 300 20270131000000 20270101000000 64846 example\.sec\. \QPeyW8twmVfFsm6LCf33bQOT16Ct16zGwoVlz8tSP9mIDM80xvxJJt63V7cXoW51NQgI3iHvLU3UdiA6BqnkVSB0rjZrPJmHh7qt+ucwB1rpIiXnM1KPsvGeY1+PS3\/zp6x8n7b+jVx8\/fdC\/9B7zabYOvBRC07d2SrnyPshBbr8=\E$/m,
        'RRSIG renders all fields and the full signature, timestamps in UTC');

    # Round-trip fixed point: splice the rendered RRSIG lines into the
    # zone in place of the originals; every signature must still verify,
    # proving the rendered rdata is wire-identical to the original.
    my @rendered = (stderr =~ /^-> [^:]+:\d+: (\S+) IN (\d+) RRSIG (.+)$/mg);
    my @rrsig_lines;
    while (@rendered) {
        my ($name, $ttl, $rdata) = splice(@rendered, 0, 3);
        push @rrsig_lines, "$name $ttl IN RRSIG $rdata";
    }
    ok(@rrsig_lines >= 8, 'RRSIG round-trip: rendered RRSIG lines captured');

    open my $zfh, '<', $srcdir . '/t/issues/65-caa-validation/example.sec.signed'
        or die "cannot read issue-65 signed zone: $!";
    my @keep = grep { !/^\S+\s+\d+\s+IN\s+RRSIG\s/ } <$zfh>;
    close $zfh;
    my $tmpzone = write_temp_zone(@keep, (map { "$_\n" } @rrsig_lines));

    my $nsigs = scalar @rrsig_lines;
    run('./validns', @threads, '-s', '-t1800057600', $tmpzone);
    is(rc, 0, 'RRSIG round-trip rebuilt zone parses clean');
    like(stdout, qr/^signatures verified: $nsigs$/m,
        'RRSIG round-trip: all spliced signatures verify');

    # Timestamps past 2038-01-19 must render correctly even where
    # time_t is 32-bit (issue 89 family).
    run('./validns', @threads, '-v', '-t2220480000',
        $srcdir . '/t/issues/89-year-2038-timestamp/example.sec.signed.after-2038');
    is(rc, 0, 'RRSIG post-2038 verbose parse OK');
    like(stderr, qr/RRSIG SOA 8 2 300 20401231235959 20400101000000 13896 example\.sec\. \QRVkfMjNvzsPzpN6o3NhKHs\/agqP4rsb2wDasI\/nQsVCcCe3ipYudLazlpZrhWcDIwjxd76jJ168jLOnkAWtdRssNowrh3x4lH6Ze7TDK2z8\/YopIVJkRzHqiSbRUvTshKxa+cxrUC7nZ8YJn4yhRkPhEk8sfzU+0y4TD7nSpvy8=\E$/m,
        'RRSIG renders post-2038 timestamps correctly');

    # ECDSA signatures are stored DER-encoded internally; rendering must
    # convert back to the raw r||s pair of the presentation (and wire)
    # form, zero-padded to the per-curve component length.
    run('./validns', @threads, '-v', '-t1459259658',
        $srcdir . '/t/issues/51-support-curved-algorithms/13.example.com.signed');
    is(rc, 0, 'RRSIG ECDSA P-256 verbose parse OK');
    like(stderr, qr/RRSIG SOA 13 3 86400 20160428125218 20160329125218 18450 13\.example\.com\. \Q7LfQswmP8B9hr6Bg8nr9o8yd\/fe6n86HDhs9pAByPITSjdqML6Rwb4NOHWvFDZJXVA4mz5PeTG4JVHiGYU7HCw==\E$/m,
        'RRSIG ECDSA P-256 signature renders as the original r||s pair');

    run('./validns', @threads, '-v', '-t1459259658',
        $srcdir . '/t/issues/51-support-curved-algorithms/14.example.com.signed');
    is(rc, 0, 'RRSIG ECDSA P-384 verbose parse OK');
    like(stderr, qr/RRSIG SOA 14 3 86400 20160428125222 20160329125222 1045 14\.example\.com\. \Q5FEzZuz1HrgRNTakg4D24h1RrO1Kx9IDzXN6S\/00bsfO5AQ8hxVd2X7XzrYGdqs+gpecBpklWLG1MrEgzYvRVgPTVY0bL0U7GxmvqAp871WHyuJKB8NFTkgQDA7cA2Do\E$/m,
        'RRSIG ECDSA P-384 signature renders as the original r||s pair');
}

# Codeberg issue 78: the "signatures verified" stat must count each RRSIG
# once.  The fixture zone has 8 RRSIG records, two of which carry key tag
# 729 — and the zone publishes two DNSKEYs sharing that key tag (a KSK and
# a ZSK).  Pre-fix, validns counted one verification per candidate key,
# inflating the stat to 10.
run('./validns', @threads, '-s', '-t1664700000',
    $srcdir . '/t/issues/78-key-tag-collision/xn--leagaviika-52b.no.signed');
is(rc, 0, 'issue 78: zone with key-tag collision validates ok');
like(stdout, qr/^signatures verified: 8$/m,
     'issue 78: signatures counted once per RRSIG, not per candidate key');

# issue 26: https://github.com/tobez/validns/issues/26
run('./validns', @threads, '-t1349357570', $srcdir . '/t/issues/26-spurios-glue/example.sec.signed.no-optout');
is(rc, 0, 'issue 26 did not come back (NSEC3 NO optout)');
run('./validns', @threads, '-t1349357570', $srcdir . '/t/issues/26-spurios-glue/example.sec.signed.optout');
is(rc, 0, 'issue 26 did not come back (NSEC3 optout)');
run('./validns', @threads, '-t1349358570', $srcdir . '/t/issues/26-spurios-glue/example.sec.signed.nsec');
is(rc, 0, 'issue 26 did not come back (NSEC)');

# Codeberg issue 111: a real owner name whose label coincides with an NSEC3
# chain owner (i.e., another name's hash) must not be flagged as a typemap
# mismatch.  The NSEC3 records that happen to live at that name belong to
# the chain, not to the real owner, so they must not count toward the real
# owner's typemap when validating the NSEC3 covering the real owner itself.
run('./validns', @threads, '-t1743465600', $srcdir . '/t/issues/111-recursive-nsec3/nsec3-004.zone');
is(rc, 0, 'issue 111: real name coinciding with NSEC3 hash label parses ok');

# issues about NSEC chain validation raised by Daniel Stirnimann
run('./validns', @threads, '-t1361306089', $srcdir . '/t/issues/nsec-chain/example.com.signed');
is(rc, 0, 'all is good when all NSEC are there');
run('./validns', @threads, '-t1361306089', $srcdir . '/t/issues/nsec-chain/example.com.signed-without-first-nsec');
isnt(rc, 0, 'zone without first NSEC returns an error');
@e = split /\n/, stderr;
is(scalar @e, 1, "only one error here");
like(shift @e, qr/apex NSEC not found/, "apex NSEC not found");
run('./validns', @threads, '-t1361306089', $srcdir . '/t/issues/nsec-chain/example.com.signed-without-last-nsec');
isnt(rc, 0, 'zone without an NSEC returns an error');
@e = split /\n/, stderr;
is(scalar @e, 1, "only one error here");
like(shift @e, qr/broken NSEC chain example.com. -> domain1.example.com./, "broken NSEC chain detected");

# IPSECKEY tests
run('./validns', @threads, $srcdir . '/t/zones/ipseckey-errors');
isnt(rc, 0, 'bad zone returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/precedence expected/, "bad-precedence 1");
like(shift @e, qr/precedence range is not valid/, "bad-precedence 2");
like(shift @e, qr/gateway type expected/, "bad-gw-type 1");
like(shift @e, qr/gateway type is not valid/, "bad-gw-type 2");
like(shift @e, qr/algorithm expected/, "bad-algo 1");
like(shift @e, qr/algorithm is not valid/, "bad-algo 2");
like(shift @e, qr/gateway must be "\." for gateway type 0/, "gw-not-dot");
like(shift @e, qr/cannot parse gateway\/IPv4/, "bad-ip4 1");
like(shift @e, qr/gateway\/IPv4 is not valid/, "bad-ip4 2");
like(shift @e, qr/gateway\/IPv4 is not valid/, "bad-ip4 3");
like(shift @e, qr/cannot parse gateway\/IPv6/, "bad-ip6 1");
like(shift @e, qr/gateway\/IPv6 is not valid/, "bad-ip6 2");
like(shift @e, qr/cannot parse gateway\/IPv6/, "bad-ip6 3");
like(shift @e, qr/cannot parse gateway\/IPv6/, "bad-ip6 4");
like(shift @e, qr/garbage after valid IPSECKEY data/, "garbage-key");

# Verify that "." is 00 and not 00 00
run('./validns', @threads, '-t1361306089', $srcdir . '/t/issues/dot-is-single-zero/example.sec.signed');
is(rc, 0, 'dot is zero, all is good');

# Check rare RRs
SKIP: {
skip "SHA-1 disabled by system crypto policy", 1 if $sha1_disabled;
run('./validns', @threads, '-t1365591600', $srcdir . '/t/issues/lots-of-rare-rrs/all.rr.org');
is(rc, 0, 'rare RRs are parsed correctly, all is good');
}

# Stuff containing '/' in various places (issue #29)
run('./validns', @threads, $srcdir . '/t/issues/29-slash/example.com');
isnt(rc, 0, 'zone with slashes returns an error');
@e = split /\n/, stderr;

like(shift @e, qr/host name contains '\/'/, "slash-A");
like(shift @e, qr/host name contains '\/'/, "slash-MX");
like(shift @e, qr/host name contains '\/'/, "slash-AAAA");
like(shift @e, qr/NS data contains '\/'/, "NS-slash");

# Codeberg #34: zone parsing edge cases.
run('./validns', @threads,
    $srcdir . '/t/issues/34-zone-parsing-edge-cases/good.zone');
is(rc, 0, 'Codeberg #34: edge-case zone parses cleanly');

run('./validns', @threads,
    $srcdir . '/t/issues/34-zone-parsing-edge-cases/bad-ptr.zone');
isnt(rc, 0, 'Codeberg #34: empty PTR target is rejected');
like(stderr, qr/PTR domain name expected/,
    'Codeberg #34: PTR error message is PTR-specific, not "name server"');

run('./validns', @threads,
    $srcdir . '/t/issues/34-zone-parsing-edge-cases/bad-quoted.zone');
isnt(rc, 0, 'Codeberg #34: quoted name without closing quote is rejected');
like(stderr, qr/closing quote not found/,
    'Codeberg #34: missing closing quote produces a specific diagnostic');

# Codeberg #34: signed zone where the edge-case labels round-trip through
# BIND's dnssec-signzone. If wire-format encoding for any of these names
# is wrong, the RSASHA256 RRSIGs will not verify.
run('./validns', @threads, '-t1778025600',  # 2026-05-06 in signature range
    $srcdir . '/t/issues/34-zone-parsing-edge-cases/edge-cases.zone.signed');
is(rc, 0, 'Codeberg #34: signed edge-case zone validates');
unlike(stderr, qr/is not valid|cannot assume previous name/,
    'Codeberg #34: signed zone parses without parser warnings');

# DS does not mean the zone is signed
run('./validns', @threads, $srcdir . '/t/issues/ds-does-not-mean-signed/example.com');
is(rc, 0, 'DS does not mean zone is signed');

# issue 32: support ECDSA and SHA-256 for SSHFP: https://github.com/tobez/validns/issues/32
run('./validns', @threads, '-t1378203490', $srcdir . '/t/issues/32-sshfp-ecdsa-sha-256/example.sec.signed');
is(rc, 0, 'issue 32: SSHFP supports ECDSA and SHA-256');

# issue 34: multiple time specifications
SKIP: {
skip "SHA-1 disabled by system crypto policy", 1 if $sha1_disabled;
run('./validns', @threads, ('-t1501789043') x 32, $srcdir . '/t/zones/example.sec.signed');
is(rc, 0, 'valid signed zone parses ok');
}

SKIP: {
skip "SHA-1 disabled by system crypto policy", 2 if $sha1_disabled;
run('./validns', @threads, ('-t1501789043') x 33, $srcdir . '/t/zones/example.sec.signed');
isnt(rc, 0, 'too many time specs');
@e = split /\n/, stderr;
like(shift @e, qr/too many -t/, "too many -t");
}

SKIP: {
skip "SHA-1 disabled by system crypto policy", 2 if $sha1_disabled;
run('./validns', @threads, '-t1501789043', '-t1303720010', $srcdir . '/t/zones/example.sec.signed');
isnt(rc, 0, 'multitime: valid signed zone with timestamps in the future');
@e = split /\n/, stderr;
like(shift @e, qr/signature is too new/, "multitime: signature is too new");
}

SKIP: {
skip "SHA-1 disabled by system crypto policy", 2 if $sha1_disabled;
run('./validns', @threads, '-t1501789043', '-t1561789043', $srcdir . '/t/zones/example.sec.signed');
isnt(rc, 0, 'multitime: valid signed zone with timestamps in the past');
@e = split /\n/, stderr;
like(shift @e, qr/signature is too old/, "multitime: signature is too old");
}

# issue 51: support curved algorithms
run('./validns', @threads, '-t1459259658', $srcdir . '/t/issues/51-support-curved-algorithms/13.example.com.signed');
is(rc, 0, 'issue 51: support ECDSAP256SHA256');

run('./validns', @threads, '-t1459259658', $srcdir . '/t/issues/51-support-curved-algorithms/14.example.com.signed');
is(rc, 0, 'issue 51: support ECDSAP384SHA384');

# issue 90: support ED25519 and ED448
run('./validns', @threads, '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/15.example.com.signed');
is(rc, 0, 'issue 90: support ED25519');

SKIP: {
skip "ED448 not available (build without Ed448 support, e.g. LibreSSL)", 1 if $libressl_no_ed448;
run('./validns', @threads, '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/16.example.com.signed');
is(rc, 0, 'issue 90: support ED448');
}

if ($libressl_no_ed448) {
	run('./validns', @threads, '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/16.example.com.signed');
	isnt(rc, 0, 'issue 90: ED448 unavailable produces non-zero exit');
	like(stderr, qr/ED448 signatures cannot be validated/, 'issue 90: ED448 unavailable error message');
}

run('./validns', @threads, '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/15.example.com.bad-key-length.signed');
isnt(rc, 0, 'issue 90: ED25519 bad key length rejected');
like(stderr, qr/ED25519 public key has wrong length/, 'issue 90: ED25519 bad key length error');

run('./validns', @threads, '-t1767225600', $srcdir . '/t/issues/90-ed25519-ed448-support/15.example.com.bad-sig-length.signed');
isnt(rc, 0, 'issue 90: ED25519 bad sig length rejected');
like(stderr, qr/ED25519 signature has wrong length/, 'issue 90: ED25519 bad sig length error');

# PERMIT_STARTING_HYPHEN test
run('./validns', @threads, $srcdir . '/t/issues/policy-permit-starting-hyphen/example.com');
isnt(rc, 0, 'starting hyphen in name fails');
@e = split /\n/, stderr;
like(shift @e, qr/record name expected/, "starting hyphen 1");
like(shift @e, qr/record name expected/, "starting hyphen 2");
is(+@e, 0, "no unaccounted errors for starting hyphen checks");

run('./validns', @threads, '-p', 'permit-starting-hyphen', $srcdir . '/t/issues/policy-permit-starting-hyphen/example.com');
is(rc, 0, 'starting hyphen parses OK with corresponding policy check');

# ZONEMD tests
run('./validns', @threads, $srcdir . '/t/zones/zonemd-valid.zone');
is(rc, 0, 'valid ZONEMD zone with SHA-384 parses ok');

run('./validns', @threads, $srcdir . '/t/zones/zonemd-sha512.zone');
is(rc, 0, 'valid ZONEMD zone with SHA-512 parses ok');

run('./validns', @threads, $srcdir . '/t/zones/zonemd-errors.zone');
isnt(rc, 0, 'ZONEMD errors zone returns errors');
@e = split /\n/, stderr;
like(shift @e, qr/unsupported scheme 2 \(only scheme 1 is defined\)/, "ZONEMD invalid scheme");
like(shift @e, qr/wrong digest length, expected 48 bytes for hash algorithm 1/, "ZONEMD wrong digest length");
like(shift @e, qr/unsupported hash algorithm 3 \(only 1=SHA-384 and 2=SHA-512 are defined\)/, "ZONEMD invalid hash algorithm");
is(+@e, 0, "no unaccounted errors for ZONEMD checks");

# Coredump test: malformed zone with DNSSEC records but no valid SOA should not crash
run('./validns', @threads, $srcdir . '/t/issues/coredump-null-zone-apex/crash.zone');
isnt(rc, 0, 'malformed zone with RRSIG returns an error');
ok(rc == 0 || rc > 127, 'malformed zone with RRSIG does not crash (no signal)');
@e = split /\n/, stderr;
like(shift @e, qr/ttl not specified/, "RRSIG: ttl not specified");
like(shift @e, qr/the first record in the zone must be an SOA record/, "RRSIG: first record not SOA");
is(+@e, 0, "no unaccounted errors for RRSIG coredump check");

run('./validns', @threads, $srcdir . '/t/issues/coredump-null-zone-apex/crash-nsec.zone');
isnt(rc, 0, 'malformed zone with NSEC returns an error');
ok(rc == 0 || rc > 127, 'malformed zone with NSEC does not crash (no signal)');
@e = split /\n/, stderr;
like(shift @e, qr/ttl not specified/, "NSEC: ttl not specified");
like(shift @e, qr/the first record in the zone must be an SOA record/, "NSEC: first record not SOA");
is(+@e, 0, "no unaccounted errors for NSEC coredump check");

run('./validns', @threads, $srcdir . '/t/issues/coredump-null-zone-apex/crash-nsec3.zone');
isnt(rc, 0, 'malformed zone with NSEC3 returns an error');
ok(rc == 0 || rc > 127, 'malformed zone with NSEC3 does not crash (no signal)');
@e = split /\n/, stderr;
like(shift @e, qr/ttl not specified/, "NSEC3: ttl not specified");
like(shift @e, qr/NSEC3 record name is not valid/, "NSEC3: record name not valid");
is(+@e, 0, "no unaccounted errors for NSEC3 coredump check");

# issue 104: unsupported algorithm cascade - NSEC variant
run('./validns', @threads, '-t1800000000', $srcdir . '/t/issues/104-unsupported-algorithm-cascade/example.sec.signed.nsec');
isnt(rc, 0, '#104 nsec: zone with unknown algorithm returns an error');
@e = split /\n/, stderr;
ok((grep { /delegation\.example\.sec\. DS record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec: DS message');
ok((grep { /example\.sec\. CDS record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec: CDS message');
ok((grep { /dlv-target\.example\.sec\. DLV record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec: DLV message');
ok(!(grep { /RRSIG exists for non-existing/ } @e),
    '#104 nsec: no orphan-RRSIG cascade');
ok(!(grep { /NSEC mentions/ } @e),
    '#104 nsec: no NSEC-mentions cascade');
is(+@e, 3, '#104 nsec: exactly 3 messages total');

# issue 104: unsupported algorithm cascade - NSEC3 opt-out variant
run('./validns', @threads, '-t1800000000', $srcdir . '/t/issues/104-unsupported-algorithm-cascade/example.sec.signed.nsec3');
isnt(rc, 0, '#104 nsec3: zone with unknown algorithm returns an error');
@e = split /\n/, stderr;
ok((grep { /delegation\.example\.sec\. DS record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec3: DS message');
ok((grep { /example\.sec\. CDS record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec3: CDS message');
ok((grep { /dlv-target\.example\.sec\. DLV record refers to a DNSKEY with an unsupported algorithm 99/ } @e),
    '#104 nsec3: DLV message');
ok(!(grep { /RRSIG exists for non-existing/ } @e),
    '#104 nsec3: no orphan-RRSIG cascade');
ok(!(grep { /NSEC3 without a corresponding/ } @e),
    '#104 nsec3: no NSEC3-without-corresponding cascade');
is(+@e, 3, '#104 nsec3: exactly 3 messages total');

# issue 104: unsupported algorithm cascade - policy-disabled (RHEL9/CentOS9) variant
SKIP: {
skip "SHA-1 enabled - cannot test policy-disabled cascade", 6 unless $sha1_disabled;
run('./validns', @threads, '-t1800000000', $srcdir . '/t/issues/104-unsupported-algorithm-cascade/example.sec.signed.policy');
isnt(rc, 0, '#104 policy: zone with policy-disabled algorithm returns an error');
@e = split /\n/, stderr;
ok((grep { /signatures cannot be validated.*disabled by system crypto policy/ } @e),
    '#104 policy: policy-disabled message');
ok(!(grep { /record refers to a DNSKEY with an unsupported algorithm/ } @e),
    '#104 policy: no unsupported-algorithm cascade');
ok(!(grep { /RRSIG exists for non-existing/ } @e),
    '#104 policy: no orphan-RRSIG cascade');
ok(!(grep { /NSEC mentions/ } @e),
    '#104 policy: no NSEC-mentions cascade');
is(+@e, 1, '#104 policy: exactly 1 message total');
}

# issue 16: signed-zone completeness - fully signed fixtures are silent
run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec');
is(rc, 0, '#16 nsec: fully signed zone exits 0');
@e = split /\n/, stderr;
is(+@e, 0, '#16 nsec: fully signed zone produces no errors');

run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec3');
is(rc, 0, '#16 nsec3: fully signed zone exits 0');
@e = split /\n/, stderr;
is(+@e, 0, '#16 nsec3: fully signed zone produces no errors');

run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec3optout');
is(rc, 0, '#16 nsec3 opt-out: fully signed zone exits 0');
@e = split /\n/, stderr;
is(+@e, 0, '#16 nsec3 opt-out: fully signed zone produces no errors');

# issue 16: one missing signature must produce exactly one error
run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec.noleafsig');
isnt(rc, 0, '#16: missing leaf A signature returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/leaf\.example\.sec\. A: no covering RRSIG in a signed zone/,
    '#16: missing leaf A signature message');
is(+@e, 0, '#16: no unaccounted errors for missing leaf A signature');

run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec.nodssig');
isnt(rc, 0, '#16: missing DS signature at a delegation returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/signed-del\.example\.sec\. DS: no covering RRSIG in a signed zone/,
    '#16: missing DS signature message');
is(+@e, 0, '#16: no unaccounted errors for missing DS signature');

run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec3.nonsec3sig');
isnt(rc, 0, '#16: missing NSEC3 signature returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/\.example\.sec\. NSEC3: no covering RRSIG in a signed zone/,
    '#16: missing NSEC3 signature message');
is(+@e, 0, '#16: no unaccounted errors for missing NSEC3 signature');

# issue 16: all-algorithms-signed policy check
run('./validns', @threads, '-t1802692800', '-p', 'all-algorithms-signed',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec');
is(rc, 0, '#16 policy: single-algorithm zone is algorithm-complete');
@e = split /\n/, stderr;
is(+@e, 0, '#16 policy: no errors for single-algorithm zone');

run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/alg2.example.sec.signed');
is(rc, 0, '#16 alg2: two-algorithm zone exits 0');
@e = split /\n/, stderr;
is(+@e, 0, '#16 alg2: no errors for two-algorithm zone');

run('./validns', @threads, '-t1802692800', '-p', 'all-algorithms-signed',
    $srcdir . '/t/issues/16-signed-completeness/alg2.example.sec.signed');
is(rc, 0, '#16 policy: complete two-algorithm zone passes the policy check');
@e = split /\n/, stderr;
is(+@e, 0, '#16 policy: no errors for complete two-algorithm zone');

# an algorithm gap is signer hygiene, not bogus data: silent by default
run('./validns', @threads, '-t1802692800',
    $srcdir . '/t/issues/16-signed-completeness/alg2.example.sec.signed.missing-alg');
is(rc, 0, '#16: algorithm gap is silent without the policy check');
@e = split /\n/, stderr;
is(+@e, 0, '#16: no errors for algorithm gap without the policy check');

run('./validns', @threads, '-t1802692800', '-p', 'all-algorithms-signed',
    $srcdir . '/t/issues/16-signed-completeness/alg2.example.sec.signed.missing-alg');
isnt(rc, 0, '#16 policy: algorithm gap returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/leaf\.alg2\.example\.sec\. A: no covering RRSIG with algorithm 13, which the DNSKEY RR set contains/,
    '#16 policy: algorithm gap message');
is(+@e, 0, '#16 policy: no unaccounted errors for algorithm gap');

run('./validns', @threads, '-t1802692800', '-p', 'all',
    $srcdir . '/t/issues/16-signed-completeness/alg2.example.sec.signed.missing-alg');
isnt(rc, 0, '#16 policy: -p all includes all-algorithms-signed');
@e = split /\n/, stderr;
like(shift @e, qr/leaf\.alg2\.example\.sec\. A: no covering RRSIG with algorithm 13, which the DNSKEY RR set contains/,
    '#16 policy: -p all algorithm gap message');
is(+@e, 0, '#16 policy: no unaccounted errors under -p all');

# issue 24: occluded records policy check
run('./validns', @threads, $srcdir . '/t/issues/24-occluded-records/occluded.zone');
is(rc, 0, '#24: occluded records are silent without the policy check');
@e = split /\n/, stderr;
is(+@e, 0, '#24: no errors without the policy check');

run('./validns', @threads, '-p', 'occluded-records',
    $srcdir . '/t/issues/24-occluded-records/occluded.zone');
isnt(rc, 0, '#24: occluded-records policy returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/ns2\.del1\.example\.com\. A: glue not referenced by its delegation del1\.example\.com\./,
    '#24: sibling glue flagged under the strict default');
like(shift @e, qr/deep\.del2\.example\.com\. NS: occluded by delegation del2\.example\.com\./,
    '#24: occluded NS RR set');
like(shift @e, qr/ns\.deep\.del2\.example\.com\. A: occluded by delegation del2\.example\.com\./,
    '#24: occluded owners cannot launder glue');
like(shift @e, qr/junk\.del2\.example\.com\. A: occluded by delegation del2\.example\.com\./,
    '#24: orphan occluded address record');
like(shift @e, qr/note\.del2\.example\.com\. TXT: occluded by delegation del2\.example\.com\./,
    '#24: occluded non-address record');
is(+@e, 0, '#24: no unaccounted errors under occluded-records');

run('./validns', @threads, '-p', 'occluded-records', '-p', 'no-strict-glue',
    $srcdir . '/t/issues/24-occluded-records/occluded.zone');
isnt(rc, 0, '#24: no-strict-glue still reports true occlusions');
@e = split /\n/, stderr;
like(shift @e, qr/deep\.del2\.example\.com\. NS: occluded by delegation/,
    '#24: no-strict-glue keeps the occluded NS RR set');
like(shift @e, qr/ns\.deep\.del2\.example\.com\. A: occluded by delegation/,
    '#24: no-strict-glue does not launder via occluded NS sets');
like(shift @e, qr/junk\.del2\.example\.com\. A: occluded by delegation/,
    '#24: no-strict-glue keeps the orphan address record');
like(shift @e, qr/note\.del2\.example\.com\. TXT: occluded by delegation/,
    '#24: no-strict-glue keeps the non-address record');
is(+@e, 0, '#24: no-strict-glue exempts sibling glue and nothing else');

run('./validns', @threads, '-p', 'all',
    $srcdir . '/t/issues/24-occluded-records/occluded.zone');
isnt(rc, 0, '#24: -p all includes occluded-records');
@e = split /\n/, stderr;
is(+@e, 5, '#24: -p all reports the five strict occlusion errors');

# issue 13: missing glue policy check
run('./validns', @threads, $srcdir . '/t/issues/13-missing-glue/missing-glue.zone');
is(rc, 0, '#13: missing glue is silent without the policy check');
@e = split /\n/, stderr;
is(+@e, 0, '#13: no errors without the policy check');

run('./validns', @threads, '-p', 'missing-glue',
    $srcdir . '/t/issues/13-missing-glue/missing-glue.zone');
isnt(rc, 0, '#13: missing-glue policy returns an error');
@e = split /\n/, stderr;
like(shift @e, qr/del1\.example\.com\.: missing glue for nameserver ns1\.del1\.example\.com\./,
    '#13: in-bailiwick nameserver without address records');
is(+@e, 0, '#13: no unaccounted errors under missing-glue');

run('./validns', @threads, '-p', 'all',
    $srcdir . '/t/issues/13-missing-glue/missing-glue.zone');
isnt(rc, 0, '#13: -p all includes missing-glue');
@e = split /\n/, stderr;
like(shift @e, qr/missing glue for nameserver/, '#13: -p all missing-glue message');
is(+@e, 0, '#13: no unaccounted errors under -p all');

# issue 24: NSEC3 opt-out insecure delegations are cuts, not occluded names
run('./validns', @threads, '-t', '1802692800', '-p', 'occluded-records',
    $srcdir . '/t/issues/16-signed-completeness/example.sec.signed.nsec3optout');
isnt(rc, 0, '#24: occluded-records reports the occluded record under NSEC3 opt-out');
@e = split /\n/, stderr;
like(shift @e, qr/occluded\.signed-del\.example\.sec\. TXT: occluded by delegation signed-del\.example\.sec\./,
    '#24: opt-out insecure delegation is a cut, its occluded child is reported');
is(+@e, 0, '#24: opt-out insecure delegation nodes themselves are not reported');

# *_human() round-trip program, stage 1: escaping fixture.
# Signed zone whose HINFO/TXT/SPF/WALLET/ISDN/X25/NAPTR records carry
# characters needing RFC 1035 escaping (quotes, backslashes, \DDD bytes,
# incl. a \DDD escape immediately followed by a literal digit). The
# RRSIGs are the canary: any wire-format regression breaks verification.
run('./validns', @threads, '-t1800057600',
    $srcdir . '/t/roundtrip/escaping/example.sec.signed');
is(rc, 0, 'roundtrip/escaping: signed fixture parses and verifies');

# HINFO -v rendering: exact-render the escaping fixture's HINFO record,
# pinning every escape (embedded quote, backslash, low \001 and high
# \200 non-printable bytes).
run('./validns', @threads, '-v', '-t1800057600',
    $srcdir . '/t/roundtrip/escaping/example.sec.signed');
is(rc, 0, 'roundtrip/escaping: -v run parses and verifies');
my $hinfo_expected = q{example.sec. IN 300 HINFO "CPU-\"quoted\"-\\\\-\001-tail" "OS-\200-high"};
like(stderr, qr/\Q$hinfo_expected\E/,
    'roundtrip/escaping: HINFO renders full escaped presentation form');

# TXT/SPF/WALLET -v rendering: exact-render the escaping fixture's
# multi-segment TXT (greedy-digit-trap \009 escape, trailing spaces
# inside quotes, empty segment), the SPF record, its conventional TXT
# twin, and the WALLET record. Each -v line is prefixed with
# "-> file:line: ", so only the end is anchored with $/m; a lingering
# trailing space (the pre-fix behaviour) still fails the match.
my $txt_nasty_expected = q{example.sec. IN 300 TXT "plain-segment" "\0092greedy" "trailing space   " "" "nul\000inside"};
like(stderr, qr/\Q$txt_nasty_expected\E$/m,
    'roundtrip/escaping: multi-segment TXT renders full escaped presentation form');
my $txt_twin_expected = q{example.sec. IN 300 TXT "v=spf1 a:mail.example.sec " "-all"};
like(stderr, qr/\Q$txt_twin_expected\E$/m,
    'roundtrip/escaping: SPF-twin TXT renders full presentation form');
my $spf_expected = q{example.sec. IN 300 SPF "v=spf1 a:mail.example.sec " "-all"};
like(stderr, qr/\Q$spf_expected\E$/m,
    'roundtrip/escaping: SPF renders full presentation form');
my $wallet_expected = q{example.sec. IN 300 WALLET "BTC" "1Ht\"quote\\\\slash\0117"};
like(stderr, qr/\Q$wallet_expected\E$/m,
    'roundtrip/escaping: WALLET renders full escaped presentation form');

# ISDN/X25 -v rendering: exact-render the escaping fixture's ISDN
# record with a subaddress (two quoted strings), the ISDN record
# without one (exactly one quoted string; end-anchoring proves no
# stray subaddress is appended), and the X25 record (single quoted
# string).
my $isdn2_expected = q{isdn2.example.sec. IN 300 ISDN "150862028003217" "004"};
like(stderr, qr/\Q$isdn2_expected\E$/m,
    'roundtrip/escaping: ISDN with subaddress renders full presentation form');
my $isdn1_expected = q{isdn1.example.sec. IN 300 ISDN "150862028003217"};
like(stderr, qr/\Q$isdn1_expected\E$/m,
    'roundtrip/escaping: ISDN without subaddress renders full presentation form');
my $x25_expected = q{example.sec. IN 300 X25 "311061700956"};
like(stderr, qr/\Q$x25_expected\E$/m,
    'roundtrip/escaping: X25 renders full presentation form');

# NAPTR -v rendering: exact-render the escaping fixture's NAPTR record
# with an empty regexp and "." replacement (proves empty character-strings
# render as "" and no fields are elided), and the NAPTR record with
# non-trivial services and a backslash-containing regexp (proves
# backslashes are escaped as \\ and the replacement fqdn is rendered as-is).
my $naptr1_expected = q{example.sec. IN 300 NAPTR 100 10 "" "" "" .};
like(stderr, qr/\Q$naptr1_expected\E$/m,
    'roundtrip/escaping: NAPTR with empty regexp renders full presentation form');
my $naptr2_expected = q{example.sec. IN 300 NAPTR 102 10 "u" "E2U+sip" "!^\\\\+46(.*)$!sip:\\\\1@example.sec!" sip.example.sec.};
like(stderr, qr/\Q$naptr2_expected\E$/m,
    'roundtrip/escaping: NAPTR with backslash-containing regexp renders full presentation form');

# *_human() round-trip program, stage 1: wire-identity proof via the
# roundtrip_splice sub (defined below) -- re-render every escape-sensitive
# type with -v, splice the rendered records back into the signed zone,
# and require the RRSIGs to still verify.
roundtrip_splice(
    zone    => $srcdir . '/t/roundtrip/escaping/example.sec.signed',
    types   => [qw(HINFO TXT SPF WALLET ISDN X25 NAPTR)],
    t       => 1800057600,
    threads => \@threads,
    tag     => 'roundtrip/escaping splice',
);

# *_human() round-trip program, stage 2: base64-consumers cluster.
roundtrip_splice(
    zone    => $srcdir . '/t/issues/base64-human/example.sec.signed',
    types   => [qw(DNSKEY CERT DHCID IPSECKEY)],
    t       => 1800057600,
    threads => \@threads,
    tag     => 'roundtrip/base64 cluster',
);

# CERT -v render: cert data must be full base64 (IPGP=6, no "...").
run('./validns', @threads, '-v', '-t1800057600',
    $srcdir . '/t/issues/base64-human/example.sec.signed');
like(stderr, qr/cert\.example\.sec\. IN \d+ CERT 6 0 0 FFsAyW1dVK7hIGuvhN56r26UwJx\/(\s|$)/m,
    'roundtrip/base64: CERT renders full base64');

# DHCID -v render: whole-rdata base64 (RFC 4701), not "...".
run('./validns', @threads, '-v', '-t1800057600',
    $srcdir . '/t/issues/base64-human/example.sec.signed');
like(stderr, qr/dhcid\.example\.sec\. IN \d+ DHCID AAIBY2\/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=(\s|$)/m,
    'roundtrip/base64: DHCID renders whole-rdata base64');

# IPSECKEY -v render: public key as base64, no parens, no "...".
run('./validns', @threads, '-v', '-t1800057600',
    $srcdir . '/t/issues/base64-human/example.sec.signed');
like(stderr, qr/ipseckey\.example\.sec\. IN \d+ IPSECKEY 10 1 2 192\.0\.2\.38 AQNRU3mG7TVTO2BkR47usntb102uFJtugbo6BSGvgqt4AQ==(\s|$)/m,
    'roundtrip/base64: IPSECKEY renders full base64 key');

# IPSECKEY algorithm 0: no key field, gateway type 0 renders "." (RFC 4025).
# Uses an unsigned zone because BIND dnssec-signzone cannot sign an
# algorithm-0 IPSECKEY (it requires >=1 key octet unconditionally).
run('./validns', @threads, '-v',
    $srcdir . '/t/issues/base64-human/ipseckey-alg0.zone');
is(rc, 0, 'IPSECKEY algorithm 0 verbose parse OK');
like(stderr, qr/ipseckey0\.example\.sec\. IN \d+ IPSECKEY 10 0 0 \.$/m,
    'roundtrip/base64: IPSECKEY algorithm 0 renders no key');

# IPSECKEY gateway-name overflow regression: a >1024-byte type-3
# gateway name must render in full, not overflow a fixed stack buffer.
run('./validns', @threads, '-v', $srcdir . '/t/zones/ipseckey-overflow.zone');
like(stderr, qr/IPSECKEY 10 3 0 (?:a{60}\.){18}a{60}\.example\.com\.(\s|$)/m,
    'IPSECKEY long gateway renders in full');

# extract_text(): the \DDD escape-decoding path must not bypass the
# 65534-byte bound that the literal/\X path enforces. A quoted string is
# built from a run of plain characters (safely under the bound) followed
# by a run of \068 escapes long enough to push the decoded length past
# 65534 bytes; the escape run is aligned to start exactly on a fresh
# 2048-byte read_zone_line() chunk (LINEBUFSZ in src/common.h) so no
# individual \DDD escape straddles a chunk boundary and gets rejected as
# a malformed escape before the bound is ever reached.
{
	my $prefix = '@ IN TXT "';
	my $chunk = 2047; # LINEBUFSZ - 1
	my $padding = 'A' x ($chunk * 32 - length($prefix));
	my $escapes = '\068' x 100;
	my $tmp = write_temp_zone(
		"\$ORIGIN example.com.\n\$TTL 300\n",
		"\@ IN SOA ns.example.com. admin.example.com. ( 1 3600 600 86400 300 )\n",
		"  IN NS ns.example.com.\n",
		$prefix, $padding, $escapes, "\"\n",
	);

	run('./validns', @threads, $tmp);
	isnt(rc, 0, 'extract_text: \DDD escape run past 65534 bytes is rejected');
	like(stderr, qr/string too long/, 'extract_text: \DDD overflow reports string too long');
}

# *_human() round-trip program, stage 5: small presentation-form fixes.
# Per-renderer -v assertions over an unsigned fixture, plus a signed
# roundtrip_splice capstone (added in the final task).
my $s5 = $srcdir . '/t/roundtrip/stage5/render.zone';

run('./validns', @threads, '-v', $s5);
is(rc, 0, 'stage5: fixture parses');
like(stderr, qr/\bn\.example\.com\. IN 3600 NID 10 0014:4fff:ff20:ee64\b/m,
    'stage5: NID renders full 64-bit node id, 4-digit groups');
like(stderr, qr/\bl\.example\.com\. IN 3600 L64 10 2001:0db8:1140:1000\b/m,
    'stage5: L64 renders full 64-bit locator, 4-digit groups');
like(stderr, qr/\bfoo\.example\.com\. IN 3600 NSAP 0x47000580005a0000000001e133ffffff00016100\b/m,
    'stage5: NSAP renders 0x-prefixed hex');
like(stderr, qr{\b_443\._tcp\.example\.com\. IN 3600 TLSA 3 1 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971\b}m,
    'stage5: TLSA renders full association data');
like(stderr, qr{\bmail\.example\.com\. IN 3600 SMIMEA 3 0 0 92003ba34942dc74152e2f2c408d29eca5a520e7f2e06bb944f4dca346baf63c1b177615d466f6c4b71c216a50292bd58c9ebdd2f74e38fe51ffd48c43326cbc\b}m,
    'stage5: SMIMEA renders full association data');
like(stderr, qr{\bexample\.com\. IN 3600 ZONEMD 1 1 1 c68090d90a7aed716bc459f9340e3d7c1370d4d24b7e2fc3a1ddc0b9a87153b9a9713b3c9ae5cc27777f98b8e730044c\b}m,
    'stage5: ZONEMD renders full digest via bounded loop');
like(stderr, qr/\bbob\.example\.com\. IN 3600 RP bob\.example\.com\. bob-info\.example\.com\.(\s|$)/m,
    'stage5: RP renders two bare domain names, no quotes');

# NID/L64 64-bit wire-encoding regression (compose_binary_data '8' case).
# Before the textparse.c fix, validns computed truncated wire rdata for NID/L64
# and could not verify their RRSIGs; this pins the fix.
run('./validns', @threads, '-s', '-t1800057600',
    $srcdir . '/t/roundtrip/stage5/example.sec.signed');
is(rc, 0, 'roundtrip/stage5: signed fixture fully verifies (NID/L64 64-bit wire)');
unlike(stderr, qr/RRSIG\((?:NID|L64)\)/,
    'roundtrip/stage5: NID and L64 RRSIGs verify (no 64-bit wire truncation)');

# *_human() round-trip program, stage 5 capstone: re-render every type with
# -v, splice the rendered records back into the signed zone, and require the
# RRSIGs to still verify -- proving wire-identity of the rendering.
roundtrip_splice(
    zone    => $srcdir . '/t/roundtrip/stage5/example.sec.signed',
    types   => [qw(NSAP TLSA SMIMEA RP NID L64 ZONEMD)],
    t       => 1800057600,
    threads => \@threads,
    tag     => 'roundtrip/stage5 splice',
);

# *_human() round-trip program, stage 6: the -L flag enumerates every
# renderable type (rr_methods[i].rr_human != any_human) -- the zero-drift
# source of truth the completeness gate below compares against.
run('./validns', '-L');
is(rc, 0, 'stage6: -L exits successfully');
like(stdout, qr/^SOA$/m,    'stage6: -L lists SOA');
like(stdout, qr/^ZONEMD$/m, 'stage6: -L lists ZONEMD');
like(stdout, qr/^HTTPS$/m,  'stage6: -L lists HTTPS (distinct from SVCB)');
like(stdout, qr/^SVCB$/m,   'stage6: -L lists SVCB');
unlike(stdout, qr/^TYPE\d+$/m, 'stage6: -L lists no unknown TYPEnnn names');

# stage6: NSEC3-signed fixture -- covers NSEC3 and NSEC3PARAM, which
# cannot coexist with NSEC in the "everything" zone.
my $s6nsec3 = $srcdir . '/t/roundtrip/stage6/nsec3/example.sec.signed';
run('./validns', @threads, '-s', '-t1800057600', $s6nsec3);
is(rc, 0, 'stage6/nsec3: signed fixture fully verifies');
run('./validns', @threads, '-v', '-t1800057600', $s6nsec3);
like(stderr, qr/ IN \d+ NSEC3PARAM /,
    'stage6/nsec3: fixture renders an NSEC3PARAM record');
like(stderr, qr/ IN \d+ NSEC3 /,
    'stage6/nsec3: fixture renders an NSEC3 record');

# stage6: "everything" NSEC-signed fixture -- one record of every
# renderable type except NSEC3/NSEC3PARAM (see nsec3/ fixture).
my $s6all = $srcdir . '/t/roundtrip/stage6/everything/example.sec.signed';
run('./validns', @threads, '-s', '-t1800057600', $s6all);
is(rc, 0, 'stage6/everything: signed fixture fully verifies');
unlike(stderr, qr/RRSIG\(L32\)/,
    'stage6/everything: L32 RRSIG verifies (little-endian wire)');
run('./validns', @threads, '-v', '-t1800057600', $s6all);
like(stderr, qr/ IN \d+ L32 10 10\.1\.2\.0(\s|$)/m,
    'stage6/everything: L32 renders correct dotted quad');

# *_human() round-trip program, stage 6: generic harness + completeness gate.
# expected = every renderable type (validns -L). Each stage6 fixture is
# spliced through roundtrip_splice (wire-identity), and the union of
# types exercised across fixtures must cover expected minus the (empty)
# exception set -- so a new renderer without a fixture record fails here.
run('./validns', '-L');
is(rc, 0, 'stage6: -L exits 0 for the completeness gate');
my %expected = map { $_ => 1 } grep { /\S/ } split /\n/, stdout;
cmp_ok(scalar(keys %expected), '>', 40, 'stage6: -L lists the renderable types');

my %exercised;
for my $fx (
    { zone => $srcdir . '/t/roundtrip/stage6/everything/example.sec.signed',
      tag  => 'roundtrip/stage6 everything' },
    { zone => $srcdir . '/t/roundtrip/stage6/nsec3/example.sec.signed',
      tag  => 'roundtrip/stage6 nsec3' },
) {
    my $types = roundtrip_splice(
        zone         => $fx->{zone},
        types        => [sort keys %expected],
        require_each => 0,
        t            => 1800057600,
        threads      => \@threads,
        tag          => $fx->{tag},
    );
    $exercised{$_} = 1 for @$types;
}

# Exception set: renderable types that cannot be a signed, splice-able
# fixture record. Each entry MUST name the concrete build-time reason.
# The design intent is that this set stays empty.
my %exception = (
    # TYPE => 'reason dnssec-signzone cannot produce a signed record',
);

for my $type (sort keys %expected) {
    next if $exception{$type};
    ok($exercised{$type},
        "stage6 completeness: $type is exercised by a stage6 fixture");
}

}

# Round-trip fixed point: re-render records of the given types with -v,
# splice them into the zone in place of the originals, and require every
# signature to still verify -- proving wire-identity of the rendering.
# Signatures cover wire format, so a spliced zone that still validates
# proves the -v rendering is byte-identical to the original wire form.
sub roundtrip_splice {
my (%a) = @_; # zone => path, types => [qw(HINFO TXT ...)],
              # t => epoch, threads => \@threads, tag => 'name',
              # require_each => 1 (default; 0 = coverage, a subset is OK)
my @types = @{$a{types}};
my $require_each = exists $a{require_each} ? $a{require_each} : 1;
my $type_alt = join '|', map { quotemeta } @types;
my @threads = @{$a{threads}};

run('./validns', @threads, '-s', "-t$a{t}", $a{zone});
is(rc, 0, "$a{tag}: baseline zone verifies");
my $baseline_stdout = stdout;
my ($baseline) = $baseline_stdout =~ /^signatures verified: (\d+)$/m;

run('./validns', @threads, '-v', "-t$a{t}", $a{zone});
is(rc, 0, "$a{tag}: -v run parses and verifies");
my $rendered_stderr = stderr;
my @rendered;
my %seen;
while ($rendered_stderr =~ /^-> [^:]+:\d+: (\S+) IN (\d+) ($type_alt) (.+)$/mg) {
    push @rendered, "$1 $2 IN $3 $4";
    $seen{$3} = 1;
}
cmp_ok(scalar(@rendered), '>', 0, "$a{tag}: rendered at least one record");
if ($require_each) {
    for my $type (@types) {
        ok($seen{$type}, "$a{tag}: fixture contains at least one $type record");
    }
}

open(my $zone_fh, '<', $a{zone}) or die "cannot open $a{zone}: $!";
my @lines = <$zone_fh>;
close $zone_fh;
my $drop_re = qr/^\S+\s+\d+\s+IN\s+(?:$type_alt)\s/;
my @dropped = grep { /$drop_re/ } @lines;
is(scalar(@dropped), scalar(@rendered),
    "$a{tag}: dropped exactly as many original lines as rendered records");
my @kept = grep { !/$drop_re/ } @lines;

my $tmp = write_temp_zone(@kept, (map { "$_\n" } @rendered));

run('./validns', @threads, '-s', "-t$a{t}", $tmp);
is(rc, 0, "$a{tag}: spliced zone verifies");
like(stdout, qr/^signatures verified: \Q$baseline\E$/m,
    "$a{tag}: spliced zone verifies the same signature count");

return [ sort keys %seen ];
}

done_testing;
