Fix evaluator and reporter defects where a rule reported compliance, or nothing at all, for input it should have rejected - #717
Conversation
emit_code computed its first context line as `max(1, line - 2)`, which
evaluates the subtraction before the clamp. On an unsigned line number of
0 or 1 that underflows:
- debug builds panic ("attempt to subtract with overflow") and exit 101,
which is not one of cfn-guard's documented exit codes
- release builds wrap to ~usize::MAX, so seek_line runs past EOF and the
source snippet is silently omitted from the violation report while the
exit code stays a correct 19
Only the violation path is affected, so the tool misbehaves precisely when
it has something to report. The trigger is input formatting rather than
content: a minified single-line template reports the violated property at
line 0 or 1.
Extract the arithmetic into context_start_line() and use saturating_sub so
the clamp applies to the input, and add unit tests covering lines 0-2 (the
underflowing inputs) plus the normal case.
Reachable from the library entry point, so it also affects guard-ffi, where
unwinding out of an extern "C" function is undefined behavior.
A leading `not` on a clause with a binary operator was parsed and stored as GuardAccessClause::negation (parser.rs:969, :1034) but never applied: the binary evaluation path called binary_operation() without passing gac.negation, while only the unary path consumed it. `grep negation` over eval.rs found three uses, none in the binary path. The effect is that `not <query> == <value>` evaluated as plain `<query> == <value>` -- the exact inverse of the author's intent. A rule written to reject an insecure value instead accepted it and rejected the secure one. The report compounded this by rendering the clause *with* the `not`, so the output gave no indication the negation had been dropped. Compose the clause negation with the operator's own not-flag (from `!=` / `not in`) by XOR at the call site. This matches invert_closure() in the superseded evaluator (evaluate.rs:293-307), which applies both flips independently and is the reference for the intended semantics -- evidence this was a regression in the v3 evaluator rather than a design decision. Tests assert the negated form now yields the author's intent, that the un-negated form is unchanged, and that double negation (`not ... !=`) composes correctly. Verified the first and third fail without the fix. No existing test covered clause negation on a binary access clause: all negation cases in parser_tests.rs are named-rule or parameterized-call forms, and eval_tests.rs had none.
eval_guard_named_clause collapsed SKIP into the same match arm as FAIL, so
with negation it produced PASS. `not <rule>` therefore reported compliance
on the strength of a dependent rule that never ran. This is worse than a
SKIPped clause: the enclosing rule reported PASS rather than SKIP, so the
output contained no indication that the check had been omitted.
The two contexts a named-rule reference is reached from need different
answers, so thread a strict_skip flag:
- rule body (GuardClause::NamedRule) -- the reference is an assertion, so
a SKIPped dependent rule fails closed. The un-negated case already
returned FAIL for a SKIP, so only the negated case changes and no
existing failure is relaxed.
- `when` condition (WhenGuardClause::NamedRule) -- gating on a rule that
did not apply is deliberate. `rule r when !other { ... }` is how a
ruleset expresses "apply this when that other rule did not apply", and
cross_rule_clause_when_checks asserts exactly that. Behavior preserved.
A first attempt failed SKIP unconditionally and broke
cross_rule_clause_when_checks, which is what surfaced the distinction.
Tests cover both contexts: the body case must not PASS (verified failing
without the fix), and the when-condition case must still gate, which guards
against over-correcting this in future.
`Tags == 'Owner'` against `Tags: []` reported the file as *compliant* -- not as not-applicable -- while the same rule against a missing `Tags` correctly failed. The weaker input was treated more leniently, and the JSON claimed a check had been performed that never compared anything. Cause: `selected`/`flattened` expand a list into its elements, so an empty list contributes none. The comparison loop pushes zero results and the enclosing fold reads an empty result vector as "nothing to check", reporting PASS. Record it per element rather than by testing the whole flattened left-hand side. The EqOperation `(None, Some(r))` arm uses `selected`, not `flattened`, so each query result is still one resource's value and the empty case can be attributed to it. A whole-LHS emptiness test is defeated by any sibling resource with a non-empty list -- the common shape in real templates -- which is how an earlier attempt at this fix passed every test written for it while doing nothing on multi-resource input. There is a regression test for exactly that cardinality. Resolved by role at the eval.rs boundary, mirroring EmptyRhsUnsatisfiable: FAIL as an assertion, no record at all as a gate. A gate must not fail here, because eval_rule treats a non-PASS condition as "rule does not apply" and drops the guarded body -- trading one unenforced clause for an entire disarmed block, at exit 0. Note the gate contributes *no* entry rather than a SKIP one: `statues` is a per-value PASS/FAIL vector whose consumers treat SKIP as unreachable (eval.rs:1353). Emitting SKIP there panicked with exit 101. Negated clauses opt out. This arm runs before the per-value inversion, so a FAIL raised here is unreachable by the `not`, and `not (Tags == 'Owner')` over nothing is vacuously true. Scope: EqOperation only. `IN` and the four ordering operators have the same wrong PASS but go through CommonOperator, which uses `flattened` and so has no per-result provenance to attach a guard to; fixing them means converting that path to `selected` first, which changes every list comparison and not just the empty ones. Left unfixed and documented rather than pattern-matched. Verified: 346 unit tests (4 new, and the two WP-1 tests fail with the guard disabled); 456-pair corpus differential 0 differences; 23/23 behaviour matrix; wrong-FAIL hunt shows no wrong PASS and no unattributed new FAIL -- the two it flags are PR aws-cloudformation#717's EmptyRhsUnsatisfiable (proven by a populated-LHS control also moving 0->19) and a `some` block with no satisfying resource (exit 0 once a satisfying sibling is added).
A template containing no matching resource at all -- no empty collection anywhere --
FAILs in one spelling and skips in the other:
%expected == Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags -> 19
Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags == %expected -> 0
Measured against a template holding only an SQS queue. docs/QUERY_AND_FILTERING.md:222
states the skip is intended: "A template contains resources but none match ... the
block level clauses will be skipped." So the mirrored spelling looks wrong.
Attribution, by execution rather than reading: exit 0 on pristine v3.2.0, exit 19 on
26b7184. It is a regression introduced by the EmptyRhs work in PR aws-cloudformation#717, not by the
empty-collection commits, and it matters because aws-cloudformation#717 is the PR awaiting review.
Cause: the `lhs.is_empty() -> Skip` rule is stated positionally, but zero selection is
a property of the query, not of the side it sits on. With a literal on the left, the
left side is never empty, so a zero-selecting right side falls through to EmptyRhs and
fails.
Not fixed, and the reason is a genuine conflict rather than effort. Skipping when the
left side is a literal was implemented and reverted: it also changes `%lit IN %empt`,
which `literal_lhs_against_empty_reference_fails_without_panicking` asserts must FAIL.
Both shapes are a literal left side against an empty query, so nothing available at
that level distinguishes "the resource type is absent from the template" from "the
reference resolved to nothing" -- that needs query provenance neither operand carries.
Choosing one would invert a deliberate assertion in the parent PR and change documented
empty-reference semantics, so it is recorded at the line that causes it instead of
guessed at.
No behaviour change in this commit: 350 unit tests, and the asymmetry above still
measures 19/0 as described.
…rule ca521cc turned a blocked violating template into a passing one: rule vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags != 'Owner' } rule body_bad when vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Name == 'privatebucket' } against a template with `Tags: []` and `Name: publicbucket`. Measured against the real parent a9c7f96: 19 -> 0, with both rules reported `not_applicable` and the violating resource never examined. That is verbatim the outcome ca521cc's own comment said the fix must not cause. Why the role guard did not cover it: `eval_when_clause` hardcodes ClauseRole::Gate, so the direct `rule r when <clause> {}` spelling is protected and measures 19/19. But eval_context.rs:1116 evaluates a *named* rule's body with ClauseRole::Assertion whatever the reference site is, so inside `vac_ne` the clause sees strict role, returns EmptyQueryResult(SKIP), and eval_rule reads the non-PASS condition as "does not apply". The status is then cached per rule name (eval_context.rs:1095), so the poisoned SKIP is reused by every later reference to that rule. ClauseRole carries the assertion/gate asymmetry at every syntactic site and cannot carry it across a named-rule boundary, because that boundary erases the reference context by construction. A real fix needs the reference-site role threaded into rule evaluation and the status cache keyed on (rule, role) rather than rule alone -- a change to the rule-evaluation contract, not to a comparator. The trade, measured across three baselines: ne_or (what ca521cc fixed) v3.2.0 0 parent 0 -> was 19 r4_namedgate (what ca521cc broke) v3.2.0 19 parent 19 -> was 0 ca521cc fixed a wrong PASS that was already present in v3.2.0 and broke a gate that worked in v3.2.0. Net regression, so it goes. Kept from ca521cc: the lhs_flattened -> lhs_selected rename, and the two interaction tests. The disjunction test is now #[ignore]d with the reproduction and both failed fix attempts recorded -- `cargo test -- --ignored` still runs it, and it will pass when the underlying issue is addressed. Verified it still fails when run explicitly, so it is a live reproduction rather than a silenced assertion. Added a regression test for the shape that caught this. Every gate fixture I had spelled the condition inline, which is the one spelling that cannot expose the named-rule path. Verified the new test fails with ca521cc restored. Also adds run3.sh, replacing run.sh for future differentials. run.sh pointed OLD at target/release/cfn-guard -- an Aug-10 v3.2.0 build, i.e. a pre-aws-cloudformation#717 baseline -- so its OLD-vs-NEW delta attributed the whole of aws-cloudformation#717 to whichever commit was under test. That is why two rounds of my own testing read this regression as "unchanged". run3.sh reports v3.2.0, the real parent, and HEAD, and flags a parent-relative regression explicitly. Verified: 352 unit tests, 2 ignored; 456-pair corpus differential 0 differences; 23/23 matrix; the reviewer's five control fixtures all 19/19/19 across three baselines.
0e140b3 to
4658eda
Compare
|
You're right that the current behaviour under-enforces, and the code comment claiming "the weaker status costs nothing in the standalone case" is wrong. What it costs is the enforcement of the clause: a rule whose only check is One useful thing fell out of looking at this. The literal-vs-query distinction you're asking for is already structural rather than something that needs new plumbing. Which is why I want to check the semantics with you before pushing it, because I don't think that distinction separates the cases we actually care about. Consider: That reference is query-derived and resolved to nothing, and the template is compliant. Failing it rejects correct input, and Two ways to take it, and I'd rather you pick:
Either way I'll add the CLI-level regression test you asked for, asserting that the standalone negated rule does not reach a successful exit without enforcement. The other two comments are addressed in the latest push:
Both fixes are mutation-checked: reverting either one fails its new test and nothing else. The gate test took two attempts. The first fixture used Also fixed |
|
@Zee2413 ^^ |
Fixing the path sanitisation in the previous commit took the validate target from 81 passed/15 failed to 95/1. The remaining failure, test_validate_with_failing_complex_rule, is real and was masked the whole time. Bisected to 5e83239, "Bind a literal rule argument as Literal, not Resolved": its parent passes, it fails. Not from PR aws-cloudformation#717 -- measured on that branch's head, which passes -- so it entered on feat/status-type-migration and every branch stacked on it inherited it. The behaviour change there is correct. For `rule r(replaced, expected) { %expected == %replaced }` called with a literal, recognising the literal moves the clause off the (None, None) diff arm onto the equality arm, which is what an `==` clause should do; the old path applied set difference and produced the wrong verdict for the case that commit fixed. What degraded is the report. The literal has no path, so the record's `from` carries Path=[L:0,C:0]: PropertyPath = [L:0,C:0] was /Resources/newServer/Properties/Arn <context lines> template 1-5 was template 7-12 The context window centres on the reported path, so a finding that used to show the offending `Arn:` line now shows the top of the file -- which is the part of the report an operator actually reads. I regenerated the fixture, read the diff, and reverted it. Updating it would make the suite green by ratifying the degradation and would destroy the only remaining signal that it happened. The failing test is the signal, and it now has the diagnosis attached at the site that causes it. The fix is to locate the record on whichever side has a real path when the other is a pathless literal, rather than unconditionally on the left -- `from:` in report_value. That changes every comparison report, so it needs its own change and its own fixture review rather than being folded in here. Comment-only. 330 lib tests, 0 failed, 1 ignored. validate 95/1.
Fixing the path sanitisation in the previous commit took the validate target from 81 passed/15 failed to 95/1. The remaining failure, test_validate_with_failing_complex_rule, is real and was masked the whole time. Bisected to 5e83239, "Bind a literal rule argument as Literal, not Resolved": its parent passes, it fails. Not from PR aws-cloudformation#717 -- measured on that branch's head, which passes -- so it entered on feat/status-type-migration and every branch stacked on it inherited it. The behaviour change there is correct. For `rule r(replaced, expected) { %expected == %replaced }` called with a literal, recognising the literal moves the clause off the (None, None) diff arm onto the equality arm, which is what an `==` clause should do; the old path applied set difference and produced the wrong verdict for the case that commit fixed. What degraded is the report. The literal has no path, so the record's `from` carries Path=[L:0,C:0]: PropertyPath = [L:0,C:0] was /Resources/newServer/Properties/Arn <context lines> template 1-5 was template 7-12 The context window centres on the reported path, so a finding that used to show the offending `Arn:` line now shows the top of the file -- which is the part of the report an operator actually reads. I regenerated the fixture, read the diff, and reverted it. Updating it would make the suite green by ratifying the degradation and would destroy the only remaining signal that it happened. The failing test is the signal, and it now has the diagnosis attached at the site that causes it. The fix is to locate the record on whichever side has a real path when the other is a pathless literal, rather than unconditionally on the left -- `from:` in report_value. That changes every comparison report, so it needs its own change and its own fixture review rather than being folded in here. Comment-only. 330 lib tests, 0 failed, 1 ignored. validate 95/1.
Option 1, fail closed on query-derived empties. The two error modes aren't symmetric: a wrong FAIL is visible and gets investigated; a wrong SKIP exits 0 and is indistinguishable from PASS in CI, which is the deny-list bypass this PR exists to close. Option 2 leaves every ruleset without an explicit The legitimate empty-reference case keeps an escape hatch under option 1: an author who expects a possibly-empty reference can gate with Two asks with the change:
|
`Tags == 'Owner'` against `Tags: []` reported the file as *compliant* -- not as not-applicable -- while the same rule against a missing `Tags` correctly failed. The weaker input was treated more leniently, and the JSON claimed a check had been performed that never compared anything. Cause: `selected`/`flattened` expand a list into its elements, so an empty list contributes none. The comparison loop pushes zero results and the enclosing fold reads an empty result vector as "nothing to check", reporting PASS. Record it per element rather than by testing the whole flattened left-hand side. The EqOperation `(None, Some(r))` arm uses `selected`, not `flattened`, so each query result is still one resource's value and the empty case can be attributed to it. A whole-LHS emptiness test is defeated by any sibling resource with a non-empty list -- the common shape in real templates -- which is how an earlier attempt at this fix passed every test written for it while doing nothing on multi-resource input. There is a regression test for exactly that cardinality. Resolved by role at the eval.rs boundary, mirroring EmptyRhsUnsatisfiable: FAIL as an assertion, no record at all as a gate. A gate must not fail here, because eval_rule treats a non-PASS condition as "rule does not apply" and drops the guarded body -- trading one unenforced clause for an entire disarmed block, at exit 0. Note the gate contributes *no* entry rather than a SKIP one: `statues` is a per-value PASS/FAIL vector whose consumers treat SKIP as unreachable (eval.rs:1353). Emitting SKIP there panicked with exit 101. Negated clauses opt out. This arm runs before the per-value inversion, so a FAIL raised here is unreachable by the `not`, and `not (Tags == 'Owner')` over nothing is vacuously true. Scope: EqOperation only. `IN` and the four ordering operators have the same wrong PASS but go through CommonOperator, which uses `flattened` and so has no per-result provenance to attach a guard to; fixing them means converting that path to `selected` first, which changes every list comparison and not just the empty ones. Left unfixed and documented rather than pattern-matched. Verified: 346 unit tests (4 new, and the two WP-1 tests fail with the guard disabled); 456-pair corpus differential 0 differences; 23/23 behaviour matrix; wrong-FAIL hunt shows no wrong PASS and no unattributed new FAIL -- the two it flags are PR aws-cloudformation#717's EmptyRhsUnsatisfiable (proven by a populated-LHS control also moving 0->19) and a `some` block with no satisfying resource (exit 0 once a satisfying sibling is added).
A template containing no matching resource at all -- no empty collection anywhere --
FAILs in one spelling and skips in the other:
%expected == Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags -> 19
Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags == %expected -> 0
Measured against a template holding only an SQS queue. docs/QUERY_AND_FILTERING.md:222
states the skip is intended: "A template contains resources but none match ... the
block level clauses will be skipped." So the mirrored spelling looks wrong.
Attribution, by execution rather than reading: exit 0 on pristine v3.2.0, exit 19 on
26b7184. It is a regression introduced by the EmptyRhs work in PR aws-cloudformation#717, not by the
empty-collection commits, and it matters because aws-cloudformation#717 is the PR awaiting review.
Cause: the `lhs.is_empty() -> Skip` rule is stated positionally, but zero selection is
a property of the query, not of the side it sits on. With a literal on the left, the
left side is never empty, so a zero-selecting right side falls through to EmptyRhs and
fails.
Not fixed, and the reason is a genuine conflict rather than effort. Skipping when the
left side is a literal was implemented and reverted: it also changes `%lit IN %empt`,
which `literal_lhs_against_empty_reference_fails_without_panicking` asserts must FAIL.
Both shapes are a literal left side against an empty query, so nothing available at
that level distinguishes "the resource type is absent from the template" from "the
reference resolved to nothing" -- that needs query provenance neither operand carries.
Choosing one would invert a deliberate assertion in the parent PR and change documented
empty-reference semantics, so it is recorded at the line that causes it instead of
guessed at.
No behaviour change in this commit: 350 unit tests, and the asymmetry above still
measures 19/0 as described.
…rule ca521cc turned a blocked violating template into a passing one: rule vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags != 'Owner' } rule body_bad when vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Name == 'privatebucket' } against a template with `Tags: []` and `Name: publicbucket`. Measured against the real parent a9c7f96: 19 -> 0, with both rules reported `not_applicable` and the violating resource never examined. That is verbatim the outcome ca521cc's own comment said the fix must not cause. Why the role guard did not cover it: `eval_when_clause` hardcodes ClauseRole::Gate, so the direct `rule r when <clause> {}` spelling is protected and measures 19/19. But eval_context.rs:1116 evaluates a *named* rule's body with ClauseRole::Assertion whatever the reference site is, so inside `vac_ne` the clause sees strict role, returns EmptyQueryResult(SKIP), and eval_rule reads the non-PASS condition as "does not apply". The status is then cached per rule name (eval_context.rs:1095), so the poisoned SKIP is reused by every later reference to that rule. ClauseRole carries the assertion/gate asymmetry at every syntactic site and cannot carry it across a named-rule boundary, because that boundary erases the reference context by construction. A real fix needs the reference-site role threaded into rule evaluation and the status cache keyed on (rule, role) rather than rule alone -- a change to the rule-evaluation contract, not to a comparator. The trade, measured across three baselines: ne_or (what ca521cc fixed) v3.2.0 0 parent 0 -> was 19 r4_namedgate (what ca521cc broke) v3.2.0 19 parent 19 -> was 0 ca521cc fixed a wrong PASS that was already present in v3.2.0 and broke a gate that worked in v3.2.0. Net regression, so it goes. Kept from ca521cc: the lhs_flattened -> lhs_selected rename, and the two interaction tests. The disjunction test is now #[ignore]d with the reproduction and both failed fix attempts recorded -- `cargo test -- --ignored` still runs it, and it will pass when the underlying issue is addressed. Verified it still fails when run explicitly, so it is a live reproduction rather than a silenced assertion. Added a regression test for the shape that caught this. Every gate fixture I had spelled the condition inline, which is the one spelling that cannot expose the named-rule path. Verified the new test fails with ca521cc restored. Also adds run3.sh, replacing run.sh for future differentials. run.sh pointed OLD at target/release/cfn-guard -- an Aug-10 v3.2.0 build, i.e. a pre-aws-cloudformation#717 baseline -- so its OLD-vs-NEW delta attributed the whole of aws-cloudformation#717 to whichever commit was under test. That is why two rounds of my own testing read this regression as "unchanged". run3.sh reports v3.2.0, the real parent, and HEAD, and flags a parent-relative regression explicitly. Verified: 352 unit tests, 2 ignored; 456-pair corpus differential 0 differences; 23/23 matrix; the reviewer's five control fixtures all 19/19/19 across three baselines.
Fixing the path sanitisation in the previous commit took the validate target from 81 passed/15 failed to 95/1. The remaining failure, test_validate_with_failing_complex_rule, is real and was masked the whole time. Bisected to 5e83239, "Bind a literal rule argument as Literal, not Resolved": its parent passes, it fails. Not from PR aws-cloudformation#717 -- measured on that branch's head, which passes -- so it entered on feat/status-type-migration and every branch stacked on it inherited it. The behaviour change there is correct. For `rule r(replaced, expected) { %expected == %replaced }` called with a literal, recognising the literal moves the clause off the (None, None) diff arm onto the equality arm, which is what an `==` clause should do; the old path applied set difference and produced the wrong verdict for the case that commit fixed. What degraded is the report. The literal has no path, so the record's `from` carries Path=[L:0,C:0]: PropertyPath = [L:0,C:0] was /Resources/newServer/Properties/Arn <context lines> template 1-5 was template 7-12 The context window centres on the reported path, so a finding that used to show the offending `Arn:` line now shows the top of the file -- which is the part of the report an operator actually reads. I regenerated the fixture, read the diff, and reverted it. Updating it would make the suite green by ratifying the degradation and would destroy the only remaining signal that it happened. The failing test is the signal, and it now has the diagnosis attached at the site that causes it. The fix is to locate the record on whichever side has a real path when the other is a pathless literal, rather than unconditionally on the left -- `from:` in report_value. That changes every comparison report, so it needs its own change and its own fixture review rather than being folded in here. Comment-only. 330 lib tests, 0 failed, 1 ignored. validate 95/1.
…trix The mirrored negated cell -- `%literal != <query>` where the query selects nothing -- asserted SKIP. PR aws-cloudformation#717 settled that a comparison whose reference resolved to no values fails closed in either polarity, so it is now FAIL. This makes the matrix state a simpler rule than it did before. Emptiness of the subject excuses the clause and emptiness of the reference does not, uniformly across polarity, instead of the positive spelling disagreeing while both negated forms sat at SKIP. The doc comment loses its "confined to the positive spelling" half for the same reason, and gains a pointer to where the decision and its rejected alternative are recorded, so a reader who finds this cell surprising is not left to reconstruct the argument from the assertion alone. Landed as its own commit rather than folded into the commit that introduced the matrix: the change comes from review feedback that arrived after it, and the sequence is worth keeping legible.
`Tags == 'Owner'` against `Tags: []` reported the file as *compliant* -- not as not-applicable -- while the same rule against a missing `Tags` correctly failed. The weaker input was treated more leniently, and the JSON claimed a check had been performed that never compared anything. Cause: `selected`/`flattened` expand a list into its elements, so an empty list contributes none. The comparison loop pushes zero results and the enclosing fold reads an empty result vector as "nothing to check", reporting PASS. Record it per element rather than by testing the whole flattened left-hand side. The EqOperation `(None, Some(r))` arm uses `selected`, not `flattened`, so each query result is still one resource's value and the empty case can be attributed to it. A whole-LHS emptiness test is defeated by any sibling resource with a non-empty list -- the common shape in real templates -- which is how an earlier attempt at this fix passed every test written for it while doing nothing on multi-resource input. There is a regression test for exactly that cardinality. Resolved by role at the eval.rs boundary, mirroring EmptyRhsUnsatisfiable: FAIL as an assertion, no record at all as a gate. A gate must not fail here, because eval_rule treats a non-PASS condition as "rule does not apply" and drops the guarded body -- trading one unenforced clause for an entire disarmed block, at exit 0. Note the gate contributes *no* entry rather than a SKIP one: `statues` is a per-value PASS/FAIL vector whose consumers treat SKIP as unreachable (eval.rs:1353). Emitting SKIP there panicked with exit 101. Negated clauses opt out. This arm runs before the per-value inversion, so a FAIL raised here is unreachable by the `not`, and `not (Tags == 'Owner')` over nothing is vacuously true. Scope: EqOperation only. `IN` and the four ordering operators have the same wrong PASS but go through CommonOperator, which uses `flattened` and so has no per-result provenance to attach a guard to; fixing them means converting that path to `selected` first, which changes every list comparison and not just the empty ones. Left unfixed and documented rather than pattern-matched. Verified: 346 unit tests (4 new, and the two WP-1 tests fail with the guard disabled); 456-pair corpus differential 0 differences; 23/23 behaviour matrix; wrong-FAIL hunt shows no wrong PASS and no unattributed new FAIL -- the two it flags are PR aws-cloudformation#717's EmptyRhsUnsatisfiable (proven by a populated-LHS control also moving 0->19) and a `some` block with no satisfying resource (exit 0 once a satisfying sibling is added).
A template containing no matching resource at all -- no empty collection anywhere --
FAILs in one spelling and skips in the other:
%expected == Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags -> 19
Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags == %expected -> 0
Measured against a template holding only an SQS queue. docs/QUERY_AND_FILTERING.md:222
states the skip is intended: "A template contains resources but none match ... the
block level clauses will be skipped." So the mirrored spelling looks wrong.
Attribution, by execution rather than reading: exit 0 on pristine v3.2.0, exit 19 on
26b7184. It is a regression introduced by the EmptyRhs work in PR aws-cloudformation#717, not by the
empty-collection commits, and it matters because aws-cloudformation#717 is the PR awaiting review.
Cause: the `lhs.is_empty() -> Skip` rule is stated positionally, but zero selection is
a property of the query, not of the side it sits on. With a literal on the left, the
left side is never empty, so a zero-selecting right side falls through to EmptyRhs and
fails.
Not fixed, and the reason is a genuine conflict rather than effort. Skipping when the
left side is a literal was implemented and reverted: it also changes `%lit IN %empt`,
which `literal_lhs_against_empty_reference_fails_without_panicking` asserts must FAIL.
Both shapes are a literal left side against an empty query, so nothing available at
that level distinguishes "the resource type is absent from the template" from "the
reference resolved to nothing" -- that needs query provenance neither operand carries.
Choosing one would invert a deliberate assertion in the parent PR and change documented
empty-reference semantics, so it is recorded at the line that causes it instead of
guessed at.
No behaviour change in this commit: 350 unit tests, and the asymmetry above still
measures 19/0 as described.
…rule ca521cc turned a blocked violating template into a passing one: rule vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags != 'Owner' } rule body_bad when vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Name == 'privatebucket' } against a template with `Tags: []` and `Name: publicbucket`. Measured against the real parent a9c7f96: 19 -> 0, with both rules reported `not_applicable` and the violating resource never examined. That is verbatim the outcome ca521cc's own comment said the fix must not cause. Why the role guard did not cover it: `eval_when_clause` hardcodes ClauseRole::Gate, so the direct `rule r when <clause> {}` spelling is protected and measures 19/19. But eval_context.rs:1116 evaluates a *named* rule's body with ClauseRole::Assertion whatever the reference site is, so inside `vac_ne` the clause sees strict role, returns EmptyQueryResult(SKIP), and eval_rule reads the non-PASS condition as "does not apply". The status is then cached per rule name (eval_context.rs:1095), so the poisoned SKIP is reused by every later reference to that rule. ClauseRole carries the assertion/gate asymmetry at every syntactic site and cannot carry it across a named-rule boundary, because that boundary erases the reference context by construction. A real fix needs the reference-site role threaded into rule evaluation and the status cache keyed on (rule, role) rather than rule alone -- a change to the rule-evaluation contract, not to a comparator. The trade, measured across three baselines: ne_or (what ca521cc fixed) v3.2.0 0 parent 0 -> was 19 r4_namedgate (what ca521cc broke) v3.2.0 19 parent 19 -> was 0 ca521cc fixed a wrong PASS that was already present in v3.2.0 and broke a gate that worked in v3.2.0. Net regression, so it goes. Kept from ca521cc: the lhs_flattened -> lhs_selected rename, and the two interaction tests. The disjunction test is now #[ignore]d with the reproduction and both failed fix attempts recorded -- `cargo test -- --ignored` still runs it, and it will pass when the underlying issue is addressed. Verified it still fails when run explicitly, so it is a live reproduction rather than a silenced assertion. Added a regression test for the shape that caught this. Every gate fixture I had spelled the condition inline, which is the one spelling that cannot expose the named-rule path. Verified the new test fails with ca521cc restored. Also adds run3.sh, replacing run.sh for future differentials. run.sh pointed OLD at target/release/cfn-guard -- an Aug-10 v3.2.0 build, i.e. a pre-aws-cloudformation#717 baseline -- so its OLD-vs-NEW delta attributed the whole of aws-cloudformation#717 to whichever commit was under test. That is why two rounds of my own testing read this regression as "unchanged". run3.sh reports v3.2.0, the real parent, and HEAD, and flags a parent-relative regression explicitly. Verified: 352 unit tests, 2 ignored; 456-pair corpus differential 0 differences; 23/23 matrix; the reviewer's five control fixtures all 19/19/19 across three baselines.
Fixing the path sanitisation in the previous commit took the validate target from 81 passed/15 failed to 95/1. The remaining failure, test_validate_with_failing_complex_rule, is real and was masked the whole time. Bisected to 5e83239, "Bind a literal rule argument as Literal, not Resolved": its parent passes, it fails. Not from PR aws-cloudformation#717 -- measured on that branch's head, which passes -- so it entered on feat/status-type-migration and every branch stacked on it inherited it. The behaviour change there is correct. For `rule r(replaced, expected) { %expected == %replaced }` called with a literal, recognising the literal moves the clause off the (None, None) diff arm onto the equality arm, which is what an `==` clause should do; the old path applied set difference and produced the wrong verdict for the case that commit fixed. What degraded is the report. The literal has no path, so the record's `from` carries Path=[L:0,C:0]: PropertyPath = [L:0,C:0] was /Resources/newServer/Properties/Arn <context lines> template 1-5 was template 7-12 The context window centres on the reported path, so a finding that used to show the offending `Arn:` line now shows the top of the file -- which is the part of the report an operator actually reads. I regenerated the fixture, read the diff, and reverted it. Updating it would make the suite green by ratifying the degradation and would destroy the only remaining signal that it happened. The failing test is the signal, and it now has the diagnosis attached at the site that causes it. The fix is to locate the record on whichever side has a real path when the other is a pathless literal, rather than unconditionally on the left -- `from:` in report_value. That changes every comparison report, so it needs its own change and its own fixture review rather than being folded in here. Comment-only. 330 lib tests, 0 failed, 1 ignored. validate 95/1.
…trix The mirrored negated cell -- `%literal != <query>` where the query selects nothing -- asserted SKIP. PR aws-cloudformation#717 settled that a comparison whose reference resolved to no values fails closed in either polarity, so it is now FAIL. This makes the matrix state a simpler rule than it did before. Emptiness of the subject excuses the clause and emptiness of the reference does not, uniformly across polarity, instead of the positive spelling disagreeing while both negated forms sat at SKIP. The doc comment loses its "confined to the positive spelling" half for the same reason, and gains a pointer to where the decision and its rejected alternative are recorded, so a reader who finds this cell surprising is not left to reconstruct the argument from the assertion alone. Landed as its own commit rather than folded into the commit that introduced the matrix: the change comes from review feedback that arrived after it, and the sequence is worth keeping legible.
…trix The mirrored negated cell -- `%literal != <query>` where the query selects nothing -- asserted SKIP. PR aws-cloudformation#717 settled that a comparison whose reference resolved to no values fails closed in either polarity, so it is now FAIL. This makes the matrix state a simpler rule than it did before. Emptiness of the subject excuses the clause and emptiness of the reference does not, uniformly across polarity, instead of the positive spelling disagreeing while both negated forms sat at SKIP. The doc comment loses its "confined to the positive spelling" half for the same reason, and gains a pointer to where the decision and its rejected alternative are recorded, so a reader who finds this cell surprising is not left to reconstruct the argument from the assertion alone. Landed as its own commit rather than folded into the commit that introduced the matrix: the change comes from review feedback that arrived after it, and the sequence is worth keeping legible.
CmpOperator::compare treated an empty operand on either side as reason to
SKIP the clause. SKIP is non-failing and exits 0, so a rule whose reference
list resolved to nothing reported compliance for a template it should have
rejected -- e.g. `Property IN %approved` where %approved is derived from a
resource type absent from the template. Reported externally.
The two empty operands are different situations and get different answers.
An empty LHS means the query selected nothing, so the clause has nothing to
say about this input. That is genuinely inapplicable and is what lets one
ruleset run across templates that do not all contain the resource type being
checked. Unchanged, and checked first so it keeps precedence when both sides
are empty.
An empty RHS means there ARE values to check but no reference to check them
against. Polarity decides:
positive (==, IN) unsatisfiable -- nothing qualifies, so FAIL
negated (!=, NOT IN) vacuously true -- nothing to collide with, so
non-failing
Failing the negated case would reject compliant templates, since a denylist
is legitimately empty whenever a template contains none of the denied values.
The vacuous case is reported as SKIP rather than PASS. eval_conjunction_clauses
treats PASS as short-circuiting (`continue 'conjunction`) but SKIP as
absorbing (`=> {}`), so a vacuous PASS satisfies an entire `or` block and
abandons the sibling disjuncts unevaluated:
Encrypted != %empty_denylist or Encrypted == true
would pass an unencrypted resource because the first disjunct is vacuously
true and the real check never runs. Base failed that ruleset correctly and an
intermediate version of this change did not -- per-clause soundness is not
sufficient, the status has to compose correctly under the folds. SKIP keeps
the clause non-failing while leaving the decision to its siblings.
The verdict is also context-dependent. In a `when` condition the unsatisfiable
case stays a SKIP: a FAIL there makes the gate not-PASS, and eval_rule treats
a non-PASS condition as "rule does not apply" and skips the whole body --
silently disarming every check in the guarded block. Hence strict_empty_rhs on
binary_operation and eval_guard_access_clause.
The unsatisfiable case emits a status rather than per-value comparison
records: there is no RHS value to report against, and building `from:` out of
a raw lhs entry panics when the lhs is a QueryResult::Literal (a `let`
literal), which three reporters treat as unreachable inside a comparison.
Tests pin both polarities, the disjunction composition, the when-condition
case, and the literal-LHS case. Each was verified to fail without the fix.
The two previous commits threaded `strict_skip: bool` and
`strict_empty_rhs: bool` to distinguish a rule-body assertion from a `when`
condition. That encoding was unsafe in two ways, and both bit.
A boolean argument carries no meaning at the call site -- `f(gac, resolver,
true)` says nothing -- and, worse, a missing one is invisible. Adding an enum
parameter turns every unthreaded path into a compile error instead.
Doing that surfaced a wrong PASS that the boolean version hid:
eval_when_clause threaded the flag into its Clause and NamedRule arms but not
ParameterizedNamedRule, and everything downstream defaulted to assertion
strictness. So a parameterized rule invoked as a gate --
`rule x when some_gate("p") { ... }` -- evaluated its body strictly, FAILed,
made the gate not-PASS, and eval_rule then treated the rule as inapplicable
and skipped the guarded body entirely. A violating template exited 0 where
base exited 19. Four shapes reached it: a plain parameterized gate, one whose
gate body held an empty-reference comparison, one nested in a rule body, and
one on a type block.
Introduce ClauseRole { Assertion, Gate } and propagate it through
eval_guard_access_clause, eval_guard_named_clause, eval_guard_clause,
eval_when_clause, eval_guard_block_clause, eval_type_block_clause,
eval_rule_clause, eval_rule, and eval_parameterized_rule_call. The compiler
identified all twelve sites, including three filter-predicate call sites in
eval_context.rs that no review had flagged: a filter selects values, so it is
a test rather than an assertion and an unevaluatable clause makes it select
nothing.
Role assignment per context:
- rules-file top level, named-rule resolution, `when`-guarded bodies, and
rule bodies -> Assertion
- `when` conditions, including parameterized gates, and filter predicates
-> Gate
- block clauses, type blocks, and rule clauses inherit from their caller
Behavior is unchanged for every case the previous commits already covered; the
only difference is the parameterized-gate path, which now matches base.
The regression test for it initially passed against the reintroduced bug
because its inline template used indented YAML, which PathAwareValue rejects
with a ParseError that the harness surfaced as a failed Result rather than a
wrong status. Rewritten in the brace form the neighbouring tests use, it now
fails without the fix and passes with it.
The parser accepts a leading `not` on a parameterized invocation and stores it on the call's named_rule (parser.rs:1145-1155), but eval_parameterized_rule_call returned the invoked rule's status unchanged and never read that flag. `not r(...)` therefore behaved identically to `r(...)`. Same defect class as the dropped clause-level negation on binary comparisons, in the one arm that commit "Apply clause-level negation to binary comparisons" did not reach. Demonstrated by three fixtures: `not inner(...)` and `inner(...)` were outcome-identical at exit 0, while the non-parameterized `not inner` correctly exited 19 -- so the mechanism existed and only the parameterized arm bypassed it. Apply the negation after evaluating the invoked rule, mirroring eval_guard_named_clause so both spellings agree: PASS inverts to FAIL under negation, a SKIPped rule fails closed where the reference is an assertion (a rule that never ran is not evidence for a negated claim), and otherwise the negation flips the outcome. The SKIP arm is role-dependent for the same reason it is there -- failing a gate would disarm the block it guards. Also tightens three under-constrained assertions in the tests added by earlier commits. They used assert_ne! and so admitted both SKIP and FAIL, which is exactly the distinction that decides whether a guarded body ran: a SKIP and a PASS both exit 0. The exact statuses were measured, not guessed, and each assertion now says why that value is the right one. One test whose name claimed only panic-freedom while asserting a status was renamed to match. Reported as WP-2 by a status-algebra analysis of the fold behaviour; the regression test was verified to fail without the fix.
290602a to
60da014
Compare
The list held 48 cells under one heading, which read as 48 defects. It is 11 and 37, and the difference is not a nuance. The 11 contradict the specification. `docs/QUERY_AND_FILTERING.md` lists `Tags: []` beside a missing key and an empty map as retrieval errors and says all retrieval errors are failures -- measured, the other two do fail, so the empty-collection rows are the outlier. And `docs/CLAUSES.md` says a comparison across kinds that are not both numeric "cannot be decided, and the clause fails rather than guessing", which `!=` honours and `NOT IN` does not. The 37 conform. `docs/CLAUSES.md:203-225` states, with a worked example, that a condition which cannot be decided does not pass, that the rule is reported as not applicable, that the run exits 0, and that "the fix is in the rule or the input rather than in Guard". They are still the wrong answer and aws-cloudformation#720 changes it, but they are not defects against the document as it stands, and the oracle is stricter than the document rather than the code being wrong. Keeping them apart matters for the next reader in both directions. A new entry in `VIOLATES_THE_SPEC` is a regression; a new entry in `CONFORMS_TO_THE_SPEC` means the oracle and the document have drifted and one of them needs an argument. One flat list of 48 could not express either. The assertion still covers the union, so nothing became unpinned by the split.
`Tags == 'Owner'` against `Tags: []` reported the file as *compliant* -- not as not-applicable -- while the same rule against a missing `Tags` correctly failed. The weaker input was treated more leniently, and the JSON claimed a check had been performed that never compared anything. Cause: `selected`/`flattened` expand a list into its elements, so an empty list contributes none. The comparison loop pushes zero results and the enclosing fold reads an empty result vector as "nothing to check", reporting PASS. Record it per element rather than by testing the whole flattened left-hand side. The EqOperation `(None, Some(r))` arm uses `selected`, not `flattened`, so each query result is still one resource's value and the empty case can be attributed to it. A whole-LHS emptiness test is defeated by any sibling resource with a non-empty list -- the common shape in real templates -- which is how an earlier attempt at this fix passed every test written for it while doing nothing on multi-resource input. There is a regression test for exactly that cardinality. Resolved by role at the eval.rs boundary, mirroring EmptyRhsUnsatisfiable: FAIL as an assertion, no record at all as a gate. A gate must not fail here, because eval_rule treats a non-PASS condition as "rule does not apply" and drops the guarded body -- trading one unenforced clause for an entire disarmed block, at exit 0. Note the gate contributes *no* entry rather than a SKIP one: `statues` is a per-value PASS/FAIL vector whose consumers treat SKIP as unreachable (eval.rs:1353). Emitting SKIP there panicked with exit 101. Negated clauses opt out. This arm runs before the per-value inversion, so a FAIL raised here is unreachable by the `not`, and `not (Tags == 'Owner')` over nothing is vacuously true. Scope: EqOperation only. `IN` and the four ordering operators have the same wrong PASS but go through CommonOperator, which uses `flattened` and so has no per-result provenance to attach a guard to; fixing them means converting that path to `selected` first, which changes every list comparison and not just the empty ones. Left unfixed and documented rather than pattern-matched. Verified: 346 unit tests (4 new, and the two WP-1 tests fail with the guard disabled); 456-pair corpus differential 0 differences; 23/23 behaviour matrix; wrong-FAIL hunt shows no wrong PASS and no unattributed new FAIL -- the two it flags are PR aws-cloudformation#717's EmptyRhsUnsatisfiable (proven by a populated-LHS control also moving 0->19) and a `some` block with no satisfying resource (exit 0 once a satisfying sibling is added).
A template containing no matching resource at all -- no empty collection anywhere --
FAILs in one spelling and skips in the other:
%expected == Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags -> 19
Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags == %expected -> 0
Measured against a template holding only an SQS queue. docs/QUERY_AND_FILTERING.md:222
states the skip is intended: "A template contains resources but none match ... the
block level clauses will be skipped." So the mirrored spelling looks wrong.
Attribution, by execution rather than reading: exit 0 on pristine v3.2.0, exit 19 on
26b7184. It is a regression introduced by the EmptyRhs work in PR aws-cloudformation#717, not by the
empty-collection commits, and it matters because aws-cloudformation#717 is the PR awaiting review.
Cause: the `lhs.is_empty() -> Skip` rule is stated positionally, but zero selection is
a property of the query, not of the side it sits on. With a literal on the left, the
left side is never empty, so a zero-selecting right side falls through to EmptyRhs and
fails.
Not fixed, and the reason is a genuine conflict rather than effort. Skipping when the
left side is a literal was implemented and reverted: it also changes `%lit IN %empt`,
which `literal_lhs_against_empty_reference_fails_without_panicking` asserts must FAIL.
Both shapes are a literal left side against an empty query, so nothing available at
that level distinguishes "the resource type is absent from the template" from "the
reference resolved to nothing" -- that needs query provenance neither operand carries.
Choosing one would invert a deliberate assertion in the parent PR and change documented
empty-reference semantics, so it is recorded at the line that causes it instead of
guessed at.
No behaviour change in this commit: 350 unit tests, and the asymmetry above still
measures 19/0 as described.
…rule ca521cc turned a blocked violating template into a passing one: rule vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Tags != 'Owner' } rule body_bad when vac_ne { Resources.*[ Type == 'AWS::S3::Bucket' ].Properties.Name == 'privatebucket' } against a template with `Tags: []` and `Name: publicbucket`. Measured against the real parent a9c7f96: 19 -> 0, with both rules reported `not_applicable` and the violating resource never examined. That is verbatim the outcome ca521cc's own comment said the fix must not cause. Why the role guard did not cover it: `eval_when_clause` hardcodes ClauseRole::Gate, so the direct `rule r when <clause> {}` spelling is protected and measures 19/19. But eval_context.rs:1116 evaluates a *named* rule's body with ClauseRole::Assertion whatever the reference site is, so inside `vac_ne` the clause sees strict role, returns EmptyQueryResult(SKIP), and eval_rule reads the non-PASS condition as "does not apply". The status is then cached per rule name (eval_context.rs:1095), so the poisoned SKIP is reused by every later reference to that rule. ClauseRole carries the assertion/gate asymmetry at every syntactic site and cannot carry it across a named-rule boundary, because that boundary erases the reference context by construction. A real fix needs the reference-site role threaded into rule evaluation and the status cache keyed on (rule, role) rather than rule alone -- a change to the rule-evaluation contract, not to a comparator. The trade, measured across three baselines: ne_or (what ca521cc fixed) v3.2.0 0 parent 0 -> was 19 r4_namedgate (what ca521cc broke) v3.2.0 19 parent 19 -> was 0 ca521cc fixed a wrong PASS that was already present in v3.2.0 and broke a gate that worked in v3.2.0. Net regression, so it goes. Kept from ca521cc: the lhs_flattened -> lhs_selected rename, and the two interaction tests. The disjunction test is now #[ignore]d with the reproduction and both failed fix attempts recorded -- `cargo test -- --ignored` still runs it, and it will pass when the underlying issue is addressed. Verified it still fails when run explicitly, so it is a live reproduction rather than a silenced assertion. Added a regression test for the shape that caught this. Every gate fixture I had spelled the condition inline, which is the one spelling that cannot expose the named-rule path. Verified the new test fails with ca521cc restored. Also adds run3.sh, replacing run.sh for future differentials. run.sh pointed OLD at target/release/cfn-guard -- an Aug-10 v3.2.0 build, i.e. a pre-aws-cloudformation#717 baseline -- so its OLD-vs-NEW delta attributed the whole of aws-cloudformation#717 to whichever commit was under test. That is why two rounds of my own testing read this regression as "unchanged". run3.sh reports v3.2.0, the real parent, and HEAD, and flags a parent-relative regression explicitly. Verified: 352 unit tests, 2 ignored; 456-pair corpus differential 0 differences; 23/23 matrix; the reviewer's five control fixtures all 19/19/19 across three baselines.
…trix The mirrored negated cell -- `%literal != <query>` where the query selects nothing -- asserted SKIP. PR aws-cloudformation#717 settled that a comparison whose reference resolved to no values fails closed in either polarity, so it is now FAIL. This makes the matrix state a simpler rule than it did before. Emptiness of the subject excuses the clause and emptiness of the reference does not, uniformly across polarity, instead of the positive spelling disagreeing while both negated forms sat at SKIP. The doc comment loses its "confined to the positive spelling" half for the same reason, and gains a pointer to where the decision and its rejected alternative are recorded, so a reader who finds this cell surprising is not left to reconstruct the argument from the assertion alone. Landed as its own commit rather than folded into the commit that introduced the matrix: the change comes from review feedback that arrived after it, and the sequence is worth keeping legible.
Three things the rebase onto `2b53c97` had to settle, all of them cases where taking this
branch's version wholesale was measurably wrong.
**The unevaluatable-clause arm keeps its role split.** Carrying `Outcome::Unevaluatable` for
both roles reads better and reintroduces the defect it was meant to remove:
`to_status(Gate)` maps it to SKIP, `eval_rule` maps every non-PASS condition to a rule-level
SKIP, so `rule r when Enabled !EMPTY { MustBeTrue == true }` exits 0 with the violation
inside it unreported. That is the case a reviewer found against aws-cloudformation#717 and `4c8c650` fixed, and
it came straight back when this arm was resolved in this branch's favour. Measured, not
reasoned: the repro exits 0 before the correction and 19 after.
An assertion is expressed as `Outcome::Unevaluatable` so the lattice applies the role once. A
condition still travels as an error, which the three condition sites catch. Wiring
`Outcome::closes_gate` into those sites would make the arm uniform and is the obvious next
step here -- the vocabulary already exists on this branch and only its tests use it.
**The vacuous-comparison deprecation notice is removed, because this branch is the release it
warned about.** `e9b143c` makes a comparison against an empty collection report a failure in
its plain polarity, so a notice saying the answer changes later has outlived its own change. A
warning that survives the thing it warned about is how warnings become noise. The membership
notice stays: `NOT IN` on an operand comparable with no element still passes here.
Worth stating precisely, because the notice's wording only half fits what this branch does.
Plain polarity now fails; the negated polarity answers SKIP, because an empty fold returns
`Outcome::identity()`, which is `NotApplicable`. That is an improvement on passing vacuously
and still not the failure `docs/QUERY_AND_FILTERING.md` asks for, which lists `Tags: []` beside
a missing key and an empty map and says all retrieval errors are failures.
**The oracle's two lists move by four and one.** Three spec violations leave -- the plain
polarity of the empty-collection comparison -- and `in_list/empty_list/not/gate` leaves the
conformant list. One joins: `in_list/empty_list/not/assert` answered FAIL on the parent branch
and answers SKIP here, so it is the single entry this branch makes worse rather than better.
The lists are regenerated from measurement rather than edited by hand, and the note above them
records the movement so a reader is not left comparing bare counts.
`SITES_EXPECTED` goes to 19: the `NotComparable` arm records `Some(nc.reason)` on a
`ClauseValueCheck`, a variant that already renders. The parent branch is at 18 rather than the
17 the previous note claimed.
948 tests, `cargo clippy -- -D warnings` and `--all-targets` clean, fmt and typos clean, and
190 of 190 aws-guard-rules-registry rule/test pairs unchanged.
96ca6ed to
9e2e6c2
Compare
The table held two answers: the clause has one, or it has none and fails closed. An empty
collection fits neither, and treating it as "no answer" mislabelled cells in both directions.
Emptiness is a fact, not an undecidable one. `no element == 50` over zero elements is vacuously
true, which is why failing it rejects a compliant template -- the argument a reviewer made
against an earlier attempt to force both polarities to fail. But vacuous truth is not an
affirmative pass either, because the clause examined nothing.
`Answer::Vacuous` splits the two positions rather than the two polarities, which is where the
first attempt at this went wrong:
- As an assertion, the positive form is a claim over nothing, which
`docs/QUERY_AND_FILTERING.md` calls a retrieval error and therefore a failure, and the
negated form asserts nothing and reports neither pass nor fail.
- As a gate, both readings converge on failing the rule. Vacuous truth opens the gate and the
always-violated body decides; the retrieval-error reading fails it closed. SKIP is the one
answer that is wrong, because that is the outcome which drops the guarded body and exits 0.
Measured rather than assumed: `--show-summary all` on the negated empty-list gate reports the
body clause as the failure, not the condition, so the gate does open. That also means these
gate cells are weak evidence -- both branches answer FAIL there for different reasons -- and
the table says so where a reader will meet it.
`Vacuous` stays distinct from `Unanswerable`, because a type mismatch has no answer in *either*
polarity while an empty collection has a different answer in each. Collapsing them is what
produced the wrong expectation.
Disagreements go from 48 to 49 and violations from 11 to 12. That is the correction rather than
a regression: this branch answers PASS for every empty-collection assertion, and two of those
cells previously scored as agreeing because the oracle was wrong in the same direction as the
code.
9e2e6c2 to
d4fea74
Compare
`cfn-guard test` listed the rules inside a result group in `HashMap` iteration order. That order comes from `RandomState`, reseeded per process, so consecutive runs of the same command over the same files printed the same rules in a different sequence. Ten runs of the fixture added here produced ten different reports before this change. The cost is not cosmetic. Anything diffing two reports -- a CI job comparing against a checked-in baseline, a reviewer reading two runs side by side -- saw churn that was not there, and a golden-file test covering more than one rule in a group could not be written at all. Which is why this survived: every fixture in `resources/test-command` has exactly one rule per group, and with one entry hash order and sorted order are the same. It also made the registry differential for this branch report ten rule files as changing their output when nothing had changed; both binaries were simply unstable. `get_by_rules` now returns a `BTreeMap`, which fixes all three output formats at once because both reporters read that one map: the generic reporter prints the group directly and the structured reporter fills `passed_rules` in iteration order, which JSON, YAML and JUnit consumers see. Sorting at each print site instead would have needed three changes and left the next reporter to rediscover it. The generic reporter already sorted its PASS/FAIL headings, with a comment saying so. This is the layer underneath the one that was sorted. The fixture is the substance of the test: five rules whose declared order is not alphabetical, listed in one group, over two cases. Verified live -- reverting the map type produces ten distinct outputs in ten runs, so the golden files fail on all but roughly one run in 14,400.
`validate` printed the notices this branch added and `test` did not, which is the wrong way round. A notice saying a clause's answer changes in a later release is addressed to whoever wrote the clause, and the command they run is `test`. The operator running `validate` in a pipeline is usually not the person who can act on it and often is not reading stderr at all. So the warn-a-release-ahead approach was invisible to its audience, which is most of its value. Found while diffing the branch against the merge-base over the rules registry: not one of the 190 rule files reported a notice, and the reason turned out to be the command rather than the rules. Both test reporters now collect them, so all four output formats carry them. Stderr, as in `validate`, because stdout is the report and `--output-format json` is parsed; the test asserts that the JSON on stdout still deserializes with a notice present. Collapsed into a set before writing. A rule file is evaluated once per test case, so the naive version prints the same notice again for every case: the fixture here has three cases over two clauses and would have emitted six notices. The directory report keeps one set across every file, so two rule files with the same hazard say it once. The rules fixture is shared with the validate-side notice test rather than copied, so there is one description of the hazard to keep current.
An expectation whose rule name does not appear in the rules file was dropped without a word. Expectations are read per evaluated rule, so one with no rule to attach to is never consulted, and the run exits 0 having checked less than the file asked for. Drop the final D from `S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED` and that expectation stops running; nothing says so, and the suite still passes. The fixture makes the shape unmistakable: two expectations assert FAIL on names that do not exist, against a template where the real rule passes. If either were checked the run would fail. It exits 0. This is the same defect as the ones this branch fixes in the evaluator -- a check that silently does not happen -- one layer out, in the harness rule authors use to trust their rules. Found while adding the fixtures for the deprecation notices, when an expectation left over from the parent branch's version of a fixture went unnoticed because nothing reported it. Reported, not enforced. Making it a failure would break suites that pass today, and the useful half is knowing. The reporters already print the mirror case, `No Test expectation was set for Rule`, so this fills in the other direction. Both stderr writers are now one mechanism rather than two: `Diagnostics` is the set of lines a run sends to stderr instead of into the report, holding deprecation notices and these messages together, because from a consumer's side both are problems with the input rather than results of running it. Deduplicated for the same reason as before -- a rule file is evaluated once per test case, so two cases would otherwise say everything twice.
Four comments cited hashes from earlier revisions of this branch. Rebasing rewrote them, so a reader following `3f8466e` or `0e140b3` gets nothing, and the citation is worse than no citation because it looks checkable. Hashes on an open branch are not stable identifiers. Every rebase invalidates every one of them, and the failure is silent: nothing in a build or a test suite reads a comment. The stacked PR already moved from line numbers to symbol names for the same reason; this is the same problem with a different identifier. So the references are to what changed rather than to the commit that changed it -- "the two clause-level negation commits", "an earlier commit on this branch" -- which stays true through a rebase and is what the reader wanted anyway. Checked by resolving every backtick-quoted hex string in `guard/**/*.rs` against this branch. The one remaining match is `2147483648` in a comment about the `i32` boundary, which is a number.
The rule-level failure line read "Parameterized Rule <name> failed". At the merge-base that was accurate, because the only failures carried on the rule rather than on a clause comparison came from parameterized rule calls. This branch gives ordinary rules a rule-level message -- a condition that could not be evaluated -- so ordinary rules now reach that arm and were being announced as something they are not. "Rule <name> failed" is correct for both. A parameterized rule is still a rule, so this is not a trade between the two cases; `NameInfo` carries no flag that would let the reporter distinguish them there, and adding one to preserve a word is not worth the plumbing. Found by reading the output of the reviewer's own repro while writing the reply that quotes it. The message was one this branch added, the label was the part I had not looked at, and it is the third time on this branch that a diagnostic said something false -- which is why the regression test now asserts the absence of the word rather than only the presence of the reason. No fixture asserted the old string, so nothing else changes.
|
@Zee2413 all four were real, and all four are fixed. Measured at the current head, Boolean
|
| Clause | base 57bbdbf |
head 99ffeff |
|---|---|---|
Items[4294967295] == "other" |
exit 0 | exit 19 |
Items[4294967296] == "safe" |
exit 0 | exit 19 |
Items[4294967297] == "other" |
exit 0 | exit 19 |
Items[9223372036854775807] == "other" |
exit 0 | exit 19 |
Items[-4294967296] == "safe" |
exit 0 | exit 19 |
An index that cannot be represented now names no element rather than wrapping onto one, so the clause fails instead of comparing something the author never asked about.
The indexed-interpolation path you asked about is covered too:
let names = Resources.Pointer.Names.*
rule r { Resources.%names[<index>].Properties.Name == "a" }
Every oversized index there exits 19 on this branch. That path also had a second defect, unrelated to yours and found separately: the index was applied twice, once to select which resolved key to use and again to the value that key had selected, so %names[0] exited 19 against input where it should pass. It now exits 0.
Range equality
Fixed in b5153de. a_range_is_equal_to_itself covers all three range variants rather than the two in your report, because the defect was the missing arm rather than anything about a particular type: RangeInt, RangeFloat with positive and with negative bounds, and RangeChar. Each asserts both == and hash equality, so the Eq and Hash contracts are covered together.
Two adjacent tests keep the surrounding behaviour pinned: equality_is_symmetric_for_ranges, for the symmetry hole whose fix opened this reflexivity one, and mixed_numeric_range_membership_is_decided, since membership across integer and float is where the arms are consulted from.
JUnit skip reasons
Fixed in b5153de as well. The reasons are joined with an explicit delimiter, and junit_keeps_multiple_skip_reasons_separate splits the <skipped> body on it and asserts two reasons rather than checking that both rule names appear somewhere. That distinction matters for exactly the reason you gave: an assertion that both names are present passes on the concatenated string too. Your note about some evaluator reasons ending in whitespace is recorded in the test, because it explains why the CLI fixtures did not catch it.
Also since b116f48
Six more commits. The first three came out of verifying the rest against the rules registry rather than from reading code, which is the pattern I would keep:
d9de29c—cfn-guard testlisted rule names inHashMaporder, so ten consecutive runs of the same command over the same files produced ten different reports. Found because the registry differential reported ten rule files as changing their output when nothing had changed; both binaries were simply unstable. All four output formats now agree run to run.78f1f11— the deprecation notices this branch adds reachedvalidatebut nottest, which is backwards: the notice is addressed to whoever wrote the rule, andtestis the command they run. Not one of the 190 registry rule files reported a notice before this.90066da— an expectation naming a rule the file does not contain was dropped in silence.cfn_no_explicit_resource_names_tests.ymlin the registry has 11 such expectations, none of which name a rule that exists anywhere in the registry, andcfn-guard testexits 0 on it.d4fea74— corrects the oracle's expectation for an empty collection, which had one answer where it needs two: a claim over nothing is a failure, and the negated form is vacuously true and asserts nothing.99ffeff— the rule-level failure line announced every such failure as a "Parameterized Rule". That was accurate at the merge-base, where the only failures carried on the rule rather than on a clause comparison came from parameterized calls; it stopped being accurate once this branch gave ordinary rules a rule-level message. I found it reading the output of your own repro while writing this reply, which makes it the third diagnostic on this branch that said something false, so the regression test now asserts the absence of the word as well as the presence of the reason.576dd22on Give the evaluator a value that can say "nothing to compare", and fix the empty-collection wrong passes #720 andefeed83here — comments only. 53 em dashes in a crate whose merge-base has no non-ASCII byte, and four commit hashes that rebasing had already invalidated.
The registry differential itself: 190 rule-file/test-file pairs, 1,956 expectation checks, 0 exit-code changes and 0 content changes against the merge-base. Five rule files now print the NOT IN deprecation notice, which is the one behaviour change on this branch with demonstrated real-world exposure and the reason that change is deferred rather than made here.
Verification at 99ffeff: 818 tests passing, clippy -- -D warnings and clippy --all-targets -- -D warnings clean, fmt --check clean, typos clean.
@satyakigh the formal CHANGES_REQUESTED is still yours, from the review whose body reads "Oops". Your substantive finding, the boolean EMPTY abort, is fixed by the second of the two options you offered: incompatible-type errors are clause-local, with the multi-rule CLI regression test. If you still think that clause-local change belongs in its own PR, say so and I will split it out; otherwise a re-review when you have time would unblock this.
An undecidable clause inside a `when` block, in the body of a rule used as another rule's gate, made
the outer rule report itself not applicable and exit 0 with the guarded violation unchecked:
rule inner_gate(unused) {
when Enabled !EMPTY { Enabled == true }
}
rule guarded when inner_gate("x") {
MustBeTrue == true
}
Measured against `unevaluatable-gate-template.yaml`, where `Enabled` is a boolean so `!EMPTY` has no
answer and `MustBeTrue` is false:
merge-base exit 19 the gate opened, because `!EMPTY` on a boolean was unconditionally true
this branch exit 0 reported not applicable, `MustBeTrue` never checked
with fix exit 19 the rule fails closed on its condition
Reported by a reviewer, and their diagnosis named the mechanism exactly: `eval_when_condition_block`
converted the undecidable answer to `Status::FAIL`, and one level out a FAIL on a condition is
indistinguishable from a condition that was decided and did not match, which `eval_rule` maps to a
rule-level SKIP. FAIL means both "the condition is false" and "the condition could not be evaluated",
so converting the second one early loses what the outer rule needs.
The fix splits that arm by role, which is what the arm in `unary_operation` already does. An
assertion still answers FAIL: the block fails, the rule fails, and every other rule in the file still
reports -- that is what keeps one undecidable clause from aborting the run, which is the defect the
same reviewer found earlier. A gate keeps the error, so the enclosing condition site fails its own
rule closed rather than deciding the rule does not apply.
Why the existing regression test did not catch it: `an_unevaluatable_gate_fails_the_rule_closed`
covers the direct form, where the rule's own condition is the undecidable clause. That path was
closed. This one crosses a parameterized-rule boundary and a nested `when`, so the answer passes
through two conversion sites instead of one, and only the outer site was asking.
`an_undecidable_nested_gate_does_not_silence_the_outer_rule` covers it, and asserts the rule is named
as failing and that nothing is reported as skipped.
Found by a composition sweep rather than by reading: hold the undecidable clause fixed, vary where it
sits relative to the guard, and assert that a body which fails on its own is never silenced. The first
two attempts at that sweep did not reproduce it, because `%prop !EMPTY` on a parameter is not
undecidable at all -- which is a separate defect, and the subject of the next commit.
Verification: 819 tests, clippy clean on all targets, fmt and typos clean. The 440-cell matrix moves
zero cells, and the registry differential is unchanged at 190 rule files, 1,956 expectation checks,
zero exit-code changes and zero content changes -- so no real rule relies on the silenced shape.
One gap remains and is not fixed here: the report names the rule as failing without saying why. The
rule-level message recorded for this path does not reach the console for this shape, which is the
same class as the explanations rendered in `de431f7`. It needs the reporter walk, not the evaluator.
`EMPTY` on a lone variable was answered by `res.is_null()`. Null is empty, but so are other things,
and nothing else was consulted. Measured against a template with `Tags: []`, `Name: ""`,
`Enabled: true`, `Size: 50`:
%tags !EMPTY passed the list IS empty
%tags EMPTY failed the list IS empty
%name !EMPTY passed the string IS empty
%flag !EMPTY passed a boolean has no emptiness; it could not fail for any input
%size !EMPTY passed likewise for a number
Both polarities wrong on a value whose emptiness is the entire question, and two of them are the
silent always-pass that `ff7205d` removed for the direct path. That commit looked complete because
every test and every cell of the operator matrix reaches this operator through a direct query; a `let`
binding and a rule parameter both take this shortcut instead, and neither was covered.
`element_empty_operation` is the question the operator asks, and it is the same function the direct
path already uses, so a boolean now fails closed with the diagnostic naming the path rather than
passing in silence.
Two boundaries matter here, and getting the first one wrong broke a test that then explained the
second:
The shortcut has to stay. It is what makes the selection idiom work: for `%vols !empty` an empty
selection resolves to zero values and is answered before the per-value loop. Deleting the arm sends
that case to a `Status::SKIP` further down, which would turn the most common gate in the registry from
a failure into a silent skip.
A query ending in a filter keeps resolution semantics. `Condition[ keys == 'aws:IsSecure' ] !empty`
means "that key is present", and the value behind the key may be a boolean, which has no emptiness of
its own. The first version of this change applied value semantics to both and failed
`block_evaluation`, an upstream test that asserts exactly that reading. So `empty_of` takes the
question as an argument and the two callers are named: `lone_variable` asks about the value,
`selection_empty` asks whether anything resolved.
An undecidable value takes the same role split as the rest of the function: an assertion fails closed
here, a gate keeps the error so the enclosing condition fails its own rule closed instead of reading
it as a condition that did not match.
Verification: 821 tests, clippy clean on all targets, fmt and typos clean. The registry differential is
unchanged -- 190 rule files, 1,956 expectation checks, zero exit-code changes, zero content changes --
so no published rule depends on the wrong answer and this needs no deprecation notice, unlike the
`NOT IN` change. `empty_on_a_lone_variable_asks_about_the_value` pins all ten cases, the six that were
wrong and the four idioms that must not move.
…ler's
`rule_status` evaluated a named rule once, always as an assertion, and cached the result under the
rule's name alone. Every later reference read that answer whatever it had asked. So a `when` condition
referencing a rule got a verdict computed for a different question.
The visible cost, measured against `unevaluatable-gate-template.yaml`:
rule inner_gate {
when Enabled !EMPTY { Enabled == true }
}
rule guarded when inner_gate {
MustBeTrue == true
}
merge-base guarded FAIL
this branch guarded SKIP, `MustBeTrue` never checked
`inner_gate` is also a top-level rule, so it was evaluated as an assertion first, its undecidable
condition failed it, and FAIL went into the cache. The gate reference then read FAIL, `eval_rule` read
a non-PASS condition as "the rule does not apply", and the guarded check was dropped. Keyed on
`(rule, role)`, the reference re-evaluates with gate semantics, the undecidable answer reaches the
enclosing condition as an error rather than as a status, and the rule fails closed.
This is the third site on this branch where the same conflation cost a verdict: the per-value arm, the
nested `when`, and now the cache. FAIL means both "the condition is false" and "the condition could not
be evaluated", and each site that converts early loses what its caller needed.
Why the exit code could not catch it, which is the part worth keeping: `inner_gate` fails on its own as
a top-level rule, so the file exits 19 whether or not `guarded` runs. A sweep that asserts "the body
fails, so the file must not exit 0" passes here while the guarded rule is silently dropped. The test
asserts on `guarded` itself, and the parameterized spelling in
`an_undecidable_nested_gate_does_not_silence_the_outer_rule` is the case where the exit code does move,
because a parameterized rule cannot be evaluated standalone and so cannot mask it.
Cherry-picked from the stacked PR, where it was written for a different symptom in the same family, with
the tests that depend on that branch's empty-collection semantics left behind and the message rewritten
for what it does here.
Verification: 822 tests, clippy clean on all targets, fmt and typos clean. Registry differential
unchanged at 190 rule files, 1,956 expectation checks, zero exit-code changes and zero content changes,
so no published rule depended on a reference reading another reference's answer.
A rule that failed on its own condition printed no reason in the console. The run exited 19, the
summary named the rule, and the body of the report said "Number of non-compliant resources 0" with
nothing after it.
The explanation was never lost: the evaluator records it on the rule's `RuleCheck`, and
`--output-format json` has always printed it in full. Only the console dropped it, and the reason is
structural. That output is organised by resource, because a violation normally points at a value in
the input, and a rule that failed on its condition points at no value and produces no clause findings
underneath itself. `collect_unattributed_explanations` already existed for the clause-level version of
this -- a comparison that failed with nothing to compare -- and walked past the rule-level one, because
it only matched `Block` reports and recursed into a rule's `checks`, which for this shape are empty.
So `checks.is_empty()` is the discriminator, and it is the right one: a rule whose clauses did produce
findings has them rendered per resource already, and printing the rule-level message beside them would
duplicate rather than explain.
Both shapes now account for themselves:
Could not be evaluated:
rule guarded
The rule's condition could not be evaluated, so the rule fails rather than being treated as
not applicable: Attempting EMPTY operation on type bool ... at /Resources/Vol/Properties/Enabled
The heading is "Could not be evaluated" rather than "Clauses that could not be evaluated", since an
entry can now be a rule.
The two regression tests for the undecidable-gate shapes now assert the reason as well as the verdict.
They passed on the verdict alone while the console said nothing, which is the same gap one level out:
a test that checks only the exit code cannot tell a diagnosed failure from a bare one.
Verification: 822 tests, clippy clean on all targets, fmt and typos clean.
Every defect in this family that escaped the 252-cell corpus escaped the same way. That corpus asserts
the right invariant -- an unanswerable gate does not disarm its body -- over the wrong axis: it varies
the operator and the operand, and wraps exactly one clause in exactly one gate. The evaluator has
several places that turn an undecidable answer into a status, and composition is what makes the loss
reachable, because the answer then crosses more than one of them.
So this sweeps two axes the matrix holds fixed:
shape where the clause sits relative to the guard: the gate's own condition, a `when` nested in
the gate rule's body, a named-rule reference, a parameterized call
binding how the value reaches the clause: a direct query or a `let` variable, which takes a
different path through `unary_operation`
Eighteen cells, each with a control. The control replaces the guard with a decidable-true clause and
asserts the body still fails, because a fixture that silences the body for an unrelated reason
otherwise reads as a passing invariant -- half the cells in the first version of this sweep were dead
that way and I only noticed by printing the controls.
The assertion is per rule rather than per file, and that distinction is the sweep's main lesson. A rule
referenced as a gate is usually also a top-level rule, so its own failure exits the file 19 whether or
not the rule it gates ever ran. `named_gate_nested_when` was silently dropping its guarded check while
the file exited 19 the whole time, and an exit-code invariant would have called that healthy.
Only clauses with no answer in either polarity are swept. An empty reference used as a gate is a
different thing: `when %vols !empty` means "no such resources, so this rule does not apply", which is
the documented idiom and correctly skips. Mixing the two would make this invariant assert that a
legitimate non-match is a defect.
It found one immediately, in the fix from three commits ago: the lone-variable `EMPTY` arm returned an
error for a gate after `start_record` and before `end_record`, so the recorder was left unbalanced and
`extract` failed with "context start and end does not match", taking the run with it. The strict path
never returned early, so the imbalance arrived with that arm and no existing test exercised it as a
gate. Both paths close the record now.
Verified live: reverting the role split in `eval_when_condition_block` fails the sweep naming the cell,
`direct bool in named_gate_nested_when`.
824 tests, clippy clean on all targets, fmt and typos clean, registry differential unchanged at 190
rule files and 1,956 expectation checks with zero exit-code and zero content changes.
|
The nested-gate finding is real, and the diagnosis was exact. Fixed, along with two more instances of the same conflation that came out of chasing it. Measured at The findingI could not reproduce it twice before I could, and the failed attempts are worth one line each because they say something about the shape. My first repro put the undecidable clause behind a rule parameter ( With the clause on a direct path inside a
The fix splits that arm by role, which is what the arm in Two more of the same, found by sweeping rather than readingThe named-rule spelling was worse, and the exit code hid it. What makes this one instructive is why it stayed hidden.
Both polarities wrong on a value whose emptiness is the entire question, and two of them are the silent always-pass the boolean fix removed for the direct path. A The shortcut itself has to stay, and that boundary took two attempts: an empty selection resolves to zero values and is answered before the per-value loop, so removing the arm would turn The reporting halfThe fixed shapes exited 19 and said nothing: Both regression tests assert the reason as well as the verdict. They passed on the verdict alone while the console said nothing, which is the same gap one level out. What generalisesThe 252-cell corpus asserts the right invariant and could not catch any of this, because it varies the operator and the operand and wraps exactly one clause in exactly one gate. Every escape in this family composed instead: the answer crossed more than one conversion site, and each site that converted early lost what the next one needed. There were three such sites on this branch. So there is now a sweep over two axes the matrix holds fixed — where the clause sits relative to the guard (gate condition, nested It found a defect in my own fix within a minute of existing: the lone-variable arm returned an error for a gate between Verification824 tests, The stacked PR carries the same three fixes, and its own instance of the last one: its cache was keyed on |
`Resources.R.Properties.Size == 1e5` did not fail to parse. It split. `1` became the integer, the
leftover `e5` became a bare identifier, and a bare identifier is a valid clause -- a reference to a
rule by that name. So the rule was `Size == 1` *and* a reference to `e5`.
With no rule of that name the run dies with "Rule e5 by that name does not exist", which at least says
something is wrong. With one, it evaluates cleanly and checks the wrong number:
rule e5 { Resources.R.Properties.Size EXISTS }
rule threshold { Resources.R.Properties.Size == 1e5 }
Against `Size: 1` that reports PASS at exit 0, on v3.2.0 and on this branch before this commit. A
policy demanding 100000 accepts 1, because `Size == 1` holds and `e5` passes.
Two causes, both in the shape test at the top of `parse_float`. That test decides whether the text is
float-shaped so a bare integer can fall through to `parse_int_value`, and it accepted less than a float
can be:
- The exponent sign was mandatory, so `1e5` was not float-shaped. `parse_int_value` then took the `1`
and left `e5` behind. This is the silent case above.
- There was no leading sign, so `-1.5` was not float-shaped either. `parse_int_value` took `-1` and
left `.5`, and the clause failed to parse -- negative float thresholds could not be written at all,
in a comparison, a list or a range. That one at least failed loudly.
`double` already handled both; only the gate in front of it was narrow.
The split itself is the deeper problem, so `reject_trailing_identifier` closes it: a numeric literal may
not be followed directly by a letter, digit or underscore. Whitespace still separates clauses, so
`Size == 1 other_rule` is untouched; what is rejected is a digit running into a letter, which is never
two clauses. `1x` and `2abc` are parse errors now instead of a value plus a rule reference.
That guard changed one existing expectation, and the payload says why:
`test_with_payload_failing_type_block` feeds the fuzzed rule `m<0m<03333333`, where `0` runs into `m`.
It used to parse as `m < 0` and `m < 03333333` and evaluate to a violation; it is a parse error now.
The test's stated intent is that fuzzed garbage must not be quietly accepted, and rejecting it at parse
time serves that better than evaluating it -- both exit non-zero, and only one of them pretends the
rule was understood.
Verification: 826 tests, clippy clean on all targets, fmt and typos clean. The registry differential is
unchanged at 190 rule files and 1,956 expectation checks with zero exit-code and zero content changes,
and no published registry rule contains a digit running into a letter, so nothing depended on the split.
`substring` checked its bounds against `val.len()`, which counts bytes, then sliced with those indices.
The slice panics unless both ends land on a character boundary, so any string that is not ASCII could
take the process down:
let s = substring(Resources.R.Properties.Name, 0, 3) # Name: "naïve-café"
thread 'main' panicked at nom-7.1.3/src/traits.rs:1047:
byte index 3 is not a char boundary; it is inside 'ï' (bytes 2..4) of `naïve-café`
exit 101
Byte 3 is inside the two bytes of `ï`. Not a clean error and not a failed rule: a Rust panic with a
stack trace and exit 101, which in CI reads as the tool breaking rather than the policy failing. The
bounds check gave no protection because it was counting a different thing from what the slice indexes.
Characters, rather than a boundary check that returns nothing, because `docs/FUNCTIONS.md` calls these a
"starting index" and an "ending index" into a string, and an author counting a prefix counts characters.
For ASCII -- the ARNs and resource names these are written against, including the worked example in that
document -- the two readings are identical, so no working rule changes. What changes is that the rules
which are not ASCII stop crashing.
Out-of-range, inverted and degenerate ranges keep answering with no value, as they did, now measured in
characters: `naïve` is five characters and six bytes, so `substring(x, 0, 6)` is out of range where it
used to be a valid byte range that happened to land mid-character.
Found by probing the string functions for byte-index slicing rather than by reading; `substring` is the
only site in `guard/src/rules/functions/` that sliced by index, so this is the whole class.
Verification: 828 tests, clippy clean on all targets, fmt and typos clean. Registry differential
unchanged at 190 rule files and 1,956 expectation checks, zero exit-code and zero content changes.
`substring_counts_characters_and_does_not_panic` covers eleven cases, including the two that panicked
and the CJK ones where every index is multi-byte.
`parse_int` on a value it cannot convert aborted the run. Exit 255, an "Error occurred" line, and every
other rule's verdict discarded -- including real violations an unrelated rule had already found:
rule CANARY { Resources.R.Properties.Junk == "this-will-not-match" }
rule USES_PARSE_INT {
let n = parse_int(Resources.R.Properties.Junk) # Junk: "abc"
%n > 0
}
before exit 255, only "failed to convert a string: abc into an integer".
CANARY's violation is gone.
after exit 19, CANARY FAIL and USES_PARSE_INT FAIL.
Two separate mistakes, and fixing only the first is not enough -- I tried.
The conversions reported `Error::ParseError`. Nothing there parses: the rules file is already parsed and
valid, and what failed is a value in the *input* not supporting the operation asked of it. So the CLI
printed "Parser Error when parsing ..." for a well-formed rules file, which sends a reader to look at
the wrong artifact. All nine sites in `functions/converters.rs` and `functions/date_time.rs` now report
`IncompatibleError`, which is what they are.
That alone changed nothing observable, because the error is raised while resolving a `let`, which
propagates out through the clause's query and past the machinery that makes an incompatible type a
clause-level verdict. That machinery is reached from the per-value loop; a query that could not be
resolved never gets there. So the clause's own error arm now fails closed for an unevaluatable error
when the clause is an assertion, and keeps the error when it is a gate -- the same split the per-value
arm and `eval_when_condition_block` already use, so all three sites answer the same question the same
way.
`docs/FUNCTIONS.md` says these functions error on input they cannot convert, and they still do. What
changed is the blast radius and the label.
No published registry rule uses any function in the library -- checked all 190 -- so this has no
real-world exposure, and the registry differential confirms zero exit-code and zero content changes.
Verification: 828 tests, clippy clean on all targets, fmt and typos clean. The 440-cell matrix moves
zero cells.
The fix landed without its test, which is backwards for exactly this defect: the whole point is that an unrelated rule keeps its verdict, and only a canary shows that. `CANARY` fails on its own, so a file status of FAIL proves the conversion took down its own clause and nothing else -- an `Err` here is the abort, and it would have discarded CANARY. The second half covers the gate: the same conversion behind a `when` must leave the rule undecided rather than reading as a condition that did not match, or the rule it guards is dropped silently. The first attempt at that fixture put the function call directly in the `when`, which is not what the grammar accepts -- a function belongs in a `let` -- so the test failed on its own syntax before it could test anything. Worth recording: a fixture that does not parse is not a passing invariant, it is an absent one. 830 tests, clippy clean on all targets, fmt and typos clean.
…pelling
Three defects, one root cause: keyword tags match prefixes, and two of the literal parsers were missing
a case variant. All three are pre-existing on v3.2.0 and all three were found by review, not by me.
`TRUE` was not a boolean. It fell past every value parser into the property-access branch, so a gate
comparing against it compared one property with another property named `TRUE`, which no document has:
rule no_public_buckets {
Resources.*[ Type == "AWS::S3::Bucket" ] {
when Properties.Audited == TRUE { Properties.PublicAccess == false }
}
}
== TRUE exit 0, SKIP the public bucket is reported clean
== True exit 19, FAIL one character changed
Nothing chose lower-plus-Title for boolean and lower-plus-UPPER for null; that is two people adding one
spelling each. And a gate that can never fire is indistinguishable in the output from a gate that
correctly did not apply, so there was no signal to notice it by.
The published registry has the workaround. `cognito_allow_unauthenticated_identities_rule.guard:43-49`
tests `/(?i)true/ OR true OR True OR TRUE`, four spellings for one boolean, because the author could not
tell which the parser accepts. The `TRUE` branch never matched; the other three carried the rule. That is
why the differential shows zero changes here -- the rule already worked, and now all four branches mean
what they say.
`tag` matches a prefix, so `Public == falseFlag` was `Public == false` AND a reference to a rule named
`Flag`, and with such a rule present it reported PASS where the author asked whether one property equalled
another. `nullable` split the same way into `null` plus `able`. This is the family of `cb4719b`, which
guarded the two numeric parsers and left the keywords alone.
`this` lacked the boundary `some_keyword` has eleven lines above it, so `thisThing` could not be written
on the left of a clause at all and split on the right. `something == 1` has always parsed, because
`some_keyword` requires trailing whitespace. The asymmetry was an omission, not a policy.
One `keyword` combinator now carries the rule for all of them -- `true`/`True`/`TRUE`,
`false`/`False`/`FALSE`, `null`/`NULL`/`Null`, `this`/`THIS`, `keys`/`KEYS`, `exists`/`EXISTS`,
`empty`/`EMPTY`. Rejecting a trailing identifier makes the keyword parser fail, and the alternation then
falls through to `property_name`, which is the reading the author wrote.
It also fixed something I was going to fix separately: `keys` matched the prefix of `keys_count`, and a
`cut` in `map_keys_match` turned the comparator mismatch into an unrecoverable failure, so a property whose
name begins with `keys` could not be used in a filter predicate at all. With the boundary the `cut` never
fires and `m.*[ keys_count == 2 ]` parses. A property named exactly `keys` is still read as the operator,
which is a genuine shadowing question and not this commit's.
`test_parse_bool` pinned the old contract -- `parse_bool("true1234")` returning `Bool(true)` with `1234`
left over -- so it is updated to the new one, with the three spellings and four identifiers that are not
booleans. Pinning the split was pinning the defect.
Verification: 830 tests, clippy clean on all targets, fmt and typos clean. Registry differential unchanged
at 190 rule files and 1,956 expectation checks, zero exit-code and zero content changes; no registry rule
uses a keyword-prefixed identifier, and the one that writes `== TRUE` is the cognito rule above.
`is_unevaluatable` has three condition-site callers. A comment in this file says so and says what they
are for: "the error is the channel, and the three condition sites catch it and fail their own rule or
block rather than letting it escape". Only one of them split by role. The other two converted an
undecidable condition to a FAIL *status* whatever the role, and one level out a FAIL on a condition is
a condition that was decided and did not match, which `eval_rule` maps to a rule-level SKIP.
Both lose a verdict. Found by review, both verified before and after.
A type block's `when`, behind a parameterized gate:
rule inner_gate(unused) {
AWS::EC2::Volume when Properties.Encrypted !EMPTY { Properties.Size > 10 }
}
rule guarded when inner_gate("x") { Resources.Vol.Properties.Size == 100 }
`Encrypted` is a boolean, so `!EMPTY` has no answer. Against `Size: 5` the guarded clause is a real
violation. Before: exit 0, `guarded` SKIP, nothing reported. After: exit 19, `guarded` FAIL with the
reason. `inner_gate` is parameterized, so it is not also a top-level rule and nothing masks the exit
code -- this one was a clean exit 0 on a violating document.
A rule's own rule-level `when`:
rule inner_gate when Enabled !EMPTY { Enabled == true }
rule guarded when inner_gate { MustBeTrue == true }
Three spellings of one condition disagreed. Inline on the guarded rule: FAIL. Hoisted into a `when`
block in the gate rule's body: FAIL. Hoisted into the gate rule's own `when`: SKIP. The third also
misattributed itself, reporting that the referenced rule "did not apply to this input" for a rule whose
condition could not be evaluated at all.
The type-block fix needed its record closed on the early return, or `extract` fails with "context start
and end does not match" and the run dies at 255 rather than reporting. That is the second time today the
same trap caught a fix of mine, so it is worth naming: `start_record` above an early `return Err` is a
crash, not a leak.
A message on that record would have been recorded and discarded --
`every_recorded_explanation_has_a_rendering_path` caught it, measured, and the enclosing rule's own
explanation is what reaches the console anyway, naming the operation and the path. So `message: None`
with the measurement beside it.
The sweep grows by two shapes, both provided already measured by the reviewer who found these:
`named_gate_rule_level_when`, and a type-block shape which needs resource-relative operands because a
type block resolves its condition against each resource rather than the file root. Verified live --
reverting the type-block split fails the sweep naming the cell. That is 8 shapes by 3 bindings plus 3
type-block cells, each with a control.
No parameterized counterpart to the rule-level shape and no rule-level counterpart to the type-block
shape: the parser rejects `rule r(p) when ... {}`, and a type block only exists as a direct rule-body
clause, never inside a `when` block or a block clause.
Verification: 830 tests, clippy clean on all targets, fmt and typos clean. The 440-cell matrix moves zero
cells and the registry differential is unchanged at 190 rule files and 1,956 expectation checks, zero
exit-code and zero content changes.
Twenty-six commits in the rule evaluator. Every one is a case where a rule reported compliance, or reported nothing at all, for input it should have rejected — plus the reporter fixes needed to make those failures visible.
The class matters more than the count.
SKIPandPASSboth exit0, so a clause that quietly stops failing is indistinguishable from one that passed, and a rule that never fires looks exactly like a rule that holds. Most of what follows is that shape, reached by different routes: a dropped negation, a reference that resolved to nothing, a role that did not propagate, a comparison between an integer and a float, a condition scoped to the wrong object, an index applied twice.The first twelve commits came from auditing the PASS/FAIL/SKIP paths by reading, and two of them were found by @satyakigh in review. The last twelve came from measuring instead: drive branch coverage over the evaluator, then treat every line that decides a verdict and has never executed as a bug candidate. That turned up four more defects, all pre-existing, and confirmed one block of code as unreachable rather than untested. The two halves were briefly separate PRs; they are one story and the split made the diff harder to read, not easier.
Commits
2b215ccusizeunderflow drops the source snippet from violation reportsa54e4canoton a binary comparison is parsed, displayed, then discardeded342eanot <rule>reports compliance when the referenced rule never ran3f8466e8c51b77ClauseRoletype; fixes a gate that disarmed the block it guarded0e140b3noton a parameterized rule call is discarded70b9006whennested inside awhenre-labels its body, so a gate's FAIL becomes an absorbed SKIPde431f770feb757ea5c43whena023d09eeddc92whenconditions are evaluated against the file root, not each resourceca818b0b104090cc5a2f5d56965cf3c919f46156f0std::any::type_name, which varies by compiler version9900d0bdcf52admaxwhereminwas meantEach commit stands alone and can be reviewed or dropped independently. The table omits a few commits that only adjust tests or apply rustfmt.
2b215cc— underflow in the violation reporteremit_codecomputed its first context line asmax(1, line - 2), which evaluates the subtraction before the clamp. On an unsigned line number of 0 or 1 that underflows: debug builds panic and exit 101; release builds wrap to~usize::MAX, seek past EOF, and silently omit the snippet while the exit code stays a correct 19.Only the violation path is affected, so the tool misbehaves precisely when it has something to report. The trigger is input formatting rather than content — a minified single-line template puts the violated property on line 0 or 1.
Extracted into
context_start_line()usingsaturating_sub, with unit tests for lines 0-2, the normal case, and a never-returns-zero property. Reachable from the library entry point, so it also affectsguard-ffi, where unwinding out of anextern "C"function is undefined behaviour.a54e4ca— clause-levelnotdropped on binary comparisonsparser.rsaccepts a leadingnotbefore a clause and stores it asGuardAccessClause::negation, but the binary evaluation path never passed it tobinary_operation; only the unary path consumed it.grep -n negationovereval.rsreturned three uses, none in the binary path.The result is that
not <query> == <value>evaluated as plain<query> == <value>, the exact inverse of the author's intent. A rule written to reject an insecure value accepted it and rejected the secure one. The report compounded this by rendering the clause with thenot, so nothing in the output indicated it had been dropped.Composed with the operator's own not-flag by XOR at the call site. This matches
invert_closurein the supersededevaluate.rs, which applies both flips independently — that reference implementation is the reason I read this as a regression in the v3 evaluator rather than a design decision. No existing test covered clause negation on a binary access clause.ed342ea—not <rule>on a rule that never raneval_guard_named_clausecollapsedSKIPinto the same match arm asFAIL, so under negation it producedPASS.not <rule>therefore reported compliance on the strength of a dependent rule that never ran, and because the enclosing rule then reportedPASSrather thanSKIP, the output contained no trace of the omission.The two contexts a named-rule reference is reached from need different answers, so this threads a role: in a rule body the reference is an assertion and a skipped dependency fails closed; in a
whencondition, gating on a rule that did not apply is deliberate and is asserted by the existingcross_rule_clause_when_checks. A first attempt failedSKIPunconditionally and broke that test, which is what surfaced the distinction.3f8466e— comparisons against an empty referenceCmpOperator::comparetreated an empty operand on either side as reason to skip the clause. A rule whose reference list resolved to nothing therefore reported compliance for input it should have rejected.The two empty operands are different situations:
The vacuous case reports
SKIPrather thanPASS.eval_conjunction_clausestreatsPASSas short-circuiting (continue 'conjunction) butSKIPas absorbing, so a vacuousPASSsatisfies an entireorblock and abandons its sibling disjuncts unevaluated —X != %empty_denylist or X == truewould pass a violating input because the first disjunct is vacuously true and the real check never runs. An intermediate version of this change did exactly that. Per-clause soundness is not sufficient; the status has to compose correctly under the folds.The verdict is also context-dependent: in a
whencondition the unsatisfiable case staysSKIP, because a failing condition makes the gate not-PASS andeval_rulethen treats the rule as inapplicable and skips the whole body.The unsatisfiable case emits a status rather than per-value comparison records, because there is no right-hand value to report against and building
from:out of a raw LHS entry panics when the LHS is aQueryResult::Literal— three reporters treat that as unreachable inside a comparison.8c51b77—ClauseRoleinstead of two booleansThe two preceding commits threaded
strict_skip: boolandstrict_empty_rhs: bool. That encoding was unsafe twice over: a boolean argument carries no meaning at the call site, and a missing one is invisible.Converting to an enum turned every unthreaded path into a compile error, which surfaced a defect the boolean version hid.
eval_when_clausethreaded the flag into itsClauseandNamedRulearms but notParameterizedNamedRule, and everything downstream defaulted to assertion strictness. A parameterized rule invoked as a gate therefore evaluated its body strictly, failed, made the gate not-PASS, andeval_ruleskipped the guarded body entirely — a violating input exited 0 where v3.2.0 exited 19. Four shapes reached it: a plain parameterized gate, one whose gate body held an empty-reference comparison, one nested in a rule body, and one on a type block.The compiler identified all twelve threading sites, including three filter-predicate calls in
eval_context.rsthat no amount of reading had turned up: a filter selects values, so it is a test rather than an assertion.0e140b3— clause-levelnotdropped on parameterized callsThe same defect class as
a54e4ca, in the one arm it did not reach. The parser stores a leadingnoton a parameterized invocation, buteval_parameterized_rule_callreturned the invoked rule's status unchanged and never read the flag, sonot r(...)behaved identically tor(...). Demonstrated by three fixtures: the negated and un-negated forms were outcome-identical at exit 0, while the non-parameterizednot rcorrectly exited 19 — so the mechanism existed and only this arm bypassed it.Applied after evaluating the invoked rule, mirroring
eval_guard_named_clauseso both spellings agree.This commit also tightens three assertions added by earlier commits. They used
assert_ne!and so admitted bothSKIPandFAIL— exactly the distinction that decides whether a guarded body ran, since both exit 0. The exact statuses were measured rather than guessed.70feb75— a number is a numbercompare_valuesinpath_value.rsmatched Int/Int, Float/Float, String/String, Char/Char and Null/Null, with no arm across the two numeric types. Every mixed comparison fell to theNotComparablecatch-all.The visible half is a wrong FAIL:
Size > 10against a template carryingSize: 50.5reported "PathAwareValues are not comparable float, int" and failed a compliant volume.The half that matters is a wrong PASS. Put the same comparison in a
whencondition andeval_rulemaps the non-PASS toStatus::SKIP, which exits 0 and takes the guarded body with it:Size: 50, Encrypted: falseSize: 50.5, Encrypted: falseChanging one character in a template turns the encryption rule off. Reproduced on the merge-base
57bbdbf, so it predates this work; the reason nobody hit it in review is that theGe/Gt/Lt/Lepaths had no test that reached them with operands of differing types.The comparison is exact rather than
(i as f64).partial_cmp(f).i64above 2^53 does not survive a round trip throughf64, so the lossy spelling answersEqualfor 2^53+1 against 2^53. Casting the other way is safe once the float is bounds-checked, and the bound is 2^63 rather thani64::MAX as f64because the latter rounds up to 2^63 and would admit a float that then saturates on the cast. NaN staysNotComparable, which is what Float/Float already answered.Mutation-verified twice: removing the arms restores
NotComparable, and substituting the lossy spelling fails on the 2^53+1 case specifically. No existing test or golden file depended on the old behaviour.7ea5c43— the comparison matrix, and two type-block findingsthe_comparison_matrix_over_operand_types_is_pinnedevaluates 288 clauses end to end: six operators against eight left-hand operand types and six right-hand literals. What the grid asserts is the absence of a wrong PASS — a FAIL cell is a pairing that is undecidable or genuinely false, and PASS appears only where the comparison is decidable and true. Two cells look like mistakes and are not, so the doc comment names them: a list on the left distributes element-wise, so[1,2,3] < 50passes, and an absent property fails rather than skips.the_type_block_status_fold_is_pinnedcoverseval_type_block_clause's fold, which has the same shape aseval_conjunction_clausesand absorbs a per-resource SKIP the same way. No test reached any of its arms, including the one that decides whether a violating resource is reported at all.a_skipped_type_block_is_indistinguishable_from_a_clean_runrecords a defect rather than fixing it. Inside one construct the scoping is not consistent: the block's clauses are resource-relative, but thewhenconditions are evaluated against the enclosing resolver, before the per-resourceValueScopeexists. Soreads as "every volume over 10 GiB must be encrypted" and instead looks for
Propertiesat the file root, finds nothing, and skips — reportingnot_applicableand exit 0 for every template it is ever run against, including the ones it was written to catch.when Resources.A.Properties.Size > 10works. Both spellings are asserted, since the contrast is the whole finding.I wrote an explanation onto that skip record and then removed it. It could not reach a reader: skipped rules arrive at the reporters as a
HashSet<String>of names, so the message would have been recorded and discarded — which is the defect #717 just removed from five variants.every_recorded_explanation_has_a_rendering_pathcaught it, which is what that test is for. Surfacing it properly means the skip set has to carry reasons, and that changes thereportsignature every reporter implements.Both halves are now fixed, in
eeddc92andca818b0.a023d09— the unary operators176 cells: eight operators in both polarities against eleven operand shapes, including the empty and absent cases that separate
EMPTYfromEXISTS.unary_operationand theis_*family had no test walking them across shapes, which is how the negation arm arrived here uncovered.Two behaviours are pinned on purpose.
EMPTYon a container answers; on a scalar it is an incompatible-type error rather than a status, which is right because both statuses would be wrong — an int is not empty, but calling it non-empty implies the question made sense. And every negation must invert its positive form: a negated operator answering the same status as the positive one has stopped discriminating, which is the shape of the role-propagation defect #717 opens with.eeddc92— type block conditions are per resourceThe conditions moved inside the loop over matched resources, so they are evaluated against the same scope as the clauses they guard. That also makes them per resource: one the condition exempts contributes to neither the pass nor the fail count, so an exempt resource cannot shield a violating one, and a block that applied to nothing answers SKIP rather than PASS.
The cost is the mirror image and it is real. A condition written as a literal root-anchored path —
when Resources.A.Properties.Size > 10— resolved before and does not now, becauseValueScope::querystarts at the resource. Accepted for two reasons: a condition over one named resource does not belong on a block that iterates all of them, and the idiom real rulesets use is unaffected, sinceValueScope::resolve_variabledelegates to the parent andlet volumes = ...followed bywhen %volumes !emptystill resolves at the root. The test asserts all three spellings so the trade is visible rather than inferred.Nothing in the repository depended on the old behaviour: a search for type blocks carrying a
whenfound two occurrences, both fixtures added by this branch.Rejected alternative: having
ValueScopefall back to the parent root when a query does not resolve locally would keep both spellings working, but every unresolved path would retry against a different scope, so a typo in a resource-relative path would silently resolve somewhere else — trading a visible skip for an invisible wrong answer.ca818b0— a skipped rule can explain itselffind_skip_reasonwalks a rule's own record subtree for a block-shaped record that skipped with a message, and the three paths that report skips were widened to carry it:FileReportgainednot_applicable_reasons, the console summary prints the reason under the rule, andGenericReporter::reporttakes aSkippedRulesmap instead of aHashSet<String>.Both new serialised fields are
skip_serializing_ifempty, so a run with nothing to explain produces byte-identical output and no consumer of the JSON or YAML document sees a shape change.BTreeMaprather thanHashMap, and the console printer sorts, because the underlying map is unordered and would otherwise reshuffle its own lines between runs.Two explanations use the mechanism, and they are the pair worth telling apart. A type block whose query matched nothing says the type is absent, which is the ordinary reason for a rule not to apply. One whose condition exempted every matched resource says so, and that is the one worth a second look — a rule that never fires looks exactly like a rule that passes, because both report SKIP and exit 0.
The counting in
every_recorded_explanation_has_a_rendering_pathchanged too. It counted the literal stringmessage: Someand undercounted the moment a message was built in amatcharm, reading 14 where the real figure was 15. It now counts by exclusion: everymessage:field that is notNone, not a type annotation, and not a forward of the author's owncustom_message. That cannot be fooled by a new spelling, and it separates evaluator explanations from the custom-message feature, which always rendered.b104090— the unreachable arms, removed by narrowingMapKeyFilterClause::comparatorwas a(CmpOperator, bool), which can express every operator, whilemap_keys_matchparses four.real_binary_operationis reached only fromQueryPart::MapKeyFilterand carriedGe,Gt,LtandLearms that recorded zero executions against the 288-clause matrix using those very operators.Deleting them and keeping the wider type would have left them one parser change from returning and converted a currently-correct dead path into
unreachable!(). A comment would have left the file permanently uncoverable and the trap intact — someone tracking an ordering bug finds the arm callingcompare_ge, edits it, and sees no effect. AMapKeyComparatorenum makes them impossible instead.One user-visible consequence, an improvement rather than a regression:
parse-treeserialises a map key filter's comparator asEqrather than the two-element sequence[Eq, false], and!=asNotEq. Regenerating the three affected golden files changed exactly one site — the other comparators in those documents belong toAccessClause, which keeps the pair. Hand-editing them first produced a wrong diff, becauseAccessClausealso has acompare_withfield and so cannot be told from a map key filter by its neighbours.cc5a2f5— the decisions nothing reachedA clause-level
notin front ofEMPTY, where the clause's negation composes with the operator's own; a negated parameterized call whose invoked rule reached a verdict; and a disjunction in which every disjunct skipped. All correct, which is worth stating as plainly as a bug would be — an audit that reports only its finds cannot be told apart from one that stopped early.The negation fixtures are fussier than they look. A first version used a plain key path, which produces the right answer through a different code path entirely:
unary_operationhandlesEMPTYon a filter-terminated or lone-variable query in a separate early-return block, and the clause-level flip lives inside it. The test passed while the arm stayed at zero. Coverage caught that the assertion was vacuous; reading the source would not have.d56965c— the language docsTwo of these changes alter what a rule means, so they belong in the docs. Type blocks with a
whencondition were undocumented — the only prior mention of type blocks anywhere indocs/is a passing note about variable scoping — soCOMPLEX_COMPOSITION.mdnow covers the construct, the per-resource scoping, the three outcomes, and how to migrate a root-anchored condition.CLAUSES.mdgains the numeric comparison rule under Binary Operators, with thewhencase spelled out because that is where the old behaviour cost enforcement rather than merely reporting a wrong verdict.KNOWN_ISSUES.mditem 2 still holds for genuinely different kinds, so a note narrows it to exclude the numeric pair.Every rule sample was run through
parse-tree, and the worked example was executed against five templates to confirm each documented outcome. The last branch to write a docs claim about evaluator behaviour did not check it, and the claim was wrong.f3c919f— a condition that cannot be decidedThe quietest wrong answer of the set, and the one that shows why the others matter. CloudFormation templates carry numbers as strings routinely, and
when ...Properties.Size > 10againstSize: "50"cannot compare a string to10. The condition does not pass, so the rule is reported not applicable, so its body never runs. Exit0, nothing named.Size: 50fails the same rule.The rule still does not enforce, and it cannot be made to from here. On a condition both FAIL and SKIP drop the block being guarded, and a gate FAIL is worse — the condition fold counts it and it outranks sibling conditions that passed, dropping a body those siblings would have kept enforced, which is why the empty-reference arms in
3f8466eare role-aware rather than failing outright. Telling "could not decide" from "decided false" where it matters needs a status meaning "could not tell", andStatushas three variants, none of which is that.What is available is saying so, which
ca818b0makes possible. The change is entirely reporter-side, and the discriminator is precise rather than heuristic: only two things record an explanation on a comparison — an empty reference and incomparable operands — and both mean the clause could not be decided, while the ordinary failure arm records no message. So a rule that legitimately does not apply stays silent, which is what makes the message worth reading.46156f0— a context that changed with the compilereval_conjunction_clausesbuilt its record context withstd::any::type_name, which is documented as being for diagnostics with no stability guarantee. This use was not confined to diagnostics: the result reaches verbose output, which four golden-file tests compare byte for byte. rustc 1.77.2 rendersGuardClause<'_>and later versions drop the elided lifetime, so the suite passed on the pinned toolchain and failed on every newer one.Not hypothetical debt: those four tests had to be skipped to measure branch coverage at all, since branch counters require nightly and one failing binary aborts the run before the later ones execute. Taking the path before the generic arguments is stable on both, and no golden file changed — they were already written for the normalised form.
9900d0b— an index applied twiceThe query resolver handles a variable used where a map key is expected, and peeks at the following part: an index there says which of the resolved keys to use. Having consumed it, the recursion advanced by one anyway, so the index was applied a second time, to the value the key had just selected.
%names[0]pickedBucketAcorrectly, then[0]was applied toBucketAitself, the query resolved to nothing, and every part after[0]was discarded. Two things kept it hidden: the form without an index always worked, so the pair reads as a rule-authoring mistake; and an unresolved query is reported as a retrieval failure against the input, so the output blames the template. In a condition it is the same wrong PASS as the rest — the last case in the test is that shape, SKIP at exit0with an unreported violation inside.dcf52ad— a failure message that printed everythingbinary_error_in_msgcomputed its cut-off asmax(values.len(), 5), which is never below the number of values, so the loop meant to stop early never did and the branch reporting aTotalwas unreachable. A rule comparing against a denylist of five hundred entries printed all five hundred, in every failure message, for every non-compliant resource. The dead branch is what gives the intent away: it exists to say how many there were when not all are shown.Review response
@satyakigh raised two findings on the first revision. Both were real, and both are fixed above rather than argued with.
Role propagation was incomplete.
8c51b77converted the strictness booleans to aClauseRoleso that an unthreaded path becomes a compile error, buteval_when_condition_blockthen hardcodedClauseRole::Assertionfor the guarded block, on the reasoning that a guarded block holds the rule's own assertions however the conditions were evaluated. That is wrong for awhennested inside anotherwhen: the inner body was re-labelled, so a gate's FAIL arrived at the leaf as a SKIP, andeval_conjunction_clausesabsorbs SKIP rather than counting it, so one passing sibling answered PASS for the whole conjunction and the run exited 0.70b9006makes the parameter required rather than defaulted. A default reads as a deliberate choice at each call site and reintroduces the bug the first time someone adds a third caller. Both requested forms are covered:nested_when_inherits_the_enclosing_rolepins the one-level case against its unnested twin, andthe_role_reaching_a_leaf_clause_survives_every_nestinggenerates the 12-cell product of {negated, positive} leaf x {0,1,2} nesting depths x {assertion, gate} role and asserts each cell, so the behaviour is pinned at every depth rather than at the one that happened to be broken. SKIP is the signature of a leak, and the matrix distinguishes it from both PASS and FAIL.The gate fixture uses two ANDed conditions on purpose. With a single condition,
eval_rulemaps every non-PASS condition to SKIP, so a gate that FAILs and one that SKIPs are indistinguishable and the test passes even against an unconditional FAIL — which an earlier version of it did.The empty-reference explanation never rendered. This one also makes an earlier claim of mine wrong rather than merely incomplete. I told this review that the failure "names the cause and the remedy", and wrote the same into
docs/CLAUSES.md. The message was constructed and stored, andreport_all_failed_clauses_for_rulesreturned whatever a record's children reported — which for a record whose failure is described by its own message and which has no failing children is nothing at all.de431f7fixes it for every affected variant, not just the one that prompted the report. Six record types carry a message; onlyClauseValueCheckhad a rendering path.GuardClauseBlockCheck,TypeCheckandWhenChecknow fall back to their own explanation when no child reported,BlockGuardCheckprefers its stored message over a hardcoded sentence, andDisjunctiondoes the same. The console reporter grew a section for explanations that carry no unresolved value, becausepopulate_hierarchy_path_treescannot attribute those to a resource and sopprint_clausesnever reached them.Three tests, per the requested shape: the two reporter-output cases assert the text reaches console and structured JSON, a negative control asserts a clean run prints no such section (so the first is not satisfied by a reporter that prints unconditionally), and
every_recorded_explanation_has_a_rendering_pathcounts themessage: Someconstruction sites against a per-variant table so a seventh message-bearing record cannot be added without wiring a reporter. That last test has already earned its place twice: once when rebasing surfaced a fourteenth site, and once when it refused a message I tried to add to a skipped type block, where records never reach a reporter at all.Both fixes are mutation-verified. Reverting the role change fails exactly the two role tests; reverting the reporter fallback fails both output cases; suppressing only the console section fails only the console case.
Verification: output, not just verdicts
A second differential covers full output, driven by a generator rather than the repository's 45 rule
files: 6 clause families x 12 rule shapes gives 206 distinct rules, each against 14 input families in
2 output formats, for 5768 comparisons. Deterministic from a fixed seed, and every rules file also
gets a variant with an unrelated always-failing rule prepended -- the shape that caught the abort
regression, since with one rule per file an abort and a clean skip both leave nothing reported.
All 2365 output differences and all 326 exit-code differences are attributed to a named commit.
Zero unclassified. Zero abort-losses.
The generator immediately falsified one claim from the exit-code pass, and that is the useful part.
That pass asserted no
19 -> 0transition -- a reported violation becoming a silent pass -- and thegenerator produced 104. All are intended:
a54e4caand0e140b3apply a clause-levelnotthat waspreviously discarded, and
70feb75makes an integer comparable with a float, so both flip verdicts inboth directions. The invariant is now "every
19 -> 0is attributable to a construct whose meaningintentionally inverted", checked per pair rather than asserted; the 28 numeric cases were each
confirmed by re-running the merge-base and checking its FAIL was specifically a mixed-numeric
NotComparable. Unexplained: 0.It also found something the verdict differential could not. The console reporter emitted failing
rules in
HashMaporder: twenty runs of the merge-base over three failing rules produced fivedistinct reports, six with
--show-summary all. Pre-existing, but there is no useful sense in whichoutput is unchanged when the baseline disagrees with itself, so
07bcd4fsorts them. And 968 of thedifferences are a violation's
Code:snippet appearing where the merge-base printed a header withnothing under it --
2b215ccis a much larger fix than its diff suggests, and7cefd51closes asecond route to the same blank snippet.
The durable half is in CI:
generated_rule_shapes_hold_the_evaluator_invariantsruns 252 cells overinvariants that need no oracle -- canary isolation, determinism, and that a clause and its negation
never both pass. Two of those invariants were wrong when first written, in ways worth recording. "
not Xmust differ fromX" is false: an undecidable comparison fails closed in both polarities, whichdocs/CLAUSES.mdalready documents. And the test's assertion that some cell must produce anincompatible-type error failed on the first run, because no generated clause reached that path -- so a
documented exception was describing code nothing exercised.
Regression verification
A differential against the merge-base, since twenty-seven commits in an evaluator is a lot to take on
trust. Both binaries built from source, exit codes compared per (rules file, data file) pair.
Every rule file that existed before this branch, against every data file in the repository — 45 x
60 = 2700 pairs — produces an identical exit code. No shipped ruleset, example or fixture changes
its verdict.
Over an extended corpus including this branch's own fixtures, 911 pairs give 14 differences, all
0 -> 19, and each one is a fixture whose purpose is to demonstrate a documented change: thirteen onthe empty-reference rule and one on the type block over a template whose volume exceeds the threshold
and lacks
Encrypted. Nothing moves19 -> 0(a reported violation becoming a silent pass) or19 -> 255(a violation discarded when a file aborts).The exercise earned its keep by finding a regression this branch had introduced, which is why
e814a88exists. Moving a type block's conditions per-resource removed an early return that had beenmasking an error path, and an unresolvable type block query returned
Err— which aborts the wholerules file, so a violation an unrelated rule had already found stopped being reported.
19became255and the finding vanished.Worth recording how it was missed: the first corpus had one rule per file, and with a single rule an
abort and a clean skip are indistinguishable by exit code, since both leave nothing reported. Adding
canary variants — every rules file with an always-failing rule prepended — is what exposed it. A plain
type block reached the same
Erron the merge-base, so the abort predates this work and is fixedalong with it.
What the differential does not cover: output, which deliberately changed in three places and is
covered by golden-file tests instead; rulesets outside this repository, where the type-block
whenmigration above does apply; and shapes a hand-built corpus does not contain, which is the class that
hid the regression in the first place.
Coverage, and a caveat about measuring it
Stable 1.77.2, the pinned toolchain, emits no branch counters, so the branch column needs nightly. But nightly's
--branchinstrumentation splits sub-expressions into separate regions —eval.rsreports 842 regions on stable and 1855 on nightly — and marks struct-literal fields and closing punctuation inside covered blocks as uncovered. Ateval.rs:2070-2071, two fields of a single struct literal report 45 executions and 0. So nightly's region and line percentages carry an artifact floor and are not comparable to anything CI reports; only its branch column is meaningful. The two toolchains also cannot share a target directory, since stable'sllvm-covrejects nightly-built artifacts outright.Region and line coverage on the pinned toolchain, before and after:
eval.rseval/operators.rseval_context.rsreporters/validate/cfn.rsBranch coverage, which only nightly reports:
eval.rs71.88% -> 79.03%,eval_context.rs42.95% -> 48.73%,cfn.rs63.33% -> 71.43%,operators.rs82.14% (unchanged). The whole suite now passes on nightly as well, so those runs no longer skip anything — before46156f0four tests failed there and aborted the run.The gains are modest next to the findings, and that is the honest shape of the result: the wrong-PASS class lives in the fold and role paths, which are a small share of the line count. What remains uncovered is mostly not status-deciding —
query_retrieval_with_converter(173 lines of query shapes) andreport_all_failed_clauses_for_rules(153 lines of reporter variants) ineval_context.rs,operators::compare(101), and the reporter's error-message formatting helpers (47 incfn.rs).Rather than chase the percentage, the sweep enumerated every line in these four files that produces or folds a PASS, FAIL or SKIP and had never executed, then closed the reachable ones. What is left is 13 error-propagation arms that require the resolver itself to fail, one
unreachable!(), and theEmptyRhsarm already commented as unreached because its wrapper resolves the case into two other variants. None of them decides a verdict for input a rules file can express.cfn.rshas no uncovered status decisions at all.Verification
guard/resourcesandguard-examplesproduce zero exit-code differences. All 24 shipped example rulesets are unaffected.whengates, nestedwhen, type blocks,someblocks, parameterized rules, multi-rule files, and map-key filters. No case where v3.2.0 exits 19 and this branch exits 0.cargo clippy -- -D warnings,cargo fmt --check,typosandshellcheck install-guard.share clean.An earlier revision of this description reported 15 failures in
validateand 17 intest_commandas pre-existing. That is no longer accurate and the count was also wrong about the cause. Two separate things were happening: a test harness that resolved fixture paths through$HOMErather than the crate directory, fixed inb6d384d, and four golden files that compare output containingstd::any::type_name, which is not stable across compiler versions. The latter pass on the pinned 1.77.2 and fail only on a newer toolchain, so CI is unaffected — but they will break on a compiler bump, and that is worth knowing independently of this PR.Intended behaviour changes
All of these are the point rather than side effects, but they are behaviour changes and belong in release notes.
Rulesets that pass today because a reference resolved empty, or because a
notwas silently dropped, will now fail — those were false passes. Thenot <query> <op> <value>form in particular changes meaning in both directions, because it previously evaluated as its own inverse.Rules comparing an integer against a float now decide instead of failing, so a rule that failed a compliant template stops doing so, and a gate that silently skipped now applies.
Type block conditions need migrating in one case. A
whenon a type block is now evaluated against each matched resource rather than against the document root. The natural spelling starts working:That previously looked for
Propertiesat the root, found nothing, and reported the rule not applicable for every input. The reverse also holds: a condition written as a literal root-anchored path no longer resolves.The variable idiom is unaffected, since variables resolve through the parent scope. Nothing in this repository used the root-anchored form — a search found two occurrences of a type block carrying a
when, both fixtures added here — but external rulesets may. Documented indocs/COMPLEX_COMPOSITION.md.Two output changes.
parse-treeserialises a map key filter's comparator asEqrather than the two-element sequence[Eq, false], and!=asNotEq. And a rule reported as not applicable may now carry a line explaining why, in the console summary and as an optionalnot_applicable_reasonsfield in the structured formats. Both serialised additions are omitted when empty, so a run with nothing to explain produces byte-identical output to before.Not addressed
Recording these so reviewers know they were considered rather than missed. All are pre-existing in v3.2.0 and none is a regression from this branch:
match_allblock clause:Resources.*[ Type == 'AWS::S3::Bucket' ] { Properties.Tags == "Owner" }passes a bucket withTags: []. The ALL fold sees zero per-value results and falls through toPASS, having discarded the polarity.Tags[*]andsomevariants fail correctly, so it is specific to the bare form.EMPTYon a boolean is always answered "not empty", because the implementation computesto_string().is_empty(). A fix for this was prepared and dropped from this PR: it converts the case to an error, and that error aborts the entire rules file, so a violation in an unrelated rule stops being reported. Correcting it needs a per-clause failure rather than a propagated error, which is a separate change.Status::andis the only correct three-valued operator in the codebase and none of the fourteen evaluation folds use it; each is a hand-rolled counter. That inconsistency is the structural root of several items above, including the disjunction asymmetry described under3f8466e. Worth addressing, but far too large to sit alongside these fixes.Notes for reviewers
The
ClauseRoledistinction in8c51b77is the load-bearing idea. An unevaluatable clause has to fail as an assertion and skip as a gate, because a failing gate makes its rule inapplicable and drops every check inside it — so the same clause needs two different answers depending on why it is being evaluated. If a future change adds a third evaluation context, that enum is where it belongs.I would rather over-explain the reasoning than have a reviewer reconstruct it, so the commit messages are long and each records what was tried and rejected. Happy to squash, split, or drop any commit.