#! /usr/bin/perl
use 5.006;
use strict;
use warnings;

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

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

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

# 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');

# 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 = "/tmp/caa-roundtrip-rebuilt-$$.zone";
open my $fh, '>', $tmpzone or die "cannot write $tmpzone: $!";
print $fh "\$ORIGIN example.\n\$TTL 300\n";
print $fh "\@ IN SOA ns.example. admin.example. ( 1 3600 600 86400 300 )\n";
print $fh "  IN NS ns.example.\n";
print $fh "ns IN A 192.0.2.1\n";
print $fh "\@ IN $_\n" for @caa_lines;
close $fh;

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

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/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");

## 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", 20 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/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');

# 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');

# 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 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');
}

}

done_testing;
