config: compile the compact-leaf security-zone interfaces stanza - #6735
config: compile the compact-leaf security-zone interfaces stanza#6735psaab wants to merge 10 commits into
Conversation
`compileZones` iterated `prop.Children` for the `interfaces` stanza and
never read `prop` itself. In the hierarchical COMPACT-LEAF spelling the
member name lands on the stanza's own `Keys[1]` with nil `Children`, so
security { zones { security-zone untrust { interfaces ge-0/0/1.0; } } }
ran the loop body ZERO times and the zone compiled with NO interfaces --
cleanly, no error, no warning. Both strict zone gates
(validateZoneInterfaceMembershipStrict, validateZoneInterfaceDefinedStrict)
then passed VACUOUSLY over the empty slice: the gates that exist
specifically to protect zone membership were inert against a membership
list that never arrived. Downstream, UserspaceBoundLinuxInterfaces skips
any interface with `Zone == ""`, so the interface was never AF_XDP-bound
and every policy naming that zone never applied to its traffic.
The with-body variant was worse than a drop. `prop.Children` there holds
the member's BODY, so the loop ran once with the `host-inbound-traffic`
node mistaken for a member: the real member was dropped AND its body
keywords were compiled as phantom interface names
(`[host-inbound-traffic system-services ssh]`).
Reachability, honest bound: hierarchical text ingest only -- `load
override`, `load merge`, the persisted config file, HA SyncApply. `set`
cannot reach it (SetPath always descends the `interfaces` container) and
`show configuration | display set` round-trips safely. Those are still
the boot path and the peer-sync path.
Implementation. zoneInterfaceMemberNodes NORMALIZES the compact shape
onto the block shape -- it synthesizes one member node carrying
`prop.Keys[1:]` with `prop.Children` as that member's body -- so every
spelling takes the identical code path and membership, the #5248 bracket
flatten and the #6391 Keys-scoped host-inbound override stay in ONE
implementation. A second read path is exactly how this defect class
arises. Note the slice: passing `prop` straight to zoneInterfaceMembers
(whose Keys loop starts at index 0, correct for a CHILD) would compile a
zone member literally named `interfaces`.
zoneInterfaceMembers now also truncates a member's Keys at a body keyword
and stops recursing from there, so the fix cannot trade a silent DROP for
a silent INVENTION. That also corrects the pre-existing block-form
behaviour for the hierarchical PACKED spelling `interfaces { a
host-inbound-traffic system-services ssh; }`, which was already compiling
three phantom members. The packed body is not parsed into a per-interface
override -- a separate gap in the same #2419 packed-body class, filed on
its own; that direction is fail-CLOSED (the override is absent, so the
interface admits only what the zone level admits).
Fail-closed belt: validateZoneInterfacesNonEmptyStrict rejects an
`interfaces` stanza that CARRIES CONTENT yet contributes zero members, so
any future shape the compiler cannot read is loud instead of silent. It
asks the compiler's own reader (zoneInterfaceStanzaMembers) and runs on
the group-expanded *ConfigTree, because a compiled ZoneConfig cannot
distinguish "no stanza" from "a stanza that compiled to nothing". Strict
on commit / commit-check, downgraded to a warning on the tolerant load /
peer-sync paths (lenientZoneInterfacesNonEmpty) per the #1960 no-brick
doctrine, matching its two neighbours in the same gate block.
The gate is content-sensitive rather than a blanket "declared but empty"
check, deliberately: `delete security zones security-zone <z> interfaces
<if>` of the LAST member leaves the now-empty container behind
(deletePath removes the member node but does not prune the container), so
a blanket check would make an ordinary edit uncommittable against a
stanza that renders invisibly -- the #4191 over-rejection class. A
mutation confirms that control binds.
Four pre-existing tests were relying on the silent drop: their configs
use the compact-leaf spelling and never define the interfaces, which only
compiled because the members never reached the defined gate. They now
carry the missing `interfaces` definitions.
Validation: `go build ./...` and `go vet ./...` clean; full Go suite
green (59 packages). Eight mutations, each with build+vet CLEAN and a
real assertion failure: the normalization reverted to `prop.Children`;
the issue's own proposed fix (`prop.Keys` unsliced, which compiles a
member named `interfaces` -- caught by an unrelated pre-existing test
too); the body-keyword truncation removed; body-on-Keys no longer
stopping child recursion; the override scope widened from
zoneInterfaceMemberKeys to zoneInterfaceMembers, which fires six
pre-existing #6391 sibling-leak guards; the gate call removed; the
over-rejection carve-out removed; and an edge mutation collapsing BOTH
sides of the differential, which trips the count floor rather than
passing vacuously. New test names confirmed present in `-v` under a fresh
GOCACHE.
Advances #6525.
Independent review at this head (
|
`zoneInterfaceKeysBeforeBody` truncates a member's Keys at the first body keyword and discards everything after it. The lexer strips brackets (#2419), so these two statements reach the compiler byte-identical: interfaces [ ge-0/0/0.0 host-inbound-traffic ge-0/0/1.0 ]; interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh; and their readings DISAGREE about zone membership. The first is a bracket member list; truncation silently drops `ge-0/0/1.0` — a valid, defined interface the operator placed in this zone — leaving it with Zone == "", so UserspaceBoundLinuxInterfaces never AF_XDP-binds it and no policy naming the zone applies to its traffic. The second is the packed body; truncation silently drops the whole host-inbound override. The #6525 non-empty belt cannot catch the first: one member survived, so the stanza is not empty, and all three readers agree because they consume the same compiled zone.Interfaces. The ambiguity is irreducible at this layer — the punctuation that would tell the two apart is gone before the compiler sees the node — so this resolves it by REFUSING rather than guessing. validateZoneInterfacePackedTailStrict hard-rejects at commit and downgrades to a cfg.Warnings entry on the tolerant load / peer-sync path (#1960 no-brick), naming the zone, the keyword and the exact tokens that would have been dropped, and pointing at the block spelling, which is unambiguous and fully supported. Deliberate consequence rather than collateral: the packed-body spelling used to COMMIT, contributing membership while silently discarding an authored host-inbound directive (the fail-closed residual #6525 left open). Quietly dropping a security directive is the class #6525 exists to close, so making it loud is the intent. A body keyword with NOTHING after it stays accepted — truncation is lossless there, and rejecting it would be the #4191 over-rejection class. The gate runs BEFORE the non-empty gate. They overlap on exactly one shape, `interfaces host-inbound-traffic ge-0/0/1.0;`, where the keyword is first so nothing precedes it and the stanza also compiles to zero members. Both would fire; this one's message is the accurate one, because telling an operator who plainly wrote ge-0/0/1.0 that the stanza "names no interface" sends them hunting the wrong defect. REACHABILITY CORRECTION. #6525 documented this defect class as hierarchical-ingest only — "NOT reachable from the `set` CLI". True of the compact-leaf shape, FALSE of this one. Measured: set security zones security-zone Z interfaces \ [ ge-0/0/0.0 ge-0/0/1.0 host-inbound-traffic ge-0/0/2.0 ] interfaces ge-0/0/0.0 Keys=["ge-0/0/1.0","host-inbound-traffic","ge-0/0/2.0"] SetPath descends the interface-name wildcard for the first token and collapses the rest onto ONE nested leaf, keyword included, so the truncator runs on a nested member and ge-0/0/2.0 is dropped from the ordinary operator CLI. Pinned by TestZoneInterfaces6735FlatSetReachesThePackedTail, which is also what binds the detector's child recursion — measured, with the recursion deleted every other case still passes, because the stanza-level loop already checks each block member's own Keys. Validation. Two mutations, both RED from an ASSERTION with `go build` and `go vet` CLEAN under the mutation, GREEN on restore: M1 detector never reports a tail -> TestZoneInterfaces6735PackedTailRejectedAndPositiveControl "CompileConfig ACCEPTED the ambiguous stanza ..." (3 subtests) M2 child recursion removed -> TestZoneInterfaces6735FlatSetReachesThePackedTail "detector missed a `set`-authored packed tail" The reject and accept tables live in ONE test function on purpose: a rejection test alone cannot distinguish "rejects the ambiguous statement" from "rejects everything", and keeping them together means neither can be removed without the other. Advances #6525. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Gate finding B2 said every test added by #6735 still passes under a meaningful reversion. Verified each claim rather than accepting it; the headline one is wrong and two are right. WRONG, measured. Reverting `zoneInterfaceMemberNodes` to the pre-fix `return prop.Children` — the core #6525 fix — fails NINE tests with `go build` and `go vet` clean: TestHostInbound3703HierarchicalBlockShape TestZoneInterfaces6525CompactLeafMatchesBlock TestZoneInterfaces6525CompactLeafNeverInventsMembers TestZoneInterfaces6525OverrideStaysScopedToItsOwnMember TestZoneInterfaces6525StrictGatesNoLongerVacuous TestHostInboundDupBlock4544ZoneMerges TestHostInboundDupBlock4544MergeDedups TestHostInboundDupBlock4544SingleBlockUnchanged TestNat66SourceRules including the 4544 host-inbound tests and TestNat66SourceRules, which the finding named as passing under exactly that reversion. The core fix is bound; the differential in CompactLeafMatchesBlock is what binds it. RIGHT, and fixed: - TestZoneInterfaces6525EmptyStanzaRejected had no positive control. A `zoneInterfaceStanzaMembers` that always returned nil makes EVERY stanza look empty, so the reject table passes while the gate rejects everything. Measured: that mutation builds clean, vets clean, and the test passed under it. Adds a positive-control subtest — three stanzas that name a defined interface and must still compile with a non-empty member set. It now goes RED under that mutation. - TestZoneInterfaces6525FlatSetNeverReachesCompactLeaf asserted inside two filtered loops, so if SetPath ever stopped emitting an `interfaces` property the body never ran and the test passed having asserted nothing. Adds an `examined` counter and requires at least one stanza. It now goes RED when the filter matches nothing. Both are proven by mutation, RED from an assertion, GREEN on restore. Also corrects a description that was simply wrong, in the code comment and in docs/config-schema.md: a flat bracket list does NOT produce one child per member. Empirically — set security zones security-zone Z interfaces [ a b c ] interfaces Keys=["interfaces"] children=1 a Keys=["a"] children=1 b c Keys=["b","c"] children=0 The schema models the interface name as a wildcard CONTAINER, so SetPath descends it for `a` and collapses the remainder onto one leaf beneath it. `zoneInterfaceMembers` recurses and reads every key at each level, so the members are read correctly and this was a description bug, not a code bug — but the wrong description is exactly the mental model that produces a one-level `prop.Children` reader, which is the #6525 defect itself. Pinned by TestZoneInterfaces6735FlatSetBracketNestsRatherThanFanning. The #6525 reachability bound is narrowed to the shape it actually describes, since the sibling packed-tail shape IS reachable from `set`. Advances #6525. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The packed-tail gate runs before the non-empty gate deliberately: they overlap on exactly one shape, `interfaces host-inbound-traffic ge-0/0/1.0;`, where the keyword is first so nothing precedes it and the stanza also compiles to zero members. The packed-tail message names the token that was dropped; "names no interface" is true but sends an operator who plainly wrote ge-0/0/1.0 hunting the wrong defect. Nothing tested that. Both gates return a non-nil error for the overlap shape, so swapping them left every test green and silently handed the operator the worse message — an ordering rationale with no guard behind it, which is how it decays into someone "simplifying" the two gates into one. Asserting the three tokens does not separate them either: the non-empty message renders the same zone, keyword and dropped token through zoneInterfaceStanzaTokens, so the pre-existing assertion in the reject table matched both messages equally. Each gate now declares the distinguishing clause of its own message as a named constant — zoneInterfacePackedTailReason and zoneInterfacesNonEmptyReason — used verbatim in that message. The rendered text is byte-identical; the point is that a test can assert WHICH gate rejected rather than matching prose a later reword would silently unbind. TestZoneInterfaces6735OverlapShapeReportsThePackedTailGate asserts both directions. The overlap shape must carry the packed-tail reason and NOT the non-empty one; a genuinely empty stanza (`interfaces host-inbound-traffic;`, a body-only block) must carry the non-empty reason and NOT the packed-tail one. Without the second half, "make the packed-tail gate fire on everything" would satisfy the first. Proven by swapping the two gate invocations: RED on the reason assertion with `go build` = 0 and `go vet` = 0 — both orders compile, so this is an assertion failure and not a build break — and GREEN on restore. Also fixes two comments that contradicted the shape this PR's own tests pin. Both described SetPath as storing "each member below it", implying a flat fan-out; TestZoneInterfaces6735FlatSetBracketNestsRatherThanFanning proves a flat bracket list NESTS into a chain. The load-bearing claim in both places is narrower — the STANZA node's Keys stay exactly ["interfaces"] — so both now assert that and explicitly disclaim any statement about how the members are arranged below it. Since this PR is what established that shape empirically, the comments follow the tests. And repairs structure this branch broke earlier: moving the packed-tail gate ahead of the non-empty gate left the non-empty comment block stranded above the packed-tail comment with its own code far below it. Each gate's comment now sits with its own code. Advances #6525. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The positive-control table asserted compiled MEMBERSHIP only. Two of its cases carry a `host-inbound-traffic` body, and those are exactly the spellings the packed-tail reject message tells the operator to rewrite INTO. So the table would have stayed green while the block-spelling override silently vanished — it accepted the one regression that would make the reject message's own advice actively harmful, sending operators to a spelling whose body no longer compiled. Each accept case now asserts the compiled per-interface override as well. `wantHIB` is nil for `interfaces ge-0/0/0.0 host-inbound-traffic;`, which is the documented fail-CLOSED #6525 residual (a packed body is not parsed into an override) rather than an oversight, and stating it as an expectation keeps that distinction visible instead of implicit. Three mutations added so every assertion introduced by this branch has a distinguishing one. All RED from an assertion, with `go build` = 0 and `go vet` = 0 under the mutation, GREEN on restore: M5 gate fires regardless of tail -> accept table: "REJECTED the unambiguous stanza `interfaces ge-0/0/0.0 host-inbound-traffic;`" (#4191 direction) M6 override fans to no member -> accept table: "scoped the per-interface host-inbound override to [], want [ge-0/0/0.0]" — the assertion added here M7 packed-tail gate strict on the tolerant path -> "CompileConfigLenient rejected a packed-tail stanza" (#1960) Note on the technique this branch settled on, since it generalizes: the ordering fix works by giving each gate a named reason constant used VERBATIM in its own message. Rendered operator-visible text is byte-identical, so nothing an operator reads changed and every pre-existing assertion still matches — but a test can now assert WHICH gate rejected, binding gate identity rather than prose that a later reword would silently unbind. That is the reusable shape for any pair of gates whose messages can both plausibly match the same input. Advances #6525. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The r4 log entry claimed three mutations gave "every new assertion" a distinguishing one. That was false. This round measures per-mutation failure sets instead of restating the claim, corrects the sentence, and closes the one test-acceptance gap the gate found. DEFINITION, so the claim stays checkable. A mutation DISTINGUISHES an assertion when, with that single production edit applied, the failure set across the whole pkg/config package is that assertion ALONE. A mutation that also reds assertions predating it proves those older assertions still work; it proves nothing about the new one. Measured (every one: go build ./... = 0, go vet ./... = 0, RED from an assertion, GREEN on restore): M5 gate ignores the tail 10 failing distinguishes nothing M6 override fans to no member 35 failing distinguishes nothing M7 packed gate strict on tolerant path 1 failing the lenient warning A5 gate fires when keyword is not FIRST 1 failing keyword-last accept row ORD swap the two gate invocations 1 failing the gate ORDER A6 override compiles keyed-but-EMPTY 26 failing distinguishes nothing M5 reds the pre-branch TestZoneInterfaces6525EmptyStanzaRejected; M6 reds #6391 x9 plus #5248/#3362/#3703/#4544/#4818/#3226/#4455. Neither says anything about an assertion added here. A5 replaces M5 as the citation for the keyword-last accept row — same file, one condition narrower, and its failure set is that row alone. M6 has no replacement because none exists. So THREE assertions this branch adds are independently binding, each with a named edit that reds it and nothing else: the tolerant-path warning, the keyword-last accept row, and the gate order. The rest corroborate. The accept table asserted override KEYS, which accepts an override whose value is nil or empty — the failure default of that path, so it was green for exactly the regression it names. It now asserts member -> admission TOKENS via hibTokens6735, and one row carries a multi-token body ([ ssh ping ] + protocols ospf) fanned across two bracket members, so a fan keeping only the first service and a clone sharing a backing store both fail it. The empty-value edit reds 26 assertions against the key-only form and 30 against the token form; the +4 are exactly the two body-carrying rows. All 26 are pre-branch, so this assertion still does not BIND the override — the struct comment now says so rather than implying otherwise. Third comment this PR that its own tests disprove: the packed-tail gate said "a keyword with nothing after it, or a body-only block, leaves this gate silent". A body-only block is not silent by virtue of being body-only. `interfaces { host-inbound-traffic system-services ssh; }` carries `system-services ssh` on the keyword's OWN Keys, so it is a tail and the gate FIRES; only the nested-block spelling `interfaces { host-inbound-traffic { system-services ssh; } }` stays silent and falls through to the non-empty gate. The comment now names nested-block, and the same-Keys spelling is a new reject-table row so the corrected text is pinned rather than asserted. Also recorded in the overlap test that it observes rendered reason TEXT, not helper identity: an overbroad packed-tail gate that rendered the non-empty reason for the empty-member shapes would pass it. Deliberate — the operator-visible message is the contract — but it should not be read as a structural pin, and nobody should add an assertion trying to make it one. Validation: go build ./... = 0, go vet ./... = 0, and a full unsandboxed go test ./... under a FRESH GOCACHE = exit 0, 59 packages ok, zero failures. No file crossed a refactor-audit tier (compiler_validate_strict_zones.go 735, the test file 589 and excluded); pkg/refactoraudit passes, so no heatmap regeneration is owed. Advances #6735.
# Conflicts: # _Log.md
Resolve the sole conflict, _Log.md, by union: every entry from both sides is retained and none is rewritten. The file is no longer append-ordered, so line counts and prefix checks say nothing useful about the result; the resolution was verified structurally instead, by confirming that each pre-merge side diffs into the merged file with add-hunks only and no changed-or-deleted hunk on either side. Every other path merged without conflict. Because a clean textual auto-merge can still break compilation when a signature moves on one side, that was confirmed by building rather than by inspection: go build ./... clean on the merged tree. Advances #6735.
…ct-leaf # Conflicts: # _Log.md
# Conflicts: # _Log.md
The defect
pkg/config/compiler_security_zones.go,case "interfaces":iteratedprop.Childrenand never readpropitself. In the hierarchical COMPACT-LEAFspelling the member name lands on the stanza's own
Keys[1]with nilChildren, soran the loop body zero times and the zone compiled with no interfaces —
cleanly, no error, no warning. Both strict zone gates
(
validateZoneInterfaceMembershipStrict,validateZoneInterfaceDefinedStrict)then passed vacuously over the empty slice: the gates that exist
specifically to protect zone membership were inert against a membership list
that never arrived. Downstream
UserspaceBoundLinuxInterfaces(
pkg/dataplane/userspace/interfaces.go) skips any interface withZone == "",so the interface was never AF_XDP-bound and every policy naming that zone never
applied to its traffic.
The with-body variant was worse than a drop:
prop.Childrenthere holds themember's body, so the loop ran once with the
host-inbound-trafficnodemistaken for a member — the real member dropped and its body keywords
compiled as phantom interface names.
Measured differential (compileZones, at the parent SHA)
[ge-0/0/1.0][][ge-0/0/0.0 ge-0/0/1.0][][ge-0/0/0.0]+hib[ge-0/0/0.0]={ssh}[host-inbound-traffic system-services ssh], no overrideReachability (honest bound)
Hierarchical text ingest only —
load override/load merge/ the persistedconfig file / HA
SyncApply. Not reachable from thesetCLI (SetPathalways descends the
interfacescontainer and stores each member below it —pinned by
TestZoneInterfaces6525FlatSetNeverReachesCompactLeaf), andshow configuration | display setround-trips safely. Those are still the bootpath and the peer-sync path.
The fix
zoneInterfaceMemberNodesnormalizes the compact shape onto the blockshape: it synthesizes one member node carrying
prop.Keys[1:]withprop.Childrenas that member's body. Every spelling then takes the identicalcode path, so membership, the #5248 bracket flatten and the #6391 Keys-scoped
host-inbound override stay in one implementation — a second read path is
exactly how this defect class arises.
Note the slice. The fix proposed in the issue is wrong as written: passing
propstraight tozoneInterfaceMembers(whose Keys loop starts at index 0,correct for a child) compiles a zone member literally named
interfaces. Thatis mutation-proven below, and an unrelated pre-existing test catches it too.
zoneInterfaceMembersnow also truncates a member's Keys at a body keyword andstops recursing there, so the fix cannot trade a silent drop for a silent
invention. That additionally corrects the pre-existing block-form
behaviour for the hierarchical PACKED spelling
interfaces { a host-inbound-traffic system-services ssh; }, which was alreadycompiling three phantom members.
Fail-closed belt
validateZoneInterfacesNonEmptyStrictrejects aninterfacesstanza thatcarries content yet contributes zero members, so any future shape the
compiler cannot read is loud instead of silent. It asks the compiler's own
reader (
zoneInterfaceStanzaMembers) rather than re-deriving membership fromthe AST. That is deliberate, and it is the lesson of this defect class: a gate
carrying its own second derivation of what a stanza names can drift from the
compiler and go vacuous exactly when it matters — which is precisely how the two
existing zone gates ended up walking an empty slice here. Sharing one reader
makes the gate structurally incapable of disagreeing with what actually got
compiled. It runs on the group-expanded
*ConfigTreerather than the compiled*Configbecause a compiledZoneConfigcannot distinguish "no stanza" from"a stanza that compiled to nothing".
strict vs warn: strict on commit / commit-check, downgraded to a
cfg.Warningsentry on the tolerant load / peer-sync paths(
lenientZoneInterfacesNonEmpty) — verbatim the convention of its twoneighbours in the same gate block and the #1960 no-brick doctrine. On the
tolerant path behaviour is unchanged (the stanza contributed no members before
this gate existed and still contributes none), just with an operator-visible
warning.
The gate is content-sensitive rather than a blanket "declared but empty"
check, deliberately:
delete security zones security-zone <z> interfaces <if>of the last member leaves the now-empty container behind (
deletePathremovesthe member node but does not prune the container), so a blanket check would make
an ordinary edit uncommittable against a stanza that renders invisibly — the
#4191 over-rejection class. Mutation M7 below proves that control binds.
The #6391 separation is intact
compileZonesstill scopes the per-interface host-inbound override on themember node's own Keys (
zoneInterfaceMemberKeys) and never on itschildren.
TestZoneInterfaces6525OverrideStaysScopedToItsOwnMemberproves anoverride authored for
adoes not reachb, in both the hierarchicalnested-member shape and the exact #6389 flat-set config
(
set ... interfaces [ a b ]thenset ... interfaces a host-inbound-traffic ... ssh). Mutation M5 widens thatscope and fires six pre-existing #6391 sibling-leak guards plus the new test.
Mutation evidence
Every mutation applied by
Edit(nevergit checkout), each withgo build ./...andgo vetclean and a real assertion failure, restored byEdit; unrelated tests green throughout.for _, iface := range prop.Childrencompact-leaf "interfaces ge-0/0/1.0;" compiled zone membership [], but the block spelling "interfaces { ge-0/0/1.0; }" compiled [ge-0/0/1.0](+CompileConfig accepted a compact-leaf zone member naming an UNDEFINED interface; the strict zone-interface-defined gate passed vacuously over an empty member set)Keys: prop.Keys(not sliced)compiled zone membership [interfaces ge-0/0/1.0], but the block spelling … compiled [ge-0/0/1.0]; unrelatedTestHostInbound3703HierarchicalBlockShapealso reds withreferences interface "interfaces"stanza "interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh;" compiled zone membership [ge-0/0/0.0 host-inbound-traffic system-services ssh], want [ge-0/0/0.0](block form too)reject error "… references interface \"system-services\" …" is not the #6525 non-empty gatezoneInterfaceMemberKeys→zoneInterfaceMembersper-interface host-inbound = map[ge-0/0/0:… ge-0/0/1:…], want map[ge-0/0/0:…] (sibling leak?)— six pre-existing #6391 guards + the new testCompileConfig accepted a zone whose 'interfaces' stanza (interfaces host-inbound-traffic;) names no interface+tolerant path produced no 'zone interfaces non-empty' warningCompileConfig rejected a config whose zone lost its last interface via 'delete': … — the non-empty gate must not fire on a stanza that declares nothingblock spelling "interfaces { ge-0/0/1.0; }" compiled 0 members [], want 1 — the differential's reference side is itself broken— the count floor binds, the equality does not pass vacuouslyThe differential derives its expectation from the block spelling rather than
a hardcoded list, with a per-case member-count floor so a both-empty pair cannot
pass (M8 proves the floor).
Four pre-existing tests were relying on the silent drop
TestHostInboundDupBlock4544{ZoneMerges,MergeDedups,SingleBlockUnchanged}andTestNat66SourceRulesuse the compact-leaf spelling and never define theinterfaces they name. They only compiled because the members never reached the
defined gate. They now carry the missing
interfacesdefinitions.Validation
go build ./...+go vet ./...clean. Full Go suite green (59 packages, exit0). New test names confirmed present in
-voutput under a fresh GOCACHE(8 top-level, 19 subtests). No cluster/deploy tooling was run.
Docs
docs/config-schema.mdgains a "The COMPACT-LEAF spelling…" section between the#5248 and #6391 sections (shape table, both traps, the gate and its
over-rejection carve-out, the reachability bound);
pkg/config/README.mdgainsthe general Gotchas rule — a stanza's members can live on the stanza's own Keys,
not only in its children.
Adjacent gap filed separately
The hierarchical packed per-interface host-inbound body
(
interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh;) still doesnot compile into an override — same #2419 packed-body class, different
dimension, fail-CLOSED. Filed as its own issue rather than folded here.
Closes #6525
🤖 Generated with Claude Code
https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi