From ce56590a98d20e2abec8f63955f3707e2886045f Mon Sep 17 00:00:00 2001 From: "Lukasz D. Tulikowski" Date: Thu, 22 Oct 2026 09:12:09 +0200 Subject: [PATCH 1/5] Add CZDS structural failure classification: scripts/build_nsdb.py --- scripts/build_nsdb.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/scripts/build_nsdb.py b/scripts/build_nsdb.py index af19da8..d85d28b 100644 --- a/scripts/build_nsdb.py +++ b/scripts/build_nsdb.py @@ -10,7 +10,31 @@ # These fixed codes may appear in public CI logs; exception messages may not. -_PUBLIC_VALIDATION_CODES = frozenset(["dns_name_non_ascii","dns_escape_dangling","dns_escape_decimal_range","dns_label_empty","dns_label_too_long","dns_name_too_long","dns_label_non_ascii","ns_target_empty","ns_rdata_extra","ns_target_escape_dangling","ns_target_root","record_blank","record_fields","header_record_fields","apex_hint_missing","apex_hint_unavailable","after_final_soa","soa_owner_class","soa_serial_invalid","soa_serial_mismatch","soa_excess","ns_owner_outside_apex","ns_owner_order","zone_empty","soa_missing","soa_final_missing","archive_empty","archive_member_unreadable"]) +_PUBLIC_VALIDATION_CODES = frozenset(["dns_name_non_ascii","dns_escape_dangling","dns_escape_decimal_range","dns_label_empty","dns_label_too_long","dns_name_too_long","dns_label_non_ascii","ns_target_empty","ns_rdata_extra","ns_target_escape_dangling","ns_target_root","record_blank","record_fields","header_record_fields","apex_hint_missing","apex_hint_unavailable","after_final_soa","soa_owner_class","soa_serial_invalid","soa_serial_mismatch","soa_excess","ns_owner_outside_apex","ns_owner_order","zone_empty","soa_missing","soa_final_missing","archive_empty","archive_member_unreadable","record_fields_comment","record_fields_directive","record_fields_space_delimited","record_fields_missing_tabs","record_fields_partial_tabs"]) + + +def classify_record_fields(line: str) -> str: + """Fixed structural diagnostic only; never expose private record data. + + This is deliberately not a record parser. Every malformed record is + still rejected; these heuristics identify the probable input shape. + """ + if not line.strip(): + return "record_blank" + stripped = line.lstrip() + if stripped.startswith((";", "#")): + return "record_fields_comment" + if stripped.startswith("$"): + return "record_fields_directive" + tabs = line.count("\t") + if tabs == 0: + if any(char in line for char in " \v\f"): + return "record_fields_space_delimited" + return "record_fields_missing_tabs" + if any(char in line for char in " \v\f"): + return "record_fields_mixed_delimiters" + return "record_fields_partial_tabs" + class ZoneValidationError(ValueError): @@ -318,7 +342,7 @@ def flush_owner(): raise ZoneValidationError("record_blank", f"{label}: blank line violates CZDS zone format") fields = line.split("\t", 4) if len(fields) != 5: - raise ZoneValidationError("record_fields", f"{label}: expected five tab-separated fields at record {records + 1}") + raise ZoneValidationError(classify_record_fields(line), f"{label}: expected five tab-separated fields at record {records + 1}") owner_raw, ttl, rrclass, rrtype, rdata = fields owner = normalize_name(owner_raw) rrclass = rrclass.lower() From 91b8fb5ddba2281dabce67a54167174645edc702 Mon Sep 17 00:00:00 2001 From: "Lukasz D. Tulikowski" Date: Thu, 22 Oct 2026 09:12:13 +0200 Subject: [PATCH 2/5] Add CZDS structural failure classification: scripts/preflight_czds.py --- scripts/preflight_czds.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/preflight_czds.py b/scripts/preflight_czds.py index 1067c4c..6704e24 100644 --- a/scripts/preflight_czds.py +++ b/scripts/preflight_czds.py @@ -11,6 +11,7 @@ from build_nsdb import ( ZoneValidationError, + classify_record_fields, public_validation_code, infer_apex_from_filename, load_special_zones, @@ -35,7 +36,7 @@ def has_nonleading_soa(path: Path) -> bool: first = source.readline().rstrip("\r\n") fields = first.split("\t", 4) if len(fields) != 5: - raise ZoneValidationError("header_record_fields", "first zone record does not have five tab-separated fields") + raise ZoneValidationError(classify_record_fields(first), "first zone record does not have five tab-separated fields") return fields[3].lower() != "soa" or fields[2].lower() != "in" From 2672f4c23b287bbf691cbf09b1ff33b9990d9aaa Mon Sep 17 00:00:00 2001 From: "Lukasz D. Tulikowski" Date: Thu, 22 Oct 2026 09:12:16 +0200 Subject: [PATCH 3/5] Add CZDS structural failure classification: tests/test_preflight.py --- tests/test_preflight.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index e04c342..ff1ba77 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -114,7 +114,18 @@ def test_failure_reasons_are_safe_and_match_full_aggregation(self): soa = "gov.\t3600\tIN\tSOA\tns.gov. hostmaster.gov. 123 2 3 4 5" cases = { "record_blank": [initial, "", soa], - "record_fields": [initial, "private-name.gov. malformed", soa], + "record_fields_comment": [initial, " ; private-name.gov. hidden", soa], + "record_fields_directive": [initial, " $ORIGIN private-name.gov.", soa], + "record_fields_space_delimited": [ + initial, "private-name.gov. 3600 IN NS ns.hidden.example.", soa + ], + "record_fields_missing_tabs": [initial, "private-name.gov.", soa], + "record_fields_partial_tabs": [ + initial, "private-name.gov.\t3600\tIN\tNS", soa + ], + "record_fields_mixed_delimiters": [ + initial, "private-name.gov.\t3600 IN NS ns.hidden.example.", soa + ], "soa_missing": [initial, "private-name.gov.\t3600\tIN\tNS\tns.hidden.example."], "soa_final_missing": [ initial, soa, "private-name.gov.\t3600\tIN\tNS\tns.hidden.example." @@ -164,6 +175,34 @@ def test_failure_reasons_are_safe_and_match_full_aggregation(self): self.assertNotIn("hostmaster.gov", output) self.assertNotIn("privaté.gov", output) + def test_first_record_format_reason_matches_full_aggregation(self): + # Both CLI entry points must use the same structural classifier, + # including the preflight header check before process_input(). + cases = { + "record_blank": "", + "record_fields_comment": "; private-name.gov. confidential", + "record_fields_directive": "$TTL 3600", + "record_fields_space_delimited": "private-name.gov. 3600 IN NS ns.hidden.example.", + "record_fields_missing_tabs": "private-name.gov.", + "record_fields_partial_tabs": "private-name.gov.\t3600\tIN\tNS", + "record_fields_mixed_delimiters": "private-name.gov.\t3600 IN NS ns.hidden.example.", + } + final = "gov.\t3600\tIN\tSOA\tns.gov. hostmaster.gov. 123 2 3 4 5" + for reason, first in cases.items(): + with self.subTest(reason=reason), tempfile.TemporaryDirectory() as d: + root = pathlib.Path(d) + self.setup_fixture(root, "gov.txt.gz", [first, final]) + preflight = self.invoke(root) + build = self.invoke_build(root) + self.assertNotEqual(preflight.returncode, 0) + self.assertNotEqual(build.returncode, 0) + for result in (preflight, build): + output = result.stdout + result.stderr + self.assertIn(f"reason={reason}", output) + self.assertNotIn("private-name.gov.", output) + self.assertNotIn("ns.hidden.example", output) + self.assertNotIn("hostmaster.gov", output) + def test_nonleading_soa_and_glue_deduplicate_in_both_paths(self): with tempfile.TemporaryDirectory() as d: root = pathlib.Path(d) From fdd26f301bc4ba84694ffa9285f24c702a59548f Mon Sep 17 00:00:00 2001 From: "Lukasz D. Tulikowski" Date: Thu, 22 Oct 2026 09:12:23 +0200 Subject: [PATCH 4/5] Add CZDS structural failure classification: README.md --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 73ece1b..37c14fe 100644 --- a/README.md +++ b/README.md @@ -312,11 +312,24 @@ python scripts/preflight_czds.py \ On validation failure, public logs now include a fixed `reason=`, for example `reason=soa_serial_mismatch`, `reason=ns_owner_order`, or -`reason=record_fields`. The code is selected by the production parser; -neither the offending record, owner, NS target, SOA serial, record offset, nor -the exception's original message is printed. Unexpected errors become -`reason=unclassified_error`. Both preflight and the production CLI use -the same error taxonomy, and all validation gates remain fail-closed. +`reason=record_fields_partial_tabs`. A record that does not split into five tab-separated fields +is now classified by its *structure*: + +- `record_fields_comment`: leading semicolon or hash (comment-like syntax; validity not inferred) +- `record_fields_directive`: leading dollar sign (possible master-file directive) +- `record_fields_space_delimited`: whitespace present but no tab (candidate only) +- `record_fields_missing_tabs`: no tabs or whitespace +- `record_fields_partial_tabs`: one to three tabs, without other whitespace +- `record_fields_mixed_delimiters`: tabs mixed with spaces or other horizontal whitespace +- `record_blank`: empty record + +These diagnostics apply to both the first record and later records. They +describe the record's shape, **not** whether a different zone-file grammar +should be accepted. Neither the offending record, owner, NS target, SOA serial, +record offset, nor the exception's original message is printed. Unexpected +errors become `reason=unclassified_error`. Both preflight and full +aggregation use the same classification without changing parsing acceptance, +delegation deduplication, DNS ordering, or SOA consistency. **Troubleshooting licensed inputs:** Run the normal daily-release workflow only on the protected runner. If preflight fails, use its reason code to From f226ce0b8240ce0f9d2aa5ee4866db89edb832df Mon Sep 17 00:00:00 2001 From: "Lukasz D. Tulikowski" Date: Thu, 22 Oct 2026 09:13:26 +0200 Subject: [PATCH 5/5] Permit fixed mixed-delimiter diagnostics in public error-code allowlist --- scripts/build_nsdb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_nsdb.py b/scripts/build_nsdb.py index d85d28b..47cbdf9 100644 --- a/scripts/build_nsdb.py +++ b/scripts/build_nsdb.py @@ -10,7 +10,7 @@ # These fixed codes may appear in public CI logs; exception messages may not. -_PUBLIC_VALIDATION_CODES = frozenset(["dns_name_non_ascii","dns_escape_dangling","dns_escape_decimal_range","dns_label_empty","dns_label_too_long","dns_name_too_long","dns_label_non_ascii","ns_target_empty","ns_rdata_extra","ns_target_escape_dangling","ns_target_root","record_blank","record_fields","header_record_fields","apex_hint_missing","apex_hint_unavailable","after_final_soa","soa_owner_class","soa_serial_invalid","soa_serial_mismatch","soa_excess","ns_owner_outside_apex","ns_owner_order","zone_empty","soa_missing","soa_final_missing","archive_empty","archive_member_unreadable","record_fields_comment","record_fields_directive","record_fields_space_delimited","record_fields_missing_tabs","record_fields_partial_tabs"]) +_PUBLIC_VALIDATION_CODES = frozenset(["dns_name_non_ascii","dns_escape_dangling","dns_escape_decimal_range","dns_label_empty","dns_label_too_long","dns_name_too_long","dns_label_non_ascii","ns_target_empty","ns_rdata_extra","ns_target_escape_dangling","ns_target_root","record_blank","record_fields","header_record_fields","apex_hint_missing","apex_hint_unavailable","after_final_soa","soa_owner_class","soa_serial_invalid","soa_serial_mismatch","soa_excess","ns_owner_outside_apex","ns_owner_order","zone_empty","soa_missing","soa_final_missing","archive_empty","archive_member_unreadable","record_fields_comment","record_fields_directive","record_fields_space_delimited","record_fields_missing_tabs","record_fields_partial_tabs","record_fields_mixed_delimiters"]) def classify_record_fields(line: str) -> str: