Repository navigation
fix: filter and capture handling in the query engine, the reporter aborts it exposed, and four parser boundaries - #727
Conversation
0316b30 to
e220608
Compare
aws-cloudformation#727 gained thirteen commits while this branch was being reviewed, and the two lines both changed `guard/src/rules/eval_tests.rs` and `guard/src/rules/eval.rs`. Merged rather than rebased: rebasing this branch's commits one at a time through a test file that both lines append to conflicts once per commit, and each resolution moves code that the next patch expects to find in place. Two resolutions were needed and neither is mechanical. `eval_tests.rs` keeps this branch's layout, with aws-cloudformation#727's nine added items re-applied as one contiguous slice. Extracting them item by item truncated an attribute block, because a `#[case::...]` attribute wraps across source lines and its continuation matches no attribute pattern, which dropped the `#[rstest::rstest]` above it and left two `#[case]` parameters with no macro to consume them. The whole-slice form carries the attributes with the items. `eval.rs` had aws-cloudformation#727's two new arms answering `Ok(Status::FAIL)` in functions this branch changed to return `Result<Outcome>`. They now answer `Outcome::Unevaluatable` role-free, which is this branch's vocabulary for the same intent: the answer is a value and the consumer applies the role, rather than the producer splitting on `role.is_strict()` as the pre-`Outcome` version did. Three of aws-cloudformation#727's tests fail on this merge and are addressed in the commit that follows, not here, so that the reconciliation is visible as its own change rather than buried in a merge.
…data cannot supply The merge took aws-cloudformation#727's production change together with the pre-aws-cloudformation#727 version of the test that change was written to replace, so three cases of a_function_argument_that_selects_nothing_does_not_panic failed on a tree where each side passed alone. 9c7f5a5 reclassified an unsuppliable scalar function argument from ParseError to IncompatibleError. ParseError is the class the evaluator reserves for a malformed rules file and is_unevaluatable does not recognise it, so a -1 in a template field that some rule feeds to substring aborted the run at 255 rather than failing the clause at 19. That reclassification is present at b2bb012. The test read its expectation off the error out of eval_rules_file and asserted the message named the second or third argument, so with the error gone it read the empty string and the three cases failed with "must name the second argument rather than abort; got \"\"". 9c7f5a5 had already rewritten the test in the same commit to assert Status::FAIL through rule_status_in, and said so in its message. The merge kept the merge base's test body character for character, so that rewrite was the only part of 9c7f5a5 that did not land. The test is the side that was wrong. What it asserted, that eval_rules_file returns an error naming the argument, is the behaviour 9c7f5a5 deliberately removed, so keeping it would mean reverting the fix to satisfy a test written against the defect. aws-cloudformation#727's replacement is also the stronger assertion: FAIL covers both the panic the test was written for and the abort that replaced it, and here it pins the one thing the argument's role decides, because to_status sends Outcome::Unevaluatable to FAIL for an assertion and to SKIP for a gate. The doc comment now says that instead of aws-cloudformation#727's wording about there being no error left to read. The Outcome translation of aws-cloudformation#727's two right-hand-side arms in eval_guard_access_clause is not implicated in these failures and is right as it stands. Those arms come from 7dcfc1b rather than 9c7f5a5, and a let-bound function argument does not reach them. Outcome::Unevaluatable is what the clause's own query arm further down already answers for the same error, role-free, with a comment explaining that the split on role.is_strict() moved to to_status and closes_gate at the consumer. Two comments are corrected rather than left standing: the sentence retained from aws-cloudformation#727 said a gate still keeps the error and named the left-hand side as its precedent, which the merge made false in both halves, and the per-arm repetition of the reasoning is dropped now that the block comment above both arms carries it. Verified: 1525 tests pass, 0 fail, 2 ignored across 16 test binaries, from 1522 passing and 3 failing. cargo build --release, clippy --release --all-targets -D warnings, fmt --all --check and typos are clean. All 193 convention-paired rule and test pairs in aws-guard-rules-registry at 6aca96e exit 0. aws-cloudformation#727's reproductions behave as it claimed: a substring bound the data cannot supply and a join separator likewise both move 255 to 19, a sibling rule in the same file still reports its own FAIL and still names the security group open to 0.0.0.0/0 as the one non-compliant resource, and the controls where the argument is supplied are unchanged, at 19 with a violating sibling and 0 without. Across 4,536 rule-by-data fixture pairs under guard/resources/validate against the pinned r720c binary there are 37 exit-code differences and 54 text differences, none of them this commit's: the change to eval.rs is comments only and eval_tests.rs sits behind cfg(test), so the release binary is byte-for-byte equivalent in behaviour to b2bb012. All 37 exit-code differences are attributable to a aws-cloudformation#727 commit, moving 255 to 19 or 0 to 19 in the direction that commit intended: bad_function_arguments and join_with_message to 9c7f5a5, count_empty_collection and count_unresolved to c9ff4d6, embedded_json_the_parser_rejects to b90613b, numbers_that_do_not_fit to 9cde8e5 and regex_replace_no_match to 145e8ad. Twelve of the 17 text-only differences are c9ff4d6 and 9cde8e5 reporting a count or a conversion as unevaluatable where the pinned binary answered zero or saturated to i64::MAX, at the same exit code either way. The remaining five are the reason suffix that 27a86f5 appends to a memoised rule answer, which means r720c predates 27a86f5 and is not the merge's first parent; it matches 4613118, one commit earlier on the same branch.
…erence-to-inapplicable question toward aws-cloudformation#727 Two conflicted paths, and eval.rs was not a textual conflict: the two branches held opposite, separately-argued positions on what a reference to a rule that did not apply means. This branch mapped it onto a violation for assertions -- `(Outcome::NotApplicable, _) if role.is_strict() => Outcome::Violated` -- on the ground that "a rule body asserting `r(...)` claims that `r` holds, and a rule that never ran is not evidence that it does", and flagged the resulting change from main as deliberate. That reasoning is true and the conclusion drawn from it is not: the answer to "no evidence" is no verdict, not a violation. Resolved toward aws-cloudformation#727 for two reasons. It produced a false positive. With two helper rules each gated on their own resource type, a template carrying one clean instance of the first type and none of the second reported FAIL at exit 19, and so did a template carrying neither type -- a violation asserted about a resource type the template does not contain. The row that matters for correctness, a violating instance beside a clean sibling, still FAILs, so this is not a slide into disjunction. It contradicted this PR's own algebra. `Outcome::and` has identity `NotApplicable` and absorbing `Violated`, so one level down an inapplicable clause conjoined with a satisfied one yields satisfied. Mapping that same `NotApplicable` onto the absorbing element at the reference site left the type saying "not applicable is neutral" while this arm said "not applicable is fatal", for one input. Whichever way the question is answered, the reference site and `Outcome::and` have to answer it the same way, and only one answer is expressible without carving an exception out of the type. Both sites are resolved, `eval_guard_named_clause` and `eval_parameterized_rule_call`, and both matches stay exhaustive over the enum rather than ending in `_`: that catch-all had been silently covering both a failing dependent and a negated gate on an inapplicable one. The negated `when` gate is unchanged -- a gate that closes silently disables the rule it guards, and the AWS rule registry's one negated reference is a gate. The comments arguing for the old behaviour are removed rather than left beside code that now does the opposite. eval_tests.rs was additive on both sides; all twelve test functions are kept.
aws-cloudformation#727 gained one commit: an empty expansion yields an empty selection only when a filter is still pending at or after the point the collection came up empty, so the two flavours of empty selection stop disagreeing. Merged clean textually. Verified behaviourally rather than on that basis, because the previous merge from this branch was also textually clean and had aws-cloudformation#727's Ok/Status::FAIL arms meeting this branch's Result/Outcome return type, which git cannot see.
`f64::from_str` signals overflow by returning an infinity and underflow by
returning zero without saying so. The `is_finite` gate caught the first and not
the second, so `1e400` fell through to a string while `1e-400` became
`Float(0.0)`: a value the document writes as a small positive number answered
`== 0` with a PASS and `> 0` with a FAIL, at exit 0, with nothing on either
channel. One rule disagreed with itself at the two ends of the same exponent
range.
It also made a comment in `rules::parser` false. That comment explains why the
rules parser rejects both saturating and underflowing float literals while the
document side does not, on the grounds that "there, `loader.rs` retypes the scalar
to a string, which leaves every comparison against it incomparable and therefore
failing closed". That was true of the overflow half only. Both halves now do it.
The discriminator is the mantissa, not the whole literal. A zero result is genuine
when every significant digit is zero and an underflow when one of them is not, and
the exponent has to be excluded from the test or `0e400` -- a real zero -- would be
read as an underflow on account of the `4`. Eight zero spellings are pinned as
controls for that, including `0e400` and `0.0e-400`.
A small float that is still representable is unaffected, which
`a_small_float_that_is_still_representable_stays_a_float` pins with a subnormal:
without it the change is also satisfied by refusing every negative exponent.
User-visible change: a float literal too small to represent is the string it was
written as, so `is_float` answers false and a comparison against a number reports
the type mismatch instead of answering as though the author had written zero.
Not fixed on the serde-backed conversion, and the reason is not effort.
`serde_yaml::Number` is `enum N { PosInt(u64), NegInt(i64), Float(f64) }` and
retains no source text -- verified against the pinned crate source, 0.9.34 -- so an
underflowed `1e-400` arrives there as `Float(0.0)`, indistinguishable from a
literal `0`. The divergence is recorded in
`both_loaders_resolve_the_same_document_to_the_same_value` with that reason.
Blast radius: no data fixture in the rules registry snapshot or this repository
contains a float literal outside f64's range. Whole suite green with no fixture
regenerated.
…, in the serde conversion
Two defects in one `serde_yaml::Value::Number` arm, both reached from
`PathAwareValue::try_from(serde_yaml::Value)` -- which is what the `test`
subcommand and the public `run_checks` read documents with.
The sign inversion first, because it is the worse of the two. The code read
`num.as_u64().unwrap() as i64` under a comment saying "Yes we are losing precision
here. TODO fix this". It was not losing precision: `as i64` reinterprets the bit
pattern, so the sign flipped. `u64::MAX` read as exactly -1 and `i64::MAX + 1` as
exactly `i64::MIN`. Every numeric guard in the language inverts for such a value --
measured, not inferred: `A < 0` PASSed, `A == -1` PASSed, and `MaxSize <= 1000`
PASSed for an input of 18446744073709551615, all at exit 0 with nothing on either
channel. `compare_values` was blameless; it was handed the wrong integer.
The digits are kept instead. Three alternatives, and what each costs:
- **Error on a u64 above `i64::MAX`.** Smallest, and it refuses rather than lying,
but it refuses the whole *document*: one oversized number and every other rule
in the file stops being checked. Failing closed on the one value is less than
that.
- **Widen `Value` with an unsigned variant.** Correct in principle, and much
larger than it looks. `Value` is matched exhaustively in the rules parser, both
conversions, `Display` and `Hash`; and for the exactness to reach a comparison
at all, `PathAwareValue::Int` needs the same widening, which lands in every
`compare_*` function -- another change's territory. It would put an exact value
in a type nothing yet compares against.
- **Store it as `f64`.** Trades a sign error for a precision error rather than
removing one: `u64::MAX` becomes 18446744073709552000, so `== 18446744073709551615`
fails and `> 18446744073709551000` passes. Still a wrong answer, only quieter.
Keeping the digits is exact -- `u64::to_string` invents nothing -- it makes a
comparison against a number refuse rather than answer from a number the input does
not contain, and it is the answer the libyaml loader already gives an integer this
wide. That last point is why this and the loader's out-of-i64 handling were decided
together: they are the same defect on two loaders, and they now agree.
The float half is an `Eq` violation that was real rather than theoretical.
`PathAwareValue` asserts `Eq` and `Float(NaN)` is not equal to itself, so
`A == A` reported FAIL through this conversion on a document that PASSed through the
libyaml one. `.inf` was worse than inert: `A > 9223372036854775807` PASSed under
`test` where the same document refused under `validate`. The libyaml loader has
carried the finiteness gate all along, with a comment stating the invariant; this
conversion never had it.
Non-finite values become YAML's own spelling -- `.nan`, `.inf`, `-.inf` -- rather
than `f64::to_string`'s "NaN" and "inf", which no YAML document writes. `serde_yaml`
resolves a float only from those three spellings, bare `NaN` and `inf` staying
strings in both loaders, so the canonical spelling makes the two agree exactly on
every input that can reach the function rather than approximately.
User-visible change, on `guard test` and `run_checks` only: an integer above
`i64::MAX` and a non-finite float are strings, so `is_int`/`is_float` answer false
and comparisons against numbers report the type mismatch.
Both divergences were on the documented exclusion list of
`both_loaders_resolve_the_same_document_to_the_same_value`; they are now in its
document, which covers 34 spellings. `0b101` and the underflowing float remain
excluded, each with its reason.
…e it
Any key that was not a `String` was refused, which refused templates
CloudFormation accepts. A template is converted to JSON before it is deployed and
JSON has no key but a string, so
Mappings:
AccountToEnv:
123456789012:
Env: prod
-- an account id written the way a person writes one -- aborted the run at exit
255, while the same content written in JSON, `{"123456789012": {...}}`, already
loaded. `path_value::list_index_of`'s doc comment presents this as fixed and names
account ids, ports and status codes as the cases; the retrieval half was fixed, and
the template half was unreachable, because a document writing the key the natural
way never got as far as retrieval. It does now.
**This reverses the reasoning I recorded two commits ago**, when the diagnostic for
this refusal was improved. That reasoning was that rendering a resolved value
"invents a name the document does not contain", since an `Int` from `0x1F` renders
as "31". The premise was wrong. CloudFormation resolves `0x1F` as 31 by the same
YAML 1.2 core schema this loader implements and then stringifies it for JSON, so
"31" is exactly the key CloudFormation sees. Rendering the resolved value models the
deployment; rendering the source text would not.
Checked against a YAML-to-JSON round trip rather than asserted -- `yaml.safe_load`
then `json.dumps` gives `{"80":…, "1.0":…, "1.5":…, "31":…}` for the same keys, and
the loader now produces the same five names. A whole float is formatted with its
fractional part, so `1.0` is "1.0" and not Rust's "1", because "1.0" is what that
round trip produces.
`Null` is deliberately not in the set. There is no text the two conventions agree on
-- Python's round trip gives "null", JSON has no such key -- and a document writing
`~:` is far more likely to have lost a key than to want one named after nothing, so
the refusal is the more useful answer. Container and `BadValue` keys stay refused for
the stronger reason that they have no scalar text at all.
Most of this finding's blast radius had already gone: of the ten bool-shaped keys
that aborted the run, `y n Y N on off yes no` were fixed by moving to the YAML 1.2
core boolean set, leaving `True` and `Null`. `True` is fixed here.
Two tests asserted the old behaviour and are changed.
`a_non_string_key_is_refused_with_the_key_named` keeps the keys that have no text and
loses the integer, float and boolean cases to a new
`a_scalar_key_becomes_the_text_cloudformation_would_give_it`, which pins the text each
one produces. `test_graceful_handling_when_yaml_file_has_non_string_type_key` loses
`1: foo` and `1.0: foo` for the same reason, keeping the null key, the sequence key,
and the document that does not parse.
One case in the first test was mine and wrong: `": x"` is not valid YAML -- libyaml
refuses it while parsing a block mapping, so it never reaches the key check. The only
empty-key spelling YAML accepts is the explicit `?` indicator, and it resolves to a
null key, which is the case already covered.
The refusal said "more than one", which leaves the reader to find out whether they have two documents or twenty before they can act on it. It now says the number. Counting means draining the rest of the stream, and the rest of the stream may not parse -- a file whose *later* document is not YAML is one of the shapes this refusal exists for. So the count degrades to a lower bound, "at least 2", when libyaml stops early. That is better than either abandoning the count or letting the parse failure propagate, which would replace a message about document structure with a syntax error from further down a file the user is about to split anyway.
…lure A data file cfn-guard could not read reached `main`'s catch-all, which prints "Error occurred" and exits -1 -- the code `guard/tests/utils.rs` names `INTERNAL_FAILURE`. So a template with an unquoted `~` key, an empty data file, a file that is not YAML, and a multi-document stream all told the user cfn-guard had broken, when each is their own input. `ERROR_STATUS_CODE` is 5, which this repository already gives a ruleset it cannot use. Two commits before this branch moved rules-file mistakes to it for exactly this reason, and their wording is the argument here too: 5 is not 19, so a CI gate still tells a broken input from a violation, and unlike -1 it does not additionally claim the tool is at fault. `guard-ffi`'s error table had already reached the same judgement independently, mapping `ParseError` to its code 5. `ParseError` is the discriminator, and it is safe to classify on because every one of its producers is about the user's input: the rules parser and its let-cycle checks, the libyaml loader, the test-spec readers, the payload deserializer, the data-file reader. Enumerated with `git grep`, which is the only search tool immune to the NUL bytes in `parser_tests.rs`. None of the twenty-odd sites is an internal failure, so there is no arm this over-catches. A *missing* file keeps -1, deliberately. `File::open` and `validate_path` return `IoError` and `FileNotFoundError`, not `ParseError`, so they are untouched -- the same line the parse-tree commit drew when it moved that command's parse error and left its missing-file error alone, on the grounds that validate, test and parse-tree already agree on -1 for a path that does not exist. Both `dne` cases in the status-code table still assert it. The classification is a thin wrapper around the existing body rather than a restructuring of it: `execute` now matches on the result of `evaluate`, which is the former `execute` unchanged. Reported by the command rather than left to `main`, because once this function has decided the failure is not internal, "Error occurred" no longer describes it -- so stderr loses that prefix, which `test_a_data_file_with_no_document_is_reported_as_empty` asserts. User-visible change: `validate` exits 5 where it exited 255 for a data file whose content cannot be read, and the stderr line loses its "Error occurred" prefix. Verified unchanged: a compliant file still exits 0, a violation still exits 19, and a path that does not exist still exits 255 with the same message. Twelve test cases asserted the old code and are changed, with the reasoning at each.
…it reverted `failing_complex_rule.out` and `failing_join_show_summary_all.out` were both changed by "Report the operand a query-versus-query equality actually failed on" and then again by "Count data-file lines and columns from one". The second regenerated twelve fixtures from the binary, and that binary did not contain the first -- the two were written on the same base and neither could see the other. So the committed text carries the 1-based line and column numbers and, underneath them, the operand reporting the earlier commit had already removed. Regenerating from a binary is the right way to update a captured output and the wrong way to update one a sibling branch has also touched: it reinstates whatever that sibling removed, silently, and the result still looks internally consistent. Both files now come from the combined binary. Each differs from the committed version in exactly the two lines the operand commit had changed, and in no others, so the line numbers and excerpt windows the numbering commit established are untouched. What the restored text says, and why it is the true statement: - `failing_complex_rule.out`. `%expected == %replaced` with `%expected` the literal "random_str" and `%replaced` a regex_replace of the Arn. `ComparedWith` is `["random_str"]` and the reason names `Path=[L:0,C:0]`, because a rule literal is built with `Path::root()` and has no position in any file. The committed text printed the Arn as both `Value` and `ComparedWith` -- a failed equality whose two operands it reported as the same string, asserting the Arn was absent from a set holding the Arn. - `failing_join_show_summary_all.out`. `"a,b" == join(%collection, ",")`. The left operand in this position is a query for a property named `a,b`, which is why the check renders unquoted as `a,b EQUALS join(...)`; the template has no such property, so the operand set is empty and the diff of its unmatched values is `[]`. `Value` and `PropertyPath` still name the join result, because `QueryIn::from_rhs` places the finding on the side a reader can act on. The committed text again printed one value as both sides. Markers checked against the template by counting rather than by trusting the binary. In `functions/data/template.yaml`, line 10 is ` Arn: arn:aws:...` and its value begins at column 12; line 15 is ` - a` and the `a` is at column 9. The fixtures say `L:10,C:12` and `L:15,C:9`. The excerpt beside each marker runs n-2 to n+3 and each numbered line holds the template's text for that line. No product code changes. validate: 211 passed 4 failed, to 213 passed 2 failed, with no other test in the target moving.
…erts its message
`a_payload_that_will_not_parse_says_where_it_came_from` asserted `INTERNAL_FAILURE`.
It was written when that was the only outcome available: `deserialize_payload`'s
`Error::ParseError` escaped `validate` and reached `main`'s catch-all, which prints
"Error occurred" and exits -1. A later commit on a sibling branch classified every
`ParseError` out of `validate` as the caller's input and gave it `ERROR_STATUS_CODE`,
naming the payload deserializer as one of the producers it meant to cover. Neither
commit could see the other, so the code moved and the assertion did not.
The classification is the side that is right, and this case is the one it argues for
most directly. Empty stdin under `--payload` is the CI shape the message was rewritten
for; -1 is the code this repository reserves for cfn-guard breaking, so the old pairing
told an author who forgot a pipe that the tool was at fault. 5 is not 19, so a gate
still tells a broken input from a violation.
The message assertion needed nothing. `deserialize_payload` still names the flag, the
stream and the expected shape, and the reclassification only drops `main`'s prefix:
Parser Error when parsing `Unable to parse the --payload JSON read from stdin: EOF
while parsing a value at line 1 column 0. Expected {"rules":[...], "data":[...]}`
which is the same form `test_a_data_file_with_no_document_is_reported_as_empty` pins
for an unreadable data file.
Added an assertion that stderr does not carry "Error occurred". The code and the
message are set in two different places -- the code by `execute`'s classification, the
prefix by whether `main` ever sees the error -- and this pairing is what the two
branches disagreed about, so it is worth holding rather than inferring.
validate: 213 passed 2 failed, to 215 passed 0 failed.
rulegen emitted `""` for a null. That was right against a loader which resolved an
empty YAML node to the empty string, and a sibling commit made an empty node resolve to
null -- deliberately, so that `k:` and `k: ""` stop being indistinguishable. The two
crossed over: `"NULL"` against `""` is `not comparable null, String`, and every rule
generated from a template with an empty `BucketEncryption:` failed the template it was
generated from. Three fixtures under guard/resources do this, and the round-trip test
the rulegen commit added is what named it.
Fixing the null arm alone would have been wrong, because the null arm is not the only
place the two readers disagree. Measured over 36 one-property templates, one per YAML
spelling, before this change:
template writes loader reads rulegen emitted round-trip
P: / P: null / P: ~ null "" fails
{Inner: null} / [a, null] null, nested "", nested fails
P: .nan / P: .inf the text ".nan" "" fails
P: 1e-400 the text "1e-400" 0.0 fails
P: 0b101 the text "0b101" 5 fails
P: 9223372036854775809 the text a 19-digit int does not parse
<<: {A: 1} the merge resolved a key named "<<" fails
!Ref / !GetAtt / any !Foo {Fn::Foo: ...} -- abort, exit 1
Ten emitted a clause the source template does not satisfy. One emitted a file that does
not parse, which the re-parse check then discarded. Five aborted the process at exit 1 --
that is every CloudFormation template using a short-form intrinsic, because serde_yaml
will not deserialise a tagged node into serde_json::Value at all.
**Reconciling the two readers by hand is not possible**, and that is why this reads once
rather than twice. serde_yaml maps `.nan` and an empty node to the same `Value::Null`,
and the loader reads the first as the string ".nan" and the second as null. No rendering
rule over serde_json::Value can be right for both, because the distinction is gone
before rendering starts. `!Ref` is the same shape one level up: one reader has no value
at all where the other has a mapping.
So `generate_rule_map` now loads through `rules::values::read_from` and
`PathAwareValue::try_from` -- the two calls `validate`'s `build_data_file` makes -- and
`value_to_guard` renders a `PathAwareValue`. The value rendered is the value compared,
so a future resolution rule in the loader cannot desynchronise them. `PathAwareValue`
and not the loader's `MarkedValue`, because the conversion between them is where a
mapping's duplicate keys collapse last-write-wins, and a template writing `P` twice must
generate a clause about the value the evaluator will see.
Each rendering was measured by generating the clause and validating it against its own
template. Two need saying:
- a null is `null`, at any depth.
- a whole float keeps its fractional part. `f64::to_string` drops it, and `1e20` then
comes out as 21 digits with no point, which the rules parser rejects as an integer too
wide for i64. With the point it round-trips, checked up to 1e300 and down to 1e-300.
Everything the loader keeps as text -- the too-wide integer, `.nan`, `.inf`, `1e-400`,
`0b101`, `0755` -- now renders through the string arm and needs no arm of its own. So do
the intrinsics: `!GetAtt Other.Arn` becomes `{"Fn::GetAtt":["Other","Arn"]}` and matches.
All 36 spellings round-trip now, 35 as a passing clause and one refused with its reason.
Two other changes fall out of this and are not separable from it:
`process::exit(1)` on an unreadable template becomes `ERROR_STATUS_CODE`. Three call
sites exited 1 from inside a library function: a code that collides with
`TEST_ERROR_STATUS_CODE`, that rulegen does not document, and that an in-process caller
cannot survive. `generate_rule_map` returns `Result` and `execute` reports it, which is
the shape `validate` uses for the same class. A template that does not *exist* keeps -1
through the `?` on `read_to_string`, which is the line validate draws too.
`parse_template_and_call_gen` returns `Result<RuleMap>`; it is public but
`#[allow(dead_code)]` and only the unit tests call it.
The round-trip test's candidate filter widens. It read `HashMap<String,
serde_json::Value>` to match what rulegen parsed with, justified as keeping the walk away
from the `process::exit(1)`; both halves of that are now obsolete, and what is left is
that it was the narrower reader -- it could never admit a template using `!Ref`. It now
deserialises only a `Resources` field, ignoring the rest of the root, so an
`AWSTemplateFormatVersion` string does not disqualify a file. The logical ids go through
a HashMap and not a serde_yaml::Value, because a Value refuses a duplicate mapping key
and `duplicate-logical-id-template.yaml` declares one on purpose -- reading it as a Value
silently dropped that template, taking the candidate count from 35 to 34. The final
candidate set is byte-identical to the old one, 35 files, so the widening costs nothing
today and stops the walk from skipping the first intrinsic-using template someone adds.
Two unit tests asserted the old rendering and now assert `null`. The reason each gives is
the one above: the old expectation was correct against the old loader.
Suite: 1806 passed 5 failed, to 1811 passed 0 failed across all 16 targets. Round-trip
over guard/resources: 35 candidates, 28 round-trip, 7 refused at PARSING_ERROR -- four
carry no properties, two carry them only on a resource with no Type, and one is the
boolean-observed-both-ways refusal. clippy --all-targets -D warnings clean, fmt clean.
…er than fatal
The recursive descent in `parser.rs` recurses once per level of nesting and had no bound,
so a deep enough rules file killed the process. Measured on this repository's release
build, `parse-tree --rules` on a file of nested block clauses: 1601 levels parse, 1602
aborts with SIGABRT and "fatal runtime error: stack overflow" on stderr -- reported as 134
by a shell, which is outside the set this tool documents, so a caller checking for 0, 5 or
19 got neither a pass nor a failure it could report. `validate` and `test` die on the same
file, `validate` at a shallower depth than `parse-tree` because it does more work on the
same stack.
Same defect class as the `let` cycle and rule-reference cycle checks, and the same
argument: an authoring mistake that aborted the process instead of being refused.
Nested blocks are not the only recursion that reaches it, and that is why the level is
opened in three places rather than one. Four spellings were measured to abort, at four
different depths:
nested block clauses parses 1601, aborts 1602
nested `when` blocks parses 2000, aborts 4000
nested map literals parses 2000, aborts 4000
nested list literals parses 4000, aborts 8000
The last three are coarse -- the ladder was 2000/4000/8000 -- and the exact threshold does
not matter three orders of magnitude below it. The four share no single function: `block`
covers the first two, and list and map literals do not pass through it. A bound in `block`
alone would have left the value literals fatal.
A fifth recursion is deliberately not bounded. A query filter (`q[ q[ ... ] ]`) recurses
through `predicate_filter_clauses` and never gets deep enough to abort, because it becomes
unusable first: timed on this build and on the one before it, level 14 takes 0.25 seconds
and each further level doubles it -- 0.48, 0.97, 1.92, 3.85, 7.66, 15.46, 30.67 at level
21, every one exiting 0. It is exponential in the depth, so level 128 would be 2^107 times
30 seconds. A bound at any value that admits real files could never fire on it, and one low
enough to fire would be a workaround for the backtracking rather than a depth bound. That
is a separate defect and it needs a separate fix.
128 is the value `libyaml::loader::MAX_NESTING_DEPTH` already enforces on the other kind of
input this tool reads, and there is no reason the product's answer to "how deeply may input
nest" should differ by file kind. It is far above anything real, measured by the parser
rather than by counting braces -- braces appear in strings, regexes and comments, so a
throwaway build reported the deepest level the guard actually opened per file. Across both
corpora, every `.guard` and `.ruleset` in this repository and in the rules registry
snapshot, 318 files, the deepest is 6 levels; 172 of the 318 reach only 1. And it is far
below every abort above, the nearest at 1602. Nothing between 6 and 128 is a file anyone
writes, and nothing between 128 and 1602 was going to be evaluated anyway.
Where the count lives was decided against two alternatives. Threading a `depth: usize`
through the parser would change the signature of every function on all four recursion paths
-- `clause`, `access`, `cnf_clauses`, `disjunction_clauses`, `parse_value` and everything
between -- and of the `pub(crate)` ones the tests call directly, to carry one integer, and
would break `alt((clause, rule_clause))` where both arms must have one shape. Carrying it in
`Span`'s `extra` looks better because `Span` is `Copy` and a nested call would get depth + 1
for free, until the return path: the remaining span comes back holding the incremented
depth, so every construct would have to restore it, and one missed restore makes the depth
ratchet up monotonically -- a failure that reads exactly like a correct implementation until
a legal file is refused. A thread-local count with an RAII guard is three call sites and no
signature changes; `Drop` is the only decrement, so an `alt` arm that opens a block and then
fails unwinds and leaves the count where it found it; and the count does not vary with which
thread parses, which matters because `cargo test` parses on libtest's threads rather than on
`main` and a limit derived from the stack would admit a different set of files under test
than in the binary.
User-visible change: a rules file nested more than 128 levels deep is refused with a message
naming the construct, its position and its level, at the exit code every other rules-file
error already uses -- 5 under `validate` and `parse-tree`, 1 under `test`. That 1 is not new
here: an ordinary syntax error, a rule reference cycle and a duplicate file-level `let` all
give `test` 1 and `validate` 5 both before and after this change.
Measured after: no 134 on any of the three commands at any depth from 129 to 8000, where
before this change 1650 aborted on all three. The boundary is pinned at 127, 128 and 129 in
each of the four spellings, because an off-by-one there would either refuse a file one level
inside the documented limit or admit one past it, and neither shows up in the deep cases. 500
rules of 4 levels each is the control that says the bound is on nesting and not on how much
of the file is nested. And one test parses a legal at-the-limit file, then four refused ones,
then the same legal file again -- a guard leaking on the failure path shows up in that order
and in no other.
Both corpora sweep clean: 0 changed cells out of 318 files, comparing exit code and both
streams, which is what catches a diagnostic moving as well as a verdict moving.
…, in `RuleRefs`
The doc comment on `trait RuleRefs` said their clause parsers "take access clauses only", so
`rule a { AWS::EC2::Volume { a } }` and `rule a { Resources { a } }` are both syntax errors,
and offered that as the reason two arms of the walk recurse rather than being folded into the
`Clause(_)` catch-all: "the type permits what the parser does not produce".
The parser does produce it. Both bodies take `block(clause)`, and `clause` is an alternation
of five arms of which three are not access clauses -- a nested `block_clause`, a `when_block`,
and `parameterized_rule_call_clause`. So a rule reference reaches both bodies today, in two
spellings, each measured rather than reasoned about:
rule a(t) { Resources { a(%t) } } Rule a references itself
rule a(t) { AWS::EC2::Volume { a(%t) } } Rule a references itself
rule a { Resources { when a { Type exists } } } Rule a references itself
rule a { AWS::EC2::Volume { when a { ... } } } Rule a references itself
and `parse-tree` shows the shape rather than the consequence: a `BlockClause`'s
`block.conjunctions` holding a `ParameterizedNamedRule`, and holding a `WhenBlock` whose
conditions hold a `NamedRule`.
The two examples the comment quotes are the one spelling that genuinely is absent. `clause`
has no `GuardClause::NamedRule` arm, so a bare rule name in either body is a syntax error at
the `{` -- and that single absence is the whole of it. It is also the arm the comment claimed
the type permits and the parser never produces, which is exactly inverted.
The arms stay; what changes is the recorded reason. They are live code carrying references in
two spellings, not a tripwire kept against a hypothetical grammar change. Deleting the false
sentence and leaving the rest was the alternative, and it is worse: it loses the reason the
arms exist, which is the thing the next reader needs before touching them.
Nothing pinned those two positions, so acting on the comment and folding either arm into the
catch-all as dead code would have left every test passing while restoring an undetected
cycle. `a_cycle_through_a_block_clause_or_type_block_body_is_reported` covers the four cases
above by message rather than by `is_err()`, because a syntax error and a cycle are both errors
and which one it is is the entire point. It carries controls in both directions: the same two
shapes naming a different rule parse, and the two bare-name spellings are asserted not to be
cycles. Writing the first control as `rule a(t) { Resources { b } }` is how this is easy to
get wrong -- that bare `b` is a syntax error too -- so the control keeps the call spelling and
says why beside itself.
8b3ac32's commit message carries the same false claim, with a "measured rather than assumed"
badge on it that discourages re-checking. That cannot be corrected in place; this message is
where the correction lives.
The `(Char, String)` arms added to `compare_values` last round made `eq` relate
`Char('b')` to `String("b")` -- `PartialEq`'s fall-through delegates to
`compare_values` -- while `Hash` kept hashing a `Char` through `Hash for char`
and a `String` through `Hash for str`. For `'b'` the hasher was handed
`62 00 00 00` in one case and `62 ff` in the other, so two values `eq` calls
equal had different hashes and the type stopped satisfying the `Eq` it asserts.
This is the same contract the same round had just fixed for `Map` entry order,
three commits earlier. The `impl Eq` doc comment was edited in the commit that
added the arms and still claimed the only relation reached through
`compare_values` was numeric widening, "and `Hash` agrees with it" -- true of
the relation it named and false of the one being added beside it.
No live consumer is affected and the commit claims none. Exactly one hashed
collection in the crate keys on a `PathAwareValue`: `by_lhs_value` in
`report_at_least_one`, whose sole caller dispatches under
`MapKeyComparator::In | NotIn`, so its keys are map keys and always
`PathAwareValue::String`. Swept with `git grep` rather than `grep`, because
`parser_tests.rs` holds six NUL bytes and plain `grep` reports no matches in it
at exit 1. 144 collection declarations across `guard/src`, `guard-ffi` and
`guard-lambda`, none other keyed on the type. So this is a latent unsoundness,
not a wrong verdict.
Fixed by making `Hash` agree rather than by withdrawing the relation. The
alternative was dropping the two `compare_values` arms, and it costs the round-I
F3 fix: there is no `Char` literal in the rules language, so a `Char` would go
back to comparing with nothing a rules file can write, the documented example
for `parse_char` in `docs/FUNCTIONS.md` would fail again for every input, and
eight cells of the operator matrix would revert. Solving F3 another way means
either `parse_char` returning a one-character `String` -- which breaks
`%c in r[a,z]`, the other half of what the function is for, since a `String`
does not compare with a `RangeChar` -- or adding a `String`-against-`RangeChar`
arm, which is the wider "does `Char` belong in the value space" change the arms'
own comment flags as open. Withdrawing a shipped usability fix to close a hole
with no live consumer is the wrong trade.
`encode_utf8` into a stack buffer rather than `to_string()`: the bytes go
through the same `str` impl the equal `String` uses, and it does not allocate.
`equal_values_hash_equally` covered `Int`/`Float` and `Map` order, which is why
it stayed green over the new arms. It now covers `Char`/`String` including
multi-byte and four-byte characters, the pair nested in a list, and three
negative controls. Verified by reverting only the `Hash` arm: the test fails on
"a char against the string that spells it".
The `impl Eq` comment now names both widenings and states the standing
obligation -- an arm added to `compare_values` widens `eq` and has to be matched
in `Hash` in the same commit.
…e diff
`!=` between a left operand carrying the root path and a document query failed
for two values that are comparable and unequal. `parse_int("7") != 1` exited 19
on a compliant template, and the reason read that `property [[L:0,C:0]] was not
present in [...Value=1]` -- a "was not present in" message under a `!=` clause,
about a subject the reporter could not place.
The negation wrapper computed `reverse_diff(qin.diff, &qin.lhs)` on a stated
premise: a diff taken from the right operand means the left had no unmatched
values, so every left value survives the filter and `!=` correctly fails. That
premise stopped holding when `EqOperation` gained the `placeable` guard in the
same commit. `placeable` is `any(|v| !v.self_path().0.is_empty())`, so it is
false for a non-empty set of root-path values -- which is what every
`parse_int` / `parse_char` / `parse_string` result and every rule parameter is --
and the middle branch then takes the right-hand diff *with the left still
holding unmatched values*. `lhs \ rhs_unmatched` for two disjoint operand sets is
all of `lhs`, so the reversed diff was non-empty and the clause failed.
`QueryIn::from_rhs`'s own doc comment stated the invariant the caller was
breaking: "only when every left-hand value did find a match".
Fixed by carrying `lhs_unmatched` on `QueryIn` and negating against that. The two
questions the struct was answering with one field are different: `diff` is what
the report should name, `lhs_unmatched` is which left-hand values failed, and
they are the same set only when the diff came from the left. Where the old
premise did hold the answer is unchanged -- with every left value matched,
`lhs_unmatched` is empty and the filter keeps all of `lhs`, which is what
`lhs \ rhs_unmatched` also gave, because a right-hand value with no equal on the
left cannot be equal to a left-hand value either.
Rejected: gating `placeable` on `lhs_unmatched.is_empty()`, which also restores
the wrapper's premise but costs the `==` reporting the guard was added for.
Measured rather than assumed -- for `%expected == %query` with `expected` a rule
parameter, this branch reports `ComparedWith = [9]` against the document property,
where the pre-round binary said `One was not present in [1]`, naming the value it
had just printed. Keeping both is what the extra field buys.
Verified: `iso2.guard` restores all eight cells to the pre-round verdicts,
`param.guard` returns to PASS at exit 0, `radius.guard`'s `!=` is the only one of
eight operators that moves, and `dup.guard`'s `==` stays FAIL so the
duplicate-values fix from the same round is intact.
`not_eq_passes_for_an_unreportable_left_operand` pins it, with the polarity
control (`56 != 56` still fails) and the `==` control that asserts
`diff_from == Rhs` and a placeable diff. It builds the left operand as a
hand-made `QueryResult::Resolved` with `Path::root()`, because that is what a
converter produces: a bare `let x = 7` arrives as `QueryResult::Literal` and
short circuits through `is_literal` before any diff exists, which is why
`test_operator_not_eq` and the obvious fixture both pass either way. Reverting
only the one expression fails the test on the unequal case.
`==` between two queries that both fails on several values and hits a pairing it cannot answer reported one value. `%a.Properties.V == %c.Properties.V` over `[1, 5]` against `["p", "q]"` named `A1` / `Value = 1`; `A2` / `Value = 5` appeared nowhere in the output, so a template with N offending properties took N runs to fix. The `(false, Some(..))` arm returned `not_comparable_because` and dropped `query_in`, and every other unmatched value went with it. The pre-round code reported one entry per failing value -- it named the wrong side, which is what F10 fixed, but it did not lose values. Second defect in the same arm: `unanswerable` recorded the first error seen anywhere in either sweep, including one hit on the way to a match found afterwards. `each` can refuse against one element of `against` and then match a later one; the `continue 'each` skips the remaining comparisons but not the error already stored. So `[1]` against `["x", 1]` failed on the right-hand extra and reported that `1` is not comparable with `"x"` -- a pairing that decided nothing, since `1` matched. Now held per value and promoted only once that value turns out to have no equal, so the pairing named is always one that failed. Same fixture after: the refusal names `B1` / `"x"`, the value with no equal. The refusal now replaces the diff only when the diff names exactly the one value the refusal already names. That keeps the single-value case -- the common one -- byte-identical, and `test_operator_eq_vs_in_from_queries` passes unchanged, which is evidence it was pinning that case rather than the defect. No existing test needed changing. Two results rather than one reason carried on the diff, and this is the one place the narrower shape lost. `InComparisonCheck` has a `message` field, so attaching the reason there gives one finding per value with the reason on the right one. It was tried and measured: `common.rs:871` maps that field to `NameInfo.message`, the author's custom-message slot, where the `Comparison` variant beside it maps to `NameInfo.error`. The reason then disappears from the human output and from `--output-format json --structured` alike -- 0 occurrences of "not comparable" against 2 before -- which is `7df7617`'s reason reporting undone. Making it render means changing that mapping, which also currently discards `custom_message` for this variant; that is a separate defect and a wider change than this one. The refusal stays a `NotComparable` and is pushed first, because the negation wrapper passes that through untouched and inverts `Fail`. Verified rather than assumed: `!=` over two wholly incomparable operand sets is FAIL on this branch and on the reviewed tree, and PASS at exit 0 on the pre-round binary. Verdict-neutral. All 1536 cells of the operator matrix are unchanged in both verdict and reason text, because every cell there is a single-value clause. `a_refused_pairing_does_not_swallow_the_rest_of_the_diff` pins both defects, the single-value case, and the `!=` polarity. Each half was checked by restoring that half alone: reporting only the refusal fails the "diff beside it" assertion, and recording inside the inner loop fails the "value that found no equal" assertion.
… change that The comment on the two `is_nan` guards asserted a live failure: "with a `Float(NaN)` in the document, `A not in r[-1,1]` succeeded, so a denylist admitted a value that is not a number." That was true when it was written and is not now. `335cb34`, later in the same round, gave the serde loader a finiteness gate (`values.rs:317`), so a non-finite float becomes the string `.nan` / `.inf` / `-.inf` instead. A reader reproducing the scenario finds it does not reproduce and concludes the comment is wrong about something more important than it is. Enumerated rather than asserted. Every way a `Float` is built from input gates finiteness: the serde loader above, the libyaml loader at `libyaml/loader.rs:215`, the rules-file float literal at `parser.rs:427` which is a parse error, and `parse_float` at `functions/converters.rs:40` which yields no value. `TryFrom<&serde_json::Value>` at `values.rs:382` has no gate, and cannot need one -- JSON has no NaN spelling and `serde_json::Number::from_f64` rejects one. Confirmed at the CLI: `1e400` in a JSON data file loads as the string `"1e400"`, and `not in r[-1.0,1.0]` against it FAILs rather than passing. The arm stays, and stays a refusal rather than `unreachable!()`. What makes it unreachable is a property of the four gates and not of this match, so it returns the moment any of them goes or a fifth construction site appears -- the same argument the `Err(_) => false` arm in `PartialEq` already makes for itself, and `unreachable!()` would turn each of those changes into a panic inside a comparison. The comment now states which gates it depends on, so the next person to touch one can see what they are turning back on. Comment only; no non-comment line changed.
…t-wise `compare_eq_symmetric`'s comment bounds the change carefully -- "Exactly the five pairings the table has, and no others" -- and reasons about which callers the swap reaches. It does not mention that one of those callers distributes over a list. `EqOperation`'s literal-on-the-left branch unwraps a right-hand `List` and compares the left operand against each element, so with a range on the left the swap turned `%range == <query yielding a list>` from a refusal at exit 19 into element-wise membership at exit 0. A comment that claims to have enumerated a change's consequences and misses the widest one is worse than no comment. No behaviour change, and the reasoning for that is now in the comment rather than only in a review. Both alternatives are worse. Refusing on this side restores the asymmetry the swap exists to remove: the mirror direction, `<query yielding a list> == %range`, has distributed element-wise since before the round through the same unwrapping in the opposite branch, and it passes on the pre-round binary too. Making the literal spelling decide instead would make two more comparisons start deciding and widen element-wise `==` further. Probed rather than assumed, because "a comparison started deciding" is the risk that dominates this file. The new answer discriminates: `r[0,10]` against a query yielding `[1, 99]` fails, and against `[1, "x"]` fails in both polarities, so it is not a rubber stamp. A one-element list answers the same way a bare scalar does, and the bare-scalar cell is the F2 fix nobody disputes. The open question is recorded where the next person will find it: the literal spelling `%range == [1, 2]` refuses in both operand orders on both sides of the round, so the language answers this comparison for a query and refuses it for a literal. That axis pre-dates the swap, which widened it by one cell. Settling it means deciding whether `list == range` ever meant membership. Comment only; no non-comment line changed.
The merge test ran on the resolved key string, and by that point `"<<"` and
`<<` are both `String("<<")` -- `handle_scalar_event` puts a non-plain scalar
into `MarkedValue::String` and nothing carried the style forward. So a
document writing `"<<": plain string value`, an ordinary pair, was refused at
exit 5 for not giving the merge key a mapping.
YAML resolves the merge type from the plain scalar only: the merge key is
`tag:yaml.org,2002:merge`, an implicit resolution, and implicit resolution
applies to the plain style. Quoting is the documented way to write a key
literally named `<<`, so treating the quoted form as a merge both misread a
valid document and removed the one escape hatch for the name. PyYAML on the
same bytes keeps `{"<<": "plain string value"}` and merges the plain
spelling; cfn-guard answered both the same way.
The style is only known in `handle_scalar_event`, so the decision is recorded
there: `merge_key_index` holds the stack index of each plain untagged `<<`,
and `handle_mapping_end` tests position instead of name. The indices are
dropped when a container closes, because a `<<` written as a sequence item
rather than a key would otherwise be read as a merge key of whichever mapping
next occupied that index.
Measured, `merge-nonmap-quoted.yaml` against `merge-nonmap-plain.yaml`: the
quoted arm goes from exit 5 to loading with the key intact, the plain arm
still refuses at exit 5 with the same message and position, and every other
merge fixture -- explicit-wins, a sequence, nesting, and each unmergeable
value -- is unchanged.
… resolves
`apply_merges` claimed each name as it inserted it, so within one merge source
the *first* of two same-named entries won and the second was dropped. A plain
mapping does the opposite: the loader keys its map on `(name, location)`, so
both entries survive it and `path_value::try_from_marked` collapses them
last-wins and warns. Dropping the loser here also silenced that warning,
because `try_from_marked` is the only place a duplicate is visible.
So `<<: { Type: FIRST, Type: SECOND }` gave `FIRST` and said nothing while
`Type: FIRST` / `Type: SECOND` gave `SECOND` and warned -- one malformed shape
with two answers, and the quieter one on the spelling that is harder to read.
PyYAML gives `SECOND` for both. Claiming names once per source rather than per
entry lets the duplicate through to `try_from_marked`, which restores the
convention and the warning; skipping names already claimed by an *earlier*
source still gives the spec's earlier-entry-wins for a sequence of merges.
Two `<<` keys in one mapping changes with it, from earlier-wins to
later-wins. That is a duplicate key, which is what the previous reasoning
missed by analogising to the sequence rule instead, and the last value is what
cfn-guard does with a duplicated key everywhere else. PyYAML agrees. The other
loader in this product does not settle it either way: measured,
`serde_yaml::from_str` refuses a duplicate key outright -- `duplicate entry
with key "<<"` -- so it never reaches its merge handling at all, and this is a
divergence between the two loaders on duplicate keys generally rather than on
merges. The rstest case asserting earlier-wins encoded the old choice and now
asserts the new one.
Not restored: the "duplicate key `<<`" warning the pre-merge-fix loader
emitted for two `<<` keys, because both are removed from the mapping before
`try_from_marked` sees it and the loader has no warning channel of its own --
threading one changes `read_from`'s signature and every caller. The surviving
value now matches PyYAML, and the document is malformed either way.
Measured on the five merge fixtures: `merge-dupkey-inside` goes FIRST -> SECOND
and regains the warning, `merge-two-keys` goes FIRST -> SECOND, and
`merge-dupkey-plain`, `merge-both`, `merge-several` and `merge-nested` are
unchanged.
libyaml emits a whole document -- start, empty scalar, end -- for a `---` with nothing after it, so the "any second `DocumentStart`" test made a file of one template plus a trailing separator a two-document stream. The refusal was wrong twice over: it said "holds 2" for a file holding one document, and the one instruction it gave -- split them into separate files -- cannot be carried out, because there is nothing at the `---` to put in the second file. A trailing separator is what a generator or a concatenation leaves behind. `count_remaining_documents` now counts the documents that hold something and reports where the second such document starts, returning `Separators` when every document after the first holds nothing. `load` then answers with the document it has. It drains the stream before deciding, so returning there cannot report compliance for bytes libyaml never read, which is the defect the refusal exists for; the "at least n" lower bound for a stream that stops parsing is kept for the same reason. "Holds nothing" is the empty node specifically -- no tag, plain style, no characters -- and not everything the loader resolves to `Null`. `~` and `null` are written, so a document holding one still counts, which is asserted. Measured against `9fee1a2` on rj-loader's fourteen document fixtures: the two trailing-separator files go from exit 5 back to exit 0, `---`+`---` goes from 5 back to 19, and the real streams of 2, 3 and 4, the `...`-then-`---` file, the malformed second document, the leading `---`, the trailing `...`, the comment-then-`---` file and the comments-only file are all unchanged. Two new edges measured: a separator *between* two real documents said "holds 3 -- the second starts at" the separator and now says "holds 2" at the second real document, and a trailing document holding a written `~` is still refused.
…ring Narrowing `parse_bool` to the YAML 1.2 core set made the `!!bool` arm's fallback reachable, and the fallback returned a string. So `!!bool yes` became the string "yes": the one answer that neither reads the node as the boolean the author explicitly asked for nor says it could not be read as one. Two arms below, `!!int abc` is a `BadValue` reported at exit 5, so an out-of-set payload under an explicit type tag was a hard error for `int` and a silent type change for `bool`. `serde_yaml` decides this the same way, measured payload by payload through `guard test`: it accepts exactly the six spellings `parse_bool` accepts under `!!bool` and refuses every other payload outright -- `y`, `on`, `off`, `no`, `0`, `1` and `tRuE` included. So the set was already right and only the fallback was wrong, and refusing here closes a divergence between the two loaders rather than opening one. PyYAML also errors on an out-of-set payload, but takes the YAML 1.1 words, so `!!bool yes` is true there. Following that was the alternative and it is rejected: PyYAML is a 1.1 implementation, and accepting 1.1 words under a tag while refusing them plain would put a document's boolean vocabulary under the control of whether a tag was written -- the same defect as the one the tagged path had when it was stricter than the plain one, mirrored. The comment on the arm claimed "a bare `yes` was already a boolean", which the same round had already made false. It now says what the code does and what was measured. `!!bool` appears in no data file in either corpus, so the blast radius is the `!!bool "false"` in `yaml_loader4`, whose payload is in the set and is unchanged.
The round that rewrote the libyaml loader added a test to hold the two loaders
to the same answer, and left three of its own value-model fixes on one side of
the boundary. Two of the three are landed here.
`values::read_from` is reached only by `validate`. Everything else goes through
`TryFrom<&serde_yaml::Value> for Value`: `commands::helper::validate_and_return_json`,
which is re-exported as the public `run_checks` and is what `guard-ffi` and
`guard-lambda` call, and the `test` command's spec `input:` blocks. So until
now:
- a template whose `Type` arrives through `<<` was invisible to
`Resources[ Type == ... ]` through `run_checks` -- the silent wrong SKIP
the libyaml fix existed for, still live through the FFI and the Lambda.
- a `Mappings` block with an unquoted account id, file mode or bitmask as a
key loaded under `validate` and was refused outright under `guard test`,
at exit 255.
`merge_into` and `scalar_key_name` mirror `apply_merges` and
`stringify_scalar_key`, precedence and rendering both, and `MERGE_KEY` is now
shared rather than spelled twice. `serde_yaml::Value::apply_merge` was the
alternative and is not used: it would have to be called at every site that
reaches this conversion, so a new site would silently miss it, and it merges a
quoted `"<<"` for the same reason this conversion cannot avoid doing so.
The third fix, a float literal that underflows to zero, cannot land here.
`serde_yaml::Number` retains no source text, so `1e-400` arrives as
`Float(0.0)` and is indistinguishable from a literal `0`; refusing every
`Float(0.0)` would refuse a legitimate one, which is worse. It is not alone:
`serde_yaml::Value` is a resolved value model that has already discarded scalar
style and anchors, so a quoted `"<<"`, a YAML alias and a duplicate key are all
divergences this boundary cannot close either. The pin's doc comment now lists
all of them, says which are choices and which are limits, and names the only
cure -- one loader rather than two -- with its price: moving
`validate_and_return_json` onto `read_from`, the way `rulegen` moved in
fffbacf, costs the aliases `run_checks` accepts today, and `guard test` needs
more than that because a spec's `input:` is a `serde_yaml::Value` by the time
the command has it. Not done here.
The pin's document grows a `Mappings` block of eight non-string keys and two
merged mappings, one overriding an explicit key and one a sequence. Measured:
that document fails the assertion against `values.rs` as it stood, and passes
now.
`guard-ffi`'s `a_non_string_key_reaches_the_entry_point_as_a_parse_error`
asserted that all four key shapes come back as a parse error, which encoded
the refusal. Two of them -- a number and a boolean -- are now read, matching
`validate`; a null and a sequence key stay refused. The test is renamed for
what it still holds, that nothing panics, and carries the split.
The comment on this arm argues the change on the `k:` versus `k: ""` axis, which is the axis it was decided on, and says nothing about the other consequence: `empty` is refused on every non-container scalar, `Null` included, so moving the empty node here moved the most common spelling of a null out of the one scalar shape `empty` could answer. `empty` and `!empty` over a `k:` are now both FAIL. An explicit `null` already behaved that way, so the change widened an inconsistency rather than creating one, and the refusal itself is deliberate -- the three states have three predicates, and making `empty` hold for a null would make null the one scalar it answers for. That decision is not reopened here. What is new is a verdict flip with no edit to the template or the rule: a `!empty` inside a filter used to select nothing and SKIP at exit 0, and now bails and FAILs at exit 19, because the refusal is an error rather than a verdict. That is what turns a CI gate red, so the comment now carries it, together with both sides of the denominator, measured: 79 empty-valued keys in 13 of 246 registry data files and 5 in 4 of 115 here, against `empty` at bracket depth in a `.guard` source 8 times in 7 of 210 registry files and once in 1 of 108 here. Documentation only. No behaviour changes and no test changes.
`describe_key` and `stringify_scalar_key` sit twenty lines apart and took
opposite positions on whether a non-string key can be accepted at all.
`describe_key`'s was the stale half -- it still said rendering a resolved value
would "invent a name the document does not contain" and that accepting these
keys needs the source text carried through the value model, both of which the
function below it had already contradicted -- and it is the one the compiler
sends a reader chasing the refusal message to. It now says what it is for and
points at the decision rather than restating a reversed version of it.
`stringify_scalar_key`'s own justification was too strong in the other
direction. It claimed the resolved value "is exactly the key CloudFormation
sees" because CloudFormation resolves by "the same YAML 1.2 core schema this
loader implements", and this loader deliberately does not implement 1.2 core for
one spelling: `0755` is decimal 755 under 1.2 core and stays the literal here,
because 1.1 reads it as octal 493 and resolving either way invents a number.
Reading `0755 -> "0755"`, `0x1F -> "31"` and `1.0 -> "1.0"` as three conventions
is the trap that premise sets, and it is one rule: the key is the text of the
value the scalar resolved to. A `String` key never reaches this function --
`handle_mapping_end` has its own arm -- so `0755` is "0755" because the value
*is* the string "0755". The key inherits `resolve_int`'s trade rather than
contradicting it, which is the property a rule author needs: `0755` compares as
"0755" and looks up as "0755".
The float rendering is recorded against the renderer that disagrees with it,
which is not this one. `{f:.1}` matches `PathAwareValue`'s `serde_json`
conversion, so `-o json` prints the value `1.0` beside the key `"1.0"`;
`display::ValueOnlyDisplay` formats a float with Rust's `Display`, so the human
text report prints `1`. Left alone on measurement: whole-float values number 6
across both corpora and non-string keys 0, so changing the report's float
rendering would alter human output in the reporters' code to fix a collision no
file in either corpus can reach.
Documentation only. No behaviour changes and no test changes.
The loader work was amended after it was cherry-picked here, so this file carried the pre-amend version while every other file in that branch carried the final one. The commit subjects were unchanged by the amend, which is why the integration check that compared subjects did not notice. The difference is not cosmetic. The superseded version tested `num.is_i64()` and then called `num.as_i64().unwrap_or_default()`, so an arm whose guard held but whose accessor returned `None` would render the key as "0". A map key silently rendered "0" is a lookup that misses, which is the class of defect this function exists to close. The amended version binds through `if let Some(int) = num.as_i64()` and falls back to `serde_yaml::Number`'s own `Display`, so no arm can produce a default. Also takes the amended doc comment on the two-loader divergence, which states what each loader did with the same bytes rather than only that they differed.
…nnot pin
Three things, all in eval_tests.rs, none of them behavioral.
1. THE RECORDED REASON WAS INVERTED. `the_whole_value_pairing_stops_where_the_operator_
stops` said `PartialEq` has "no `(Int, Float)` arm", so `1` against `1.0` reached
`compare_eq` from both cells. Backwards: `PartialEq`'s catch-all delegates to
`compare_values`, which carries explicit `(Int, Float)` and `(Float, Int)` arms, so
`Int(7) == Float(7.0)` is TRUE through `PartialEq` -- measured directly. The `Float` cell
stopped at the FIRST early return, not the second.
The rule to pick a discriminator by is correspondingly opposite, and that is why this is
worth correcting in the tree rather than leaving in a pushed message. "Choose a type
`PartialEq` lacks an explicit arm for" selects nearly every scalar pair and leads a reader
straight back to `Float`. The rule is: choose a pair `compare_values` REFUSES that
`compare_eq` relates by some other route. A single-point `RangeInt` against an `Int`
qualifies -- `compare_values` will not order a range against a scalar, `compare_eq`
answers range membership.
2. THE SECOND INSTANCE OF THAT DEFECT. `a_short_circuited_scalar_pairing_earns_no_refusal`
still carried the `Float`/`Int` pair for the same purpose. Measured on it as it stood:
deleting either early return left it GREEN, while the positive control -- making the `Err`
arm record no refusal -- reddened cell 3. Its cells 1 and 2 bound NEITHER return. Cell 2
now uses a single-point `RangeInt`, and deleting `Ok(true) => return Matched` reddens it at
cell 2, as it already did for the sibling.
3. WHAT THE CELLS CANNOT PIN, WHICH IS A PROPERTY OF THE OPERATOR. Measured over every
explicit arm of `PartialEq` plus its fall-through -- `(Map, Map)`, `(List, List)`,
`(Bool, Bool)`, `(String, Regex)`, `(Regex, Regex)`, the three range arms, `(Null, Null)`,
`(Char, Char)`, `(Int, Int)`, `(Int, Float)` -- wherever `PartialEq` answers true,
`compare_eq` answers `Ok(true)` too. No pair the first early return catches escapes the
second, so deleting `if elem == each { return Matched }` changes no verdict and reddens
nothing, in either table. `stops_on_partialeq` is renamed `stops_on_a_matched_entry` in
both tests, because it never showed which check matched, and both comments now say so.
`Bool` was the obvious candidate for the missing asymmetry and does not work either;
`compare_eq(Bool, Bool)` is `Ok(true)`.
Also carries the mutation-matrix caveat that the cherry-pick of the element-loop fix
predated: the `(None, None)` arm's non-separability at the predicate is now measured rather
than argued, and cell 4's real dependency on `membership_pairing_refused` is disclosed --
it isolates the element loop GIVEN that the subset matches, not unconditionally.
Verification: the three mutations above run against both short-circuit tests, each test
invoked alone so its diff block is unambiguous, with --test-threads=1 throughout. Parallel
output splices `test ... FAILED` lines, which under-counts failures and reads as green --
that is how an earlier matrix run reported a red cell as passing.
`element_pairings_built` answers per ARM of `InOperation::compare`, and for a LIST `rhs_value` the arm stops deciding: `contained_in`'s `(List, List)` arm reaches `elements_not_matched`, which asks `is_one_of` per left-hand element, and every arm can land there. So for a list right-hand value the honest answer is "always yes" and three of the four rows the doc states are wrong. The contract was written unconditionally and is false there. MEASURED, not reasoned, with counters at the only two sites that pair a list's elements against a right-hand value and ONE right-hand value per case so the count is attributable. An earlier run used two values and could not tell which one built the pairings; its `(None, None)` row read as agreeing for that reason alone. For a list `rhs_value`: (Some, Some) predicted false, observed 2 pairings via elements_not_matched DISAGREE (None, Some) predicted false, observed 2 DISAGREE (Some, None) predicted false, observed 2 DISAGREE (None, None) predicted true, observed 2 agree THREE disagreements rather than one, and `(None, None)` is not among them. It agrees, but not for the reason its row gives: its own element loop IS skipped by the `if !eachr.is_list()` guard, and `contained_in` builds the pairings instead. A reader who credits that row's own loop is wrong even where the answer is right, so the row now says which half is load-bearing. Latent, because the sole caller is `incomparable_membership`'s `(List, right)` arm and a `(List, List)` pair is consumed by the arm above it, so `right` is never a list there. That guarantee is real and invisible -- enforced by match-arm ordering and written down nowhere, in a `pub(super)` helper whose entire reason for existing is that a condition restated away from the dispatch can drift from it. One reordering turns three wrong answers live. So the precondition is stated in the doc AND asserted with `debug_assert!`, which is proven live rather than decorative: passing a list `rhs_value` panics under the debug profile and returns normally under release, both measured. No existing caller trips it -- both tables pass in debug. Also corrects the `(None, Some)` row's justification. It said `string_in`'s refusal is "the same partition" `pair_refused` makes. False: `string_in` answers `not_comparable` for `(Regex, String)`, `(String, Regex)` and `(Regex, Regex)` while `compare_eq` answers `Ok(true)` for all three, so `pair_refused` refuses none of them -- three disagreements out of four probed pairs, only `(Int, String)` agreeing. The claim is also unnecessary: the gate needs element pairings to EXIST there, not the two functions to classify them alike. It is unreachable, a `PathAwareValue::Regex` being built only by the rules parser and a rule literal on the left selecting a different arm, but an unreachable justification is still the wrong one to leave standing. Doc plus one `debug_assert!`; no behavioral change in release and no test added.
`an_element_pairing_the_arm_does_not_build_earns_no_refusal`'s "What these cells do
NOT pin" section said "Flipping each of the four arms in turn moves exactly one cell
here and one in the helper's table". That contradicted its own neighbour six lines
up, which records that forcing `(None, None)` to `false` reddens the helper's cell 1
and moves nothing in this table.
The tree held the correct fact, the reason for it, and a summary that generalized over
a set the reason had just excluded. Structural, not a measurement slip: every cell
carries a `Literal` on one side, so cells 1-2 reach `(None, Some)`, cells 3-4
`(Some, None)` and cell 5 `(Some, Some)`. `(None, None)` needs both operands queried
and no cell here is one. Verified by reading the five constructions against
`is_literal`'s `len() == 1 && Literal` rule, and independently by measurement --
`(None, None) => false` leaves this table all green, 0 of 5 cells, while the helper's
cell 1 moves `true -> false`. The reading it invited, that this table gives
`(None, None)` cell-level coverage, is exactly what the paragraph above exists to
prevent.
Also names the mutation, because "exactly one cell" is ambiguous for the two arms
carrying sub-conditions. `(None, Some)` branches on whether the right operand is a
`String` and `(Some, None)` on `any(is_list)`, so negating either flips both
sub-branches and moves two cells, while substituting `true` and then `false` moves one
each. Six substitutions across the four arms -- one apiece for the two arms already
constant, two apiece for the others -- is what was measured.
And discharges the inert-vs-untested ambiguity in the redundancy claim next door with
numbers rather than a green result: deleting `if elem == each { return Matched }`
leaves the lib target at 1430 passed / 0 failed, while deleting
`Ok(true) => return Matched` from the same loop produces 20 failures. The site is
reached; the first branch changes nothing when gone.
Records one limitation of that redundancy measurement. The walk asks `elem == each`
then `compare_eq(each, elem)`, operands reversed between the two, and the asymmetry
cell 2 relies on is one-directional. Both directions were checked for the two pairs
the cells turn on and one direction only for the remaining arms; a later 2116-ordered-
pair probe in the loop's own order found no violation and 15 pairs where `compare_eq`
matches while `PartialEq` does not. The conclusion holds, and the narrower basis is
written down because "every arm" invites a reader to assume both orders were walked.
Comment-only: every changed line is a doc comment, no test added or removed.
`two_rules_files_sharing_a_basename_get_distinct_locators` checks that each notice carries its own parent directory, and spelled the fragment with a forward slash. The locator renders the path with the platform's separator, so on Windows the notices read `...\first\incomparable_membership.guard`, neither fragment matched, and the windows-latest job failed while Linux and macOS passed. Match against a copy with the separators normalized. Each parent still matches exactly one notice, so the assertion keeps the discriminating power it was written for.
|
@Zee2413 @satyakigh — worth a look ahead of the other open PRs, for two reasons. CI is fully green at This PR also adds One other thing only a maintainer can unblock: the registry-side fix for those stale expectations is aws-cloudformation/aws-guard-rules-registry#288, whose CI has never run because it is sitting in |
…erence-to-inapplicable question toward aws-cloudformation#727 Two conflicted paths, and eval.rs was not a textual conflict: the two branches held opposite, separately-argued positions on what a reference to a rule that did not apply means. This branch mapped it onto a violation for assertions -- `(Outcome::NotApplicable, _) if role.is_strict() => Outcome::Violated` -- on the ground that "a rule body asserting `r(...)` claims that `r` holds, and a rule that never ran is not evidence that it does", and flagged the resulting change from main as deliberate. That reasoning is true and the conclusion drawn from it is not: the answer to "no evidence" is no verdict, not a violation. Resolved toward aws-cloudformation#727 for two reasons. It produced a false positive. With two helper rules each gated on their own resource type, a template carrying one clean instance of the first type and none of the second reported FAIL at exit 19, and so did a template carrying neither type -- a violation asserted about a resource type the template does not contain. The row that matters for correctness, a violating instance beside a clean sibling, still FAILs, so this is not a slide into disjunction. It contradicted this PR's own algebra. `Outcome::and` has identity `NotApplicable` and absorbing `Violated`, so one level down an inapplicable clause conjoined with a satisfied one yields satisfied. Mapping that same `NotApplicable` onto the absorbing element at the reference site left the type saying "not applicable is neutral" while this arm said "not applicable is fatal", for one input. Whichever way the question is answered, the reference site and `Outcome::and` have to answer it the same way, and only one answer is expressible without carving an exception out of the type. Both sites are resolved, `eval_guard_named_clause` and `eval_parameterized_rule_call`, and both matches stay exhaustive over the enum rather than ending in `_`: that catch-all had been silently covering both a failing dependent and a negated gate on an inapplicable one. The negated `when` gate is unchanged -- a gate that closes silently disables the rule it guards, and the AWS rule registry's one negated reference is a gate. The comments arguing for the old behaviour are removed rather than left beside code that now does the opposite. eval_tests.rs was additive on both sides; all twelve test functions are kept.
aws-cloudformation#727 gained one commit: an empty expansion yields an empty selection only when a filter is still pending at or after the point the collection came up empty, so the two flavours of empty selection stop disagreeing. Merged clean textually. Verified behaviourally rather than on that basis, because the previous merge from this branch was also textually clean and had aws-cloudformation#727's Ok/Status::FAIL arms meeting this branch's Result/Outcome return type, which git cannot see.
05edeef to
9a4cc06
Compare
satyakigh
left a comment
There was a problem hiding this comment.
Two actionable issues at current head 9a4cc06:
- Blocking — valid JSON integers above
i64::MAXstill sign-flip on the publicrun_checkspath.guard/src/rules/values.rsfixes theserde_yaml::Valuearm by preserving the digits, but theserde_json::Valuearm still doesnum.as_u64().unwrap() as i64.run_checksparses valid JSON through that arm before its YAML fallback. I reproduced18446744073709551615with a ruleSize < 0: the rule reportsPASS; changing this arm to preserve the decimal string makes itFAIL. Please apply the same fix to the JSON arm and add a regression throughrun_checks(which also covers Lambda/FFI callers). - FFI error-code contract is incomplete.
get_codenow returns 25 forUnsupportedDocumentand 26 forUndecidableComparison, whileguard-ffi/src/lib.rsstill documents1..=24, andevery_error_code_is_distinct_and_usableomits both constructible variants. Please update the documented range and include 25/26 in the distinctness test.
|
Follow-up, since the branch and its CI both moved after my comment above. The branch was force-pushed to rewrite one commit message. The rewrite was message-only: trees are byte-identical between the old and new tips, and Current CI, run 34248709375: 17 pass, 1 fail. The single failure is
Everything else is green: all three My earlier 16/16 figure was measured on run 34234046182 at Neither I nor the author can re-run the failing job; we do not have admin on this repository. So it stays red until someone who does re-runs it, or a later run gets past the rate limit. |
An integer above `i64::MAX` sign-flipped through `TryFrom<&serde_json::Value>`, which is a separate arm from the `serde_yaml` one that was already fixed. The public `run_checks` tries `serde_json::from_str` first and only falls back to `serde_yaml` when that fails, and `18446744073709551615` is valid JSON -- so the entry point `guard-ffi` and `guard-lambda` both call took the arm that still read `u64::MAX` as -1, and `Size < 0` reported PASS at exit 0 with nothing on either channel. The digits are kept, matching `convert_yaml` so the two arms agree on the same input. The regression goes through `run_checks` rather than the conversion, since that is the surface the sign flip was reachable from and the one the FFI and Lambda callers share; it reports PASS before this change and FAIL after. No finiteness gate on the float arm beside it, unlike the YAML one: `serde_json` refuses a non-finite float while it is still text -- `1e400` is `number out of range` at parse time and JSON has no `.inf` spelling -- so the gate would be dead. The comment on `try_from_json` loses the sign-flip clause it used to give as a reason not to adopt `serde_json`, which no longer holds.
`get_code` gained `UnsupportedDocument` (25) and `UndecidableComparison` (26), but the header comment in `lib.rs` still told C callers every non-zero code is in `1..=24`, so a caller switching on the code had two documented-as-impossible values to meet. `every_error_code_is_distinct_and_usable` did not reach either one. Both carry a `String`, so neither had the unconstructible-payload reason the five absent variants have -- they were simply added after the test was written. They are also the two nearest a collision, being the highest codes and sitting on the far side of the 23/24 input codes, so an arm added later that reuses one is exactly what this assertion is for.
|
Both addressed at The JSON arm. Reproduced your case before changing anything: The JSON arm now keeps the decimal string, matching One thing I checked while there and deliberately did not change: the float arm beside it has no finiteness gate, unlike the YAML one. The doc comment on The FFI contract. Also correct, and the documented range was worse than stale — it told C callers that Measured on |
…er still does not
`0755` became the literal text, on the reading that YAML 1.1 makes those characters octal 493 while
1.2 core makes them decimal 755, so resolving to either quietly picks a schema. That reading is
defensible about the ambiguity and wrong about the consequence.
An unresolved scalar becomes a `String`; `compare_values` has no arm for a string against an integer;
and in a `when` condition a clause with no answer means the condition does not hold. So
`when Mode < 1000 { ... }` over `Mode: 0755` stopped evaluating its body and exited 0, where the same
rule and document exit 19 without this. Measured against a build of the 3.2.1 tag, which resolves it
to 755 and reports the body's failure. Silently declining to check is worse than choosing the reading
this loader already had, and every leading-zero decimal fits `i64`, so the direction costs nothing.
The `0x`, `0o`, `0X` and `0O` resolution stays. It removes triggers rather than adding them.
An integer above `i64::MAX` still keeps its text, and that is now a stated choice between two measured
harms rather than a gap. Resolving it to a float answers ordering correctly and reports two distinct
integers as one: `9223372036854775809` and `9223372036854775810` both become `9.223372036854776e18`,
so a clause asserting they differ is reported non-compliant -- measured on builds of both 3.2.1 and
the default branch. Keeping the text gets identity right and leaves an ordering comparison with no
answer, which is the gate consequence above. Neither is correct; `MarkedValue::Int` is an `i64` and no
float holds `u64::MAX`, so the option that avoids both needs a value space that can hold the value.
Exact identity is preferred here because a reported violation that does not exist costs a reader more
than a rule that declines to answer, but that is a choice of harm and it belongs to whoever owns the
value space.
The registry corpus check exits 0 with this change. That number can fail: it exits 1 for a separate
experiment on the same branch that reclassified comparison refusals.
… drop two vacuous assertions Three corrections, all from measuring claims this branch had asserted without checking. The comment saying the two loaders agree on an integer above `i64::MAX` is true only for a plain decimal. They diverge on every other spelling of the same value, because the serde arm writes what `u64::to_string` gives while the libyaml loader keeps the source text: `0xFFFFFFFFFFFFFFFF` becomes `18446744073709551615` on one side and stays hex on the other, and a leading `+` is dropped on one and kept on the other. `a_spelling_the_two_loaders_read_differently` pins both readings of each, and of `0755`, whose two readings this branch has just changed. Those three spellings come out of the agreement document rather than staying in it, because they do not agree and cannot be made to without provenance the value space does not carry. Moving them into a test that pins both sides is the point: an agreement claim that quietly excluded them would be weaker than one that names them, and a new divergence still fails the agreement test. `Size < 0` through `run_checks` is replaced. It did detect the sign flip -- measured, the released binary reports PASS for it and this branch reports FAIL -- but it cannot say which representation replaced the negative number, since a float, a decimal string and a clamped integer all report FAIL. `Size > 100` discriminates nothing at all: the flipped `-1` fails it and a refusal fails it, and both binaries report FAIL. The identity comparison is what separates the representations, and it is asserted instead: the released binary reports FAIL and this one PASS. The ordering and gate consequences are asserted too, as a recorded cost rather than intended behavior. `18446744073709551615 > 100` is true, so PASS is the right answer for the clause and running the block is the right outcome for the gate; neither happens. The assertion says so, and says what the alternative representation would trade for it.
`every_error_code_is_distinct_and_usable` reaches both variants and asserts each code is positive, distinct and not 19. Those are properties, and properties survive an identity swap: exchanging the two literals in `get_code` leaves that test green while the header in `lib.rs` says 25 is an unsupported document when it has become an abandoned comparison, so a C caller switching on 26 gets the wrong error class. Verified by making that swap -- the new test fails and the distinctness test does not. 22, 23 and 24 are already pinned exactly in this file. These two were the codes this change touched and the only ones left unpinned. The documented maximum is asserted as well, since the header is a promise to a C caller and nothing else checked it.
…uietly The three spellings taken out of the agreement document are now a closed set. Without that, a fourth divergence could be added to the pinning test and removed from the agreement document and both would stay green -- the exclusion list absorbing a new problem, which is what makes an exclusion list worse than none. Also states why the exclusion is scoping rather than weakening, because the two look alike in a diff. The agreement test still compares whole documents exactly, so a spelling that starts diverging fails it; what changed is which spellings are in its input, and each of those is pinned on both sides elsewhere. Weakening would have been relaxing the comparison itself, which would have left it unable to detect any divergence at all.
Measurements on numeric scalar handlingWhat this is, since it is posted on a pull request and is mostly not about it. Parts 1, 2, 4 and 5 Two questions are left open rather than answered — one at the end of part 5, one in its own section Posted here because the measurements came out of this branch and the reproduction steps are easiest to All figures below were produced by building two references from source and running the binaries:
Both are named exactly because a fork's Every command was run twice: once with a value that must reach the comparison, as a control that the The three questions, up frontThese are the parts nobody outside the project can answer. Each is discussed where it arises; they are
1. A JSON integer above
|
| data | 3.2.1 [upstream] |
main [upstream] |
|---|---|---|
{ "Mode": 5000, "Public": true } — control |
"status": "FAIL" |
"status": "FAIL" |
{ "Mode": 18446744073709551615, "Public": true } |
"status": "SKIP" |
"status": "SKIP" |
The control confirms the rule and the assertion can report a failure.
18446744073709551615 > 1000 is true, so the condition holds, the block is entered and
Public == false is evaluated against true. That is the FAIL the control shows. With the value
under test the condition is evaluated as -1 > 1000, which is false, so the rule does not apply, the
block is not evaluated, and the returned status is SKIP.
The same rule read by the CLI reports FAIL on an equivalent YAML document, so the two entry points
disagree on the same rule and the same value.
Note on the open pull request
An open pull request changes this arm to keep the decimal digits instead of casting. That removes the
sign change, and for a bare assertion such as Size < 0 it changes a wrong PASS into a FAIL. It
does not change the result above: the value becomes a string, an ordering comparison between a
string and a number has no arm in compare_values, and the condition again does not hold, so the
status is still SKIP. Measured on that branch, the SKIP cell is unchanged. This report should not
be closed by that pull request merging.
2. KNOWN_ISSUES.md item 2 does not describe the behavior inside a when condition
docs/KNOWN_ISSUES.md item 2 [upstream] reads:
When performing
!=comparison, if the values are incompatible like comparing astringtoint,
an error is thrown internally but currently suppressed and converted tofalseto satisfy the
requirements of Rust's PartialEq. We are tracking to release a fix for this issue soon.
That describes what the comparison returns. What it does not describe is that the consequence differs
by where the clause sits, and the two differ in whether the run says anything at all.
As a bare assertion, an incompatible pair fails the clause and the report names it, so an author sees
the mismatch and can correct the rule.
Inside a when condition, the same pair means the condition does not hold. eval_rule maps every
non-PASS condition to SKIP for the rule [upstream], so the rule does not apply, the guarded
block is not evaluated, and the run exits 0. Nothing is written to either output stream about the
block that did not run. An author reading item 2 would expect a visible failure and would get silence.
This is a documentation observation, not a second defect: the behavior follows from item 2 plus the
condition-to-SKIP mapping. It is raised because the combination is what a rule author meets, and
because item 2 is the page they would read first.
3. Measured cost of treating an incompatible pair as undecided (143 registry expectations move)
3. Measured cost of treating an incompatible pair as undecided
One candidate fix for the when-condition behavior in part 2 is to treat a refusal to compare two kinds
as an unevaluatable clause rather than as a decided "no", so a condition that cannot be answered fails
its rule instead of leaving it not applicable. In this repository's own terms that is adding
Error::NotComparable(_) to the match in is_unevaluatable (guard/src/rules/eval.rs) [upstream],
which today matches Error::IncompatibleError(_) alone; ClauseRole::is_strict beside it already
carries the FAIL-rather-than-SKIP decision.
That was implemented and measured. The numbers are given here because they argue against it, and
because they are reproducible with nothing but this repository and the registry.
Against the aws-guard-rules-registry corpus at commit
7f7340c26ae5d5e8874651dbffeb12e0e9f505b6, running cfn-guard test -d rules over the whole corpus:
- 143 rule expectations move. 131 are
Expected = SKIP, Evaluated = FAIL; 12 are
Expected = PASS, Evaluated = FAIL. - The comparison that produced those figures was a diff of the corpus report between a binary built
with the change and one built without it. Both runs exit 1 at this corpus commit for an unrelated
reason — 30 expectations name a rule their own file does not define — so the exit code alone does not
separate them and the report has to be diffed. A helper that does the diffing arrives with the
pull request these figures come from; it is not in this repository yet, and nothing here depends on
it. The 143 is reproducible withdiffover two saved reports. - The corpus commit above is pinned in the pull request's workflow
[unmerged], not in this
repository'smain[upstream], where the registry checkout tracks the default branch. Either way
the corpus lives in a different repository and its expectations cannot be corrected from here.
The 12 PASS → FAIL cells are the ones worth naming, because a rule whose expectation is PASS
describes a template the registry asserts is compliant, and it is now reported as a violation. Named
cases include:
LAMBDA_NO_WILDCARD_PRINCIPALS, on the case named "Lambda permission policy limited to an account
as the principal, PASS"IAM_USER_LOGIN_PROFILE_PASSWORD_RESET_RULE, on "IAM User with Login profile and PasswordReset set
to true"SECURITY_GROUP_EGRESS_ALL_PROTOCOLS_RULEandSECURITY_GROUP_INGRESS_ALL_PROTOCOLS_RULEEMR_SECURITY_CONFIG_ENABLED_AND_CONFIGURED_RULE
The 131 SKIP → FAIL cells are suppression cases, including several IAM_USER_LOGIN_PROFILE_* tests
named "Guard suppressed" and "CFN_NAG and Guard suppressed".
An independent check inside this repository agrees. A test that holds every operator and operand shape
against a stated oracle [unmerged] reports 24 cells moving onto the disagreeing-with-oracle list
and none coming off it. Among them are == 50 against the string "50" and > 10 against the
string "50", in a condition role — a numeric-shaped string against a number, which is a shape rules
use. The oracle is this repository's own statement of the right answer, so those 24 are wrong by the
project's own definition rather than by an outside judgment.
4. A numeric-looking mapping key is addressed differently by the two loaders
4. A numeric-looking mapping key is addressed differently by the two loaders
Independent of everything above, and [upstream] at both references.
cfn-guard validate reads a document through the libyaml wrapper; cfn-guard test and run_checks
read one through serde_yaml. The two resolve a numeric-looking mapping key differently, and because a
resolved key is rendered back into the string a rule addresses, the property's name differs by entry
point.
Measured with 0755 as a key:
| entry point | key as addressed |
|---|---|
cfn-guard validate |
755 |
serde_yaml-backed readers |
0755 |
So a rule referencing that property resolves under one command and finds nothing under the other.
Not every numeric-looking key does this. 0x1F is resolved to 31 by both, so it is addressed
identically either way; the divergence needs a spelling the two disagree about, and a leading-zero
decimal is the one measured here.
The trigger is a mapping key whose text the two loaders resolve differently, and the failure is a rule
that finds nothing rather than one that matches wrongly. Included because it has the same root cause as
the rest of this report — two loaders reading one document — and because it is not visible from either
loader on its own.
5. An integer above i64::MAX has two representations and both are wrong
5. An integer above i64::MAX has two representations and both are wrong
MarkedValue::Int is an i64 and no f64 holds u64::MAX, so a value in that range cannot be
carried exactly by anything the value space currently offers. Both available readings were built and
measured.
Resolved to a float, which is what 3.2.1 and main do [upstream]: ordering answers correctly,
and identity does not. 9223372036854775809 and 9223372036854775810 both become
9.223372036854776e18, so a rule asserting the two differ is reported as a violation. Measured on
builds of both, A != B over those two values exits 19, and the report reads
provided value [9.223372036854776e18] did match expected value [[9.223372036854776e18]].
Kept as the source text: identity is exact and the two values above compare as different, which is
correct. An ordering comparison against a number then has no arm in compare_values, so as a bare
assertion the clause fails closed and, as a when condition, the rule does not apply and the guarded
block is not evaluated — the behavior described in part 2.
Neither avoids both. The only reading that does is one the value space can hold exactly, which is a
change to PathAwareValue and everything downstream of it — comparison, the serializers and SARIF —
and therefore not something to decide from outside the project.
An open pull request currently keeps the text, on the reading that a reported violation which does not
exist costs a reader more than a rule that declines to answer. That is a choice between two harms made
by someone who does not own the value space, which is why it is raised here rather than settled there.
It also has a cost worth stating plainly against itself: resolving to a float instead would fix the
run_checks result in part 1, because a float compares against a number numerically. So the reading
that is wrong about identity is the one that answers part 1 correctly.
Two questions:
- Which of the two harms should the tool prefer, if it has to choose?
- Is making the value space hold these integers exactly something worth doing, or is the range far
enough outside real templates that either reading is acceptable?
The question this cannot answer
The 131 suppression cells admit two readings, and they lead to different work:
- Suppression depends on an incompatible pair answering "no". If so, those 131 are a correctness
problem becoming visible, and the classification change is surfacing it rather than causing it. - Suppression is independent of that, and the change breaks it. If so, the classification change is
wrong as written and the fix belongs elsewhere.
Distinguishing these needs knowledge of how the suppression mechanism is intended to interact with an
unanswerable clause, which is not derivable from the corpus or from this repository. It is the most
useful thing in this report and the one part left open.
Reproducing every measurement above
Reproducing
Every measurement in parts 1 through 5 is reproducible with nothing but this repository and the registry
repository. The one exception is the operator-matrix count inside part 3, which comes from a test that
exists only in the pull request; it is noted again at the end and is offered as corroboration rather
than as a step to follow.
Parts 1, 2, 4 and 5. Build the 3.2.1 tag. For part 1, call cfn_guard::run_checks with the rule
and the two JSON documents given there, once with 5000 as the control and once with the wide integer,
and compare the status field. For part 2, run the same rule shape through cfn-guard validate on an
equivalent YAML document and note that it reports FAIL where run_checks reports SKIP. For part 4,
put 0755 in as a mapping key and read the same property through validate and through test. For
part 5, put 9223372036854775809 and 9223372036854775810 in one document and assert A != B.
Part 3. Check out aws-guard-rules-registry at 7f7340c26ae5d5e8874651dbffeb12e0e9f505b6. Run
cfn-guard test -d rules from the registry root, saving the output, once with a binary built from
main and once with a binary carrying the change described in that part.
Stated in this repository's own names: is_unevaluatable in guard/src/rules/eval.rs [upstream]
currently matches Error::IncompatibleError(_) alone, and ClauseRole::is_strict beside it is what
decides that an unevaluatable clause fails rather than skips. Adding Error::NotComparable(_) to that
match is the change — a refusal to compare two kinds becomes an unevaluatable clause, so a condition
holding one fails its rule instead of leaving it not applicable.
Then diff the two saved reports. The 143 figure is the count of Expected = ..., Evaluated = ... lines
that appear in the second and not the first. Both runs exit 1 at this corpus commit for the unrelated
reason noted above, so the exit code is not the signal.
One precision about that figure: it was measured on the pull request's branch, where the same decision
is reached through an intermediate classification the branch adds, rather than by editing
is_unevaluatable on main directly. The two are the same behavioral change and the count is expected
to match, but only the first was run. If the number differs on main, the main number is the correct
one.
Part 3, the operator matrix. That result comes from a test that exists only in the pull request, so it is
offered as corroboration rather than as something to reproduce here.
The single-line #[case::wide_leading_plus(...)] runs to 102 columns, past rustfmt's 100-column max_width, so the formatting check failed while every other check on this branch passed. This is rustfmt's own output applied verbatim. The three case arguments are unchanged, so the test still runs the same spelling and expects the same two readings.
…m was fixed `json_parse_reads_an_integer_wider_than_i64_as_digits` is the second of three cases arguing against swapping `serde_yaml` for `serde_json` in `json_parse`, and its reason was that `TryFrom<&serde_json::Value>`'s `is_u64` arm still held `num.as_u64().unwrap() as i64`, so the swap would have reintroduced the sign flip. 72ff532 fixed that arm. The swap would no longer reintroduce it, and the comment went on asserting that it would -- a stale claim of exactly the kind `Value::try_from_json`'s own comment was corrected for in the same commit, which retired this reason as its third and kept the other two. The test-file copy was missed. Deleting the case was the alternative and is not taken: it is not only an argument about the swap. `json_parse` converts with `Value::try_from_json`, so it shares `convert_yaml`'s `is_u64` arm with the `TryFrom<&serde_yaml::Value>` entry point `values_tests.rs` covers -- measured, reverting that arm fails both -- but that case stops at a `Value` while this one carries on through the `PathAwareValue::try_from` `json_parse` calls next, and asserts the `PathAwareValue` a clause is evaluated against. So it says a wide integer survives the whole of what `json_parse` does to it, which is a reading no other case makes end to end. The line reference the paragraph used to carry is a symbol name now, per the convention a1a4fa0 set.
…usion it names 200f324 added `the_known_loader_divergences_are_exactly_these_three` to stop the loader-divergence exclusion list growing quietly. It does not, in the direction that matters. The spellings lived in two places -- the `rstest` case attributes on `a_spelling_the_two_loaders_read_differently`, and a separate `DIVERGENT` array -- and nothing connected them. Measured: adding a fourth divergent case to the `rstest` and leaving `DIVERGENT` at three keeps the whole suite green, which is exactly the quiet growth both comments say the pair prevents. `assert_eq!(3, DIVERGENT.len())` could not catch it. On a `[&str; 3]` that compares two compile-time constants, so growing the array without its declared length is a compile error and the assertion never runs, and growing both fires it -- the direction someone doing the bookkeeping deliberately is already in. Neither reaches the case list. So there is one table now, holding each spelling with both of its readings, and one test over it. A fourth row gets its two readings pinned, its divergence asserted, and its exclusion checked with no second list to update. The cost is the `rstest` case names, which named each spelling in the output; every assertion carries the scalar in its message instead. Keeping the `rstest` and having its cases index the table was the alternative and is not taken: it leaves a row that no case reaches, which is the same two-list problem one level down. The test also claimed more than it checked. Its title said the spellings excluded from the agreement document are exactly these three, and nothing looked at that document. It is a `const` now, and each divergent spelling is asserted absent from it -- matched as a whole value after `": "` rather than as a substring, so a spelling occurring inside a longer scalar is not misread as present. That closes the half nobody covered: a spelling taken out of the agreement document and added to the exclusion list in one commit used to fail nothing. Every assertion is positive-controlled. Reverting `convert_yaml`'s `is_u64` arm fails the reading assertions; a fourth row that does not diverge fails the `assert_ne!`; putting `0755` back into the agreement document fails the exclusion assertion, and failed it with the scalar rendered, which an `assert!` given a lone literal on edition 2018 does not do -- the first spelling of that message printed `{scalar}`, and rustc's `non_fmt_panics` caught it. The suite goes 3206 to 3200: four tests per binary become one, and `cfn-guard` builds two.
a59154c made a leading-zero decimal resolve as decimal and recorded the argument at the arm, but left the paragraph above the function saying the old behavior. `resolve_int`'s doc comment claimed **a decimal integer with a redundant leading zero stays a string**, so `0755` is the text "0755" -- while the inline comment eighteen lines below it says `0755` is 755, and the code has no leading-zero branch at all: `from_str_radix("0755", 10)` is 755. Measured through both loaders, the libyaml side gives 755. The same sentence also said keeping the literal "is also what `serde_yaml` does", offering the arm as a point of agreement between the two loaders. It is the opposite: this is one of the three spellings `values_tests` pins as a divergence, and `serde_yaml` is the side that yields the string. So a reader checking whether the loaders agree on `0755` found a claim that they do, in the function that decides they do not. The paragraph now states what the arm does and why refusing was rejected -- an unresolved scalar becomes a `String` and disarms a numeric `when` gate silently -- and points at the arm for the full argument rather than restating it. The rejected alternative is kept, because the 1.1-versus-1.2 ambiguity that motivated it is real and the next reader will meet it. Two counts follow from the change. The header said "two deliberate departures" from 1.2 core; the leading-zero arm is no longer one, since 1.2 core's decimal regex matches `0755`, so there is one departure left -- the leading sign on the radix forms -- and two divergences from `serde_yaml`. The closing paragraph said "one divergence ... is left standing on purpose" about `0b101`; it is now "the other", so the two numbers agree.
… document shrinking 2d474b5 renamed `a_spelling_the_two_loaders_read_differently` and left two comments pointing at the old name -- `values.rs`'s `is_u64` arm and `both_loaders_resolve_the_same_document_to_the_same_value`'s own doc comment. Both are symbol references rather than line citations, which is the right style, but a symbol reference to a symbol that no longer exists is the same defect the rename was fixing: a claim maintained in two places, drifting. Nothing failed, because no gate reads a comment. The same commit hoisted the agreement document into `AGREEMENT_DOCUMENT`, above the test rather than below it, so "deliberately NOT in the document below" no longer described where anything was. It names the const now. The third repair is a direction neither the old bookkeeping nor 2d474b5 covered. Measured: deleting a value from the agreement document and listing it nowhere leaves the whole suite green -- the agreement test simply asserts less. `the_spellings_the_two_loaders_read_differently` cannot see it, because it only checks that the three excluded spellings are absent, and a spelling removed from the document and listed nowhere is absent from both. So the document's value count is asserted, at 40, measured rather than declared. Counted from the string at run time on purpose. The guard this branch just replaced was `assert_eq!(3, DIVERGENT.len())` over a `[&str; 3]`, which compares two compile-time constants and can therefore never move; `AGREEMENT_DOCUMENT.lines()` is computed from the document, so removing a line drops the count. Positive-controlled in both directions: it passes at 40, and removing `hex: 0x1F` fails it at 39.
|
Thanks for both of these — you reproduced two real defects, and I owe you an apology for the silence. Both were fixed the day after your review and nobody told you, so from your side this has looked like feedback into a void for three days. JSON sign flip — fixed in I also enumerated the class rather than just fixing the reported member. FFI error codes — fixed in Verifying the above turned up four defects of our own, now fixed on this branch:
|
`resolve_int`'s contract carried "one deliberate departure from it, and two divergences from `serde_yaml`". Both numbers were prose. Nothing computed either, so either could drift, and that paragraph has drifted already: 1ac75f7 had to correct it after a59154c moved an arm, and the sentence it corrected did not merely miscount -- it offered `0755` as a point of *agreement* with `serde_yaml`, in the function that decides they disagree. 2d474b5 put the divergent spellings in one table, so how many there are is now a property of that table. It is asserted there, in the test that reads it, and the contract names the table rather than restating a total. The 1.2-core departure loses its numeral too: nothing tabulates departures from the spec, so a count of those would be the same unchecked prose, and "a deliberate departure noted below" says what is true without a number that can be wrong. `DIVERGENT` becomes a slice, because otherwise the assertion means nothing. On `[(&str, &str, &str); 3]` the length is declared beside the rows, so `assert_eq!(3, DIVERGENT.len())` compares two compile-time constants -- which is exactly the guard 2d474b5 deleted, and exactly why it never fired. A slice's length comes from its contents. Measured in both directions at three rows: a fourth row fails it at 3 against 4, and deleting `0755` fails it at 3 against 2. Neither edit needs a second one to compile, which is the whole difference from the array. Removal is the direction the per-row assertions cannot see. Delete a row and every remaining row still pins its two readings, still asserts its divergence, still checks its exclusion from the agreement document -- all green, with one divergence no longer pinned anywhere. c8fe2ca closed that hole for `AGREEMENT_DOCUMENT` by counting its values out of the string; this is the same repair on the sibling table. Two alternatives are not taken. Keeping the array and bumping its declared length leaves the assertion unable to move for any edit that was not already deliberate, which is the defect rather than a fix for it. Having the test read `loader.rs`'s doc comment and check the number written there would tie the two directly, at the price of a doc-comment parser inside a test and a contract that can no longer be reworded; naming the table is enough, because a reader who follows the name arrives at a number that is checked.
`0b101` was named as a divergence in two contracts and asserted on one side. `case::binary` in `an_integer_resolves_by_the_1_2_core_forms` pins this loader's reading -- the string `0b101` -- and nothing anywhere read those characters through `serde_yaml`. A divergence with one side tested is half a claim, and the untested half is the half that makes it a divergence rather than a preference: if `serde_yaml` stopped resolving binary, the two loaders would agree, every assertion in the suite would stay green, and both contracts would go on calling it a divergence. Measured through the same path as the other rows: `serde_yaml` 0.9.34 resolves `0b101` to 5, which `values.rs` renders as `5` against this loader's `0b101`. Positive-controlled by expecting `6` instead, which fails on "the serde reading of `0b101` changed". It is a row in `DIVERGENT` rather than a new mechanism, because a second list beside that table is the defect 2d474b5 removed. Its arrival moves the size assertion from the previous commit to 4, which is that assertion doing the only job it has. The prose is reclassified rather than deleted. `0b101` stays under "closable, and left open with a reason" -- the distinction between a divergence nothing here can close and one this loader declines to close is real, and it is what tells the next reader whether to try -- but the category no longer ends at "a choice, not a limit" with nothing checking either half. It points at the row, and the row is what reports the choice being reversed: accepting binary makes the two loaders agree on `0b101` and fails the `assert_ne!`. `resolve_int`'s contract now names the pinned readings and `case::binary` in the same sentence that declines the extension. The asymmetry inside the table is named in the per-spelling notes and not encoded. The other three rows cannot be made to agree; this one could be. A fourth column saying which is which would be a field no assertion reads, and this table exists because the last unread copy of its own contents was wrong.
…e captures" This reverts commit 145e8ad. The fix was extracted to aws-cloudformation#749, which carries a patch-identical commit, so it was open in two places at once. Removing it here leaves aws-cloudformation#749 as the only one, where it can be reviewed on its own instead of inside a 375-commit branch. aws-cloudformation#720 carries the same revert. Reverted rather than dropped from history: this branch is already pushed, and rewriting it would need a force-push. A revert reaches the same resulting diff with a fast-forward, and cancels out cleanly if this branch is squash-merged -- the squash then contains neither the change nor its revert. One conflict, in guard/tests/validate.rs, and it is pure adjacency. The reverted commit appended its test at a point where later commits on this branch have since appended 143 lines of their own, so the hunk no longer applied. Only the 21 lines this commit added were removed; they were located as a contiguous block and compared line for line against what the commit inserted rather than matched by eye, and they sit at the end of the region, after everything later. The resulting change to the file is 21 deletions and no insertions, the exact inverse of the insertion. guard/src/rules/functions/strings.rs and guard/src/rules/functions/strings_tests.rs auto-merged and are the exact inverse of what this commit did to them: +4/-27 and 0/-63 against its +27/-4 and +63/0. The `try_replacen` call goes back to the `captures_iter`/`expand` loop under no comment, as it stood. The two later commits that touch strings.rs on this branch, da15677 and b90613b, are both in `json_parse` and are untouched. The fixture guard/resources/validate/functions/rules/regex_replace_no_match.guard goes with the test that reads it; nothing else names it.
|
Closing in favor of the smaller single-fix PRs the maintainers asked for. The work on this branch is being split into one PR per fix, submitted a few at a time in severity order; the first batch is #747, #748, #749, and #750. The branch stays on the fork unchanged as the integration reference, so anything here that has not yet been resubmitted is still reachable. |
Continues #717, which was merged at
567c0d7. These are the commits made on that branch afterwards and sonot part of it. Rebased onto current
main; the tree differs from the pre-rebase branch only in thelockfiles
ef17f36patched.Same class of defect as #717: a rule that answers compliance for a check that never ran, or a run that
exits non-zero and names no finding.
One CI job is expected to be red, and it is not this branch's doing.
aws-guard-rules-registry-integration-tests-*fails because a change here — failing a run when anexpectation names a rule that produces no verdict — is correct and the registry has 30 such expectations, all
in
rules/aws/aws_cloudformation, naming 11 rules their file never defines. Measured: exactly 1 of 49registry directories changes its exit code, and stdout is byte-identical over the whole corpus, 9,156 lines
either way. The fix is aws-cloudformation/aws-guard-rules-registry#288, which takes
cfn-guard test -d ruleson that repository from exit 1 to exit 0 against this branch's binary. Both registryjobs are also now pinned to a SHA instead of tracking
main, so a commit in another repository can no longerturn this one's CI red without a change here; read that pin as a lock on a known state rather than a green
one, and move it forward once #288 lands.
A typo could delete a rule and report compliance
extract_messagesearched all remaining input for the closing>>, so one forgotten tag consumed everyrule up to the next rule's tag as message text and those rules ceased to exist. A file whose second rule
the template violated reported PASS at exit 0 — no finding, no diagnostic on any channel, and a parse
tree holding only the first rule with the rest of the file inside its custom message.
A message is now delimited by the grammar the language actually uses, rather than by a bound on the search.
A
>>on the opening line closes the message; otherwise the message ends at the first later line whosetrimmed text is exactly
>>; otherwise it is the error this code already raised for a missing tag.Two earlier attempts at this each bounded the search, and each was wrong. They are worth stating because
the shape of the mistake is the same both times. Stopping at the first line whose first token is
}orrulefinds a forgotten tag whose next
>>is in a later rule, but when the next>>belongs to the next clause ofthe same block no such line sits between the tags, so that clause was still swallowed at exit 0 -- in a rule
body, in a type block and in a
whenblock. It also rejected a legitimate body quoting example JSON, becausesuch a body has a line starting
}. Stopping at the next<<fixed all of those and broke a third case: withno second
<<in the file the search ran on to a>>inside a comment and closed the message there, soparsed as rules one and three, with rule two absent from the parse tree, and exited 0 against a template rule
two violates. Taking the minimum of the two bounds does not work either: a comment carrying
>>at the end ofthe same block is silent under both, since neither a
}line nor a second<<sits between the tags.Each of those bounds inferred document structure from a token that may legitimately appear inside a message
body. A terminator line infers nothing -- it is what a closing tag looks like in 231 of the 233 messages that
exist. The census is the whole basis for the design: of the 233 messages in the AWS rule registry and this
repository's fixtures, 231 are block form with the closing
>>alone on its line, 2 are inline with both tagson one line, nothing follows
>>on its line in any of them, and there are no other shapes.Two behaviour changes fall out. The JSON-quoting body now parses, because a brace in a body is text, so this
removes a limitation rather than documenting one. And a block body whose closing
>>shares its line with bodytext, as in
<< Violation: XthenFix: Y >>, is now a parse error. That is a narrowing, and it cannot beavoided: the swallowed clause in the example above is itself a line ending in
>>, so anything that acceptsthe one accepts the other. No message in the corpus is written that way. It is asserted as a rejection so it
is not reopened by loosening the terminator.
All four broken shapes exit 5 now, for one reason rather than four. Measured against the previous bound over
2,494 fixture pairs and 276 rules files: zero exit-code, stdout and stderr differences, and byte-identical
parse trees, so all 233 messages parse exactly as before. Regression tests cover the four broken shapes, both
accepted forms, the empty message, and the rejectedthree shapes.
Five more readings that were not what the author wrote
[x]gaveAllIndicesand[ x ]gaveAllValues, becausethe named branch of
all_indicesdid not skip whitespace. Two spaces were then the difference between aworking rule and an unevaluatable one:
Tags.%k[x] == "prod"passed at exit 0 whileTags.%k[ x ]bailedat exit 19, since the
%varinterpolation arm accepts one query part and not the other.whenwas atagin front of acut. It matched the first four characters ofwhenCreated, thewhitespace requirement after it failed, and the cut made that unrecoverable — so any clause whose first
identifier began
whenorWHENwas a parse failure.rule whenever { ... }defined fine and awheneverreference did not: a rule you could declare and never call.let v = 1thenlet v = 999failed a rule that the reverse order passed, and two assignments of different kindsignored their order entirely, because literals, queries and function calls go into separate maps consulted
in a fixed order. Rejected now in both scopes.
count.guardin this repository was a live instance: itbound
restwice and asserted%res == 3after each, so the first assertion tested the second count —and would have failed had it been live, because there are five properties and three collection entries.
Both counts are now named and asserted.
keyswas reserved for every clause rather than for key filters. Acutafter the keyword stopped[ keys EXISTS ]from reaching the branch that parses it. The identical clause parsed one slot later,which is what showed nothing ambiguous forced the rejection.
inand the sevenIS_*operators were plain tags.Size IS_INTEGERparsed asIS_INTplus areference to a rule called
EGER— loud with no such rule, PASS at exit 0 with one. The same guardtrue,falseandnullalready had.Three smaller ones:
i64::MINwas the single integer the parser refused, a rule reference could not end itsblock (
rule b { a }was rejected while the same rule over three lines parsed), and the normative grammarcomment documented
NOT_INand a clause-levelKEYS, neither of which was ever accepted.The data loader, where every rule's answer starts
A data file of nothing but comments aborted the process. Exit 101,
panicked at guard/src/rules/libyaml/event.rs:63: not implemented, on four shapes: one comment line, the samewithout a trailing newline, several comments with blank lines between them, and a fully commented-out template.
An empty file and a whitespace-only file already exited 255 saying the file is empty, so the degenerate case was
handled and this one was not. The event arriving at the wildcard arm is
YAML_NO_EVENT, and it arrives becausethe loop had no exit for a stream that never started a document:
DocumentEndwas the only arm that returnedand
StreamEndshared the no-op arm, so the next pull ran past the end of the stream and libyaml answered itwith a success.
StreamEndnow ends the loop and reports the file as holding no document, worded as the messagean empty file already gets. The wildcard arm remains and no longer aborts; it returns an error carrying the
event type, so a future miscount stays diagnosable.
TrueandTRUEwere read as strings, so a gate never fired. Underwhen Properties.PublicAccess == true, against a bucket withEncrypted: false:true,yesandonexit 19and catch the violation, and
TrueandTRUEexit 0 leaving it unchecked.is_bool_trueandis_bool_falsematched lowercase only, while the
nullarm two lines below lowercased first, so within one function null wascase-insensitive and bool was not. YAML 1.1 makes all 22 spellings boolean; the 1.2 core schema admits six. The
loader already read
yes,onand the single letters, which only 1.1 makes boolean, so the vocabulary wasalready 1.1's with the capitalised spellings missing, and those four are boolean under both schemas so they were
missing whichever version was meant.
test_handle_bool_happy_pathhas enumerated all 22 as the intended setsince it was written — and could not fail, because its assertion sat inside an
if let MarkedValue::Bool(..)and14 of the 22 cases passed while asserting nothing.
handle_type_refusedstr::parse::<bool>, so an explicit!!bool yeswas stricter than a bareyes; both now agree. Case-insensitive matching was rejected becausetRuEis boolean under neither schema, and narrowing to the 1.2 set was rejected because it would takeyesand
onfrom documents that write them, which is a separate argument.A query for
Tagswas answered out ofTag. A rule askingProperties.Tags.Nameof a bucket withProperties.Tag.Namereported aComparisonErrorover/Properties/Tag/Namecarrying that value: the rulenamed one property and was answered about another. One-way, since
TagagainstTagsreportedMissingProperty. The cause is the class-case entry in the converter list the lookup falls back to when a key isabsent as written.
cruet's class case is Rails' class-name rule, PascalCase and singular, soto_class_case("Tags")isTag; every other entry respells a key rather than naming a different one, andto_pascal_casealready covers PascalCase. It is not a plural rule either —AnalysisbecomesAnalysiandMetadatabecomesMetadatum. Removing it costs no reach, measured by instrumenting the fallback: class casewon 57 lookups across the registry's 193 paired files and pascal, title and train produced the identical string
for all 57.
A duplicated key took the last value and said nothing.
Encrypted: falsefollowed byEncrypted: trueevaluates as
true, the reverse order asfalse, and{"Encrypted":false,"Encrypted":true}the same way, allwith nothing on stderr. A reviewer reading a template top-down sees the first value and the tool judges the
last. YAML 1.2.2 makes this a loading failure rather than a merge — a mapping's keys must be unique per §3.2.1.1
and §3.2.1.3, and §3.3 lists non-unique keys among the loading failure points — so rejecting the document would
be defensible, and it would also fail every template carrying one today, which is a maintainer's call. This
reports the fact and leaves the verdict: one line naming the key's path and both locations, the last value still
winning, no exit code moved. The duplicate is not visible in the loader, which keys its maps on name and
location so both entries survive; they collapse a layer up where the map is rebuilt on the name alone, which is
also the only point that holds the path the key was reached by. Detection is per-mapping by construction, so a
property name repeated across two resources — which nearly every real template does — cannot register. Counted
before building: one occurrence in 71 corpus files, in the fixture that exists for it, and none in 256 registry
files.
cfn-guard teststill cannot report duplicates in its embedded inputs, becauseserde_yamlcollapses arepeated key during deserialization before any of this runs.
An
unreachable!()in the FFI error table.get_codeinguard-ffiansweredError::InternalErrorwithunreachable!(), in the table every error crossing the boundary is fed through and the same one that gained acode for the no-document error above. It is not reachable through
cfn_guard_run_checksas the code stands —that path parses through serde and never touches the libyaml loader, the one construction site that is on the
path is mapped to
ParseErrorfirst, and all four non-string key shapes come back asParseErrorwithoutpanicking. It is fixed regardless:
Errorandrun_checksare public, the remaining variants are constructedin a module this path avoids only because it writes through a
BufWriterof its own, and a table total overevery variant should not rest on an unreachability the next commit can invalidate. The arm gets a code and the
table stays exhaustive rather than gaining a catch-all, since exhaustiveness is what surfaced it.
A leading space before a numeric index or a quoted key was a parse error, while every other bracket form
tolerated one.
Names[0]andNames[0 ]parsed;Names[ 0],Names[ 0 ],Names[ -1 ]andResources[ "MyBucket" ]did not. This is the[x]-versus-[ x ]defect a second and third time: that fixadded whitespace skipping to the named branch of
all_indicesand left the numeric index and the quoted keyalone. The tests compare the resulting query part rather than merely that the file parses, because falling
through to a different part is how the first version of this defect did its damage.
Filters and captures in the query engine
[*]and.*both expand a map and hand each entry's value onward. That is right for a collection andwrong for a single object whose fields the filter names: for
{ Statement: { Effect: Allow, Action: "s3:*" } }the values reaching the filter were the stringsAllowand
s3:*,Effect == 'Allow'matched neither, and the filter selected nothing. An assertion over an emptyselection is a SKIP, so the two spellings disagreed —
.*failed and[*]exited 0.A filter capture leaked across iterations of a block. An iteration that captured nothing under a name fell
through to the parent scope, which by then held the earlier iterations' merged keys, so a non-compliant
resource passed on a compliant neighbour's key. Adding a compliant resource is what made the non-compliant
one pass, which is why such a rule looks correct when tested one resource at a time.
BlockScopenow carriesthe capture names its own clauses declare, read out of the rule text rather than learned at runtime — the
first attempt learned them as keys were captured, which made the verdict depend on document order.
A bare bracket is a capture too, and was not counted as one.
Properties.Tags[ tk ]written without a pipegoes through
all_indicesand becomesAllIndices(Some("tk")), which captures the entry's key at retrievalexactly as a filter does, but the name collector read
FilterandMapKeyFilteronly. No block declared thename, so the lookup deferred to the parent, which held the keys earlier iterations had merged up. A resource
that captured nothing was credited with a previous resource's key and document order decided the verdict: with
the capturing clause in one
whenblock and the reading clause in a siblingwhenblock that only a resourcewithout
Tagsenters, one order exits 0 and the other 255. All orders now agree at 19. Pre-existing;origin/maingives the same three codes, because no scope there held the name at all. The function's own doccomment claimed
AllValuesandAllIndiceswere collected through arms of their own, and there were none.Resources[*][ nm | ... ]then%nmended the run at 255 whileResources[ nm | ... ]— the same filterwithout the wildcard — worked. After a wildcard the map was already expanded when the filter ran, so the
name was discarded and the capture never made. The filter now runs at the expansion site, where the key is
still in hand.
Reporter aborts and findings with nowhere to go
Four reachable
unreachable!()in the CloudFormation reporter and a reachabletodo!()in the Terraform onetook the process down at exit 101, the latter on an everyday rule (
INagainst a plan). One was reachableonly after fixing the panic in front of it, which is why "not reproduced" is not the same as unreachable.
One rule that could not be evaluated discarded the whole file's report, in all four output paths and in
cfn-guard test: a file whose second rule read a variable that does not exist printed one error line andthrew away five real findings from its third rule.
Findings the per-resource output cannot place are reported under their own heading,
Findings that belong to no resource:, each entry naming the rule it came from. The existingCould not be evaluated:is for a blockor rule that failed on a condition nobody could decide; these clauses were decided and merely have nowhere to
be printed, and filing a missing property under "could not be evaluated" would report an ordinary failure as
an undecidable one.
Which findings those are is decided by what the reporter rendered, not by predicting it from paths — and
the first version of this section predicted, wrongly, in both directions. It asked once per rule on the
premise that one placed sibling renders the whole rule;
pprint_clausesin fact gates every clauseindividually, so a rule with one located finding and one pathless one showed the first and lost the second
from the console entirely while the JSON carried its reason. And it could not see a block whose query failed
at the document root, because it required
unresolvedto be absent andMissingBlockValueis the one offour block-report constructors that sets it — so a rule querying a top-level property against a
CloudFormation template exited 19 saying nothing, taking the author's own
<< >>message with it.rendered_contextsis the union of the resource clause sets, which is by construction what the per-resourceoutput will show, and every clause and block is asked against it. Deciding per clause without that set
reintroduces a different fault, because the evaluator emits two reports for one comparison it resolved one
way and could not resolve another — the context set is what separates the unresolved twin from a genuinely
unreported sibling. Alongside: the author's
<< >>message now reaches the section (.or_elseover the twomessage slots was dead in its second half),
NoValueForEmptyCheckno longer prints the negation of whathappened, and a rule name matched by prefix no longer renders one rule's block under another's findings.
Two further gaps in that section, both found by reviewing it rather than by a test. It keyed "was this shown
under a resource?" on the trimmed context string, so two clauses of one rule whose text is identical were
indistinguishable and the placed one made the pathless one count as shown; a rule asserting
Properties.Tags existsboth inside a resource block and at the document root reported one of the two and printed no section atall. What separates that from the legitimate twin the context set exists to suppress took establishing: both are
two
ClauseReportnodes, adjacent siblings, with byte-identical context and no recorded source span, and whatdiffers is the check carried,
UnResolvedbesideInResolvedfor the twin againstUnResolvedbesideUnResolvedfor the collision. The gate is now node identity plus that one case named for what it is, and itsuppresses a strict subset of what the old one did, so a finding the old code printed cannot be lost.
And the 320-character cap on an explanation was applied before trimming, so a message's own indentation spent
its budget. A message is written inside the clause carrying it, so every line arrives indented to that clause's
depth, which made the length an author could write depend on how deeply the rule was nested; 9 of the corpus's
233 messages exceed the cap as written and only 5 do once trimmed. It now measures the content, and an
explanation that fits once trimmed comes out whole and unmarked, because an ellipsis is a claim that content was
dropped. One consequence is visible in four registry outputs: a short message no longer prints the whitespace
around it, so the evaluator's sentence joins it a line earlier rather than after a blank one. No exit code moves
and nothing is lost, but the separation between an author's message and the machine's reason was incidental and
is now absent.
Both console reporters also aggregated findings into a
std::collections::HashMapand iterated it to writethe report. That hasher is seeded per process, so ten runs of one binary against a three-resource template
produced five distinct outputs.
Parser boundaries
Items[4294967296]wrapped toItems[0]and compared the wrong element. Non-finite float literals (1e400)and literals that underflow to zero were accepted. Duplicate rule names and repeated parameter names were
accepted silently. A map key that reads as an integer was treated as an index rather than a key.
cfn-guard testreported success for suites it never ranThree defects in the same family as the rest of this branch, found by following up why an expectation in the
AWS rule registry had never been checked.
A test suite could be run against the wrong rules file, and its failure reported as success. Test files
under a
tests/directory are paired with rules files by prefix, taking the first match in filename-sortorder, so a shorter stem claims a longer stem's tests. With
s3.guardands3_encryption.guardin onedirectory,
tests/s3_encryption_tests.ymlwent tos3.guard, which does not define the rule its expectationsname, and
s3_encryption.guardwas reported as having no tests associated. A suite that expects PASS where thetemplate violates the rule exits 7 under
test -r/-tand exited 0 undertest -dover the same twofiles. Paired on the longest matching prefix now. The looseness is deliberate and is kept: requiring a
_testssuffix would stop
<prefix>.ymland<prefix>-1.jsonpairing, and a character-class boundary cannot separatethe readings because
_is both the conventional separator and a word character.min_by_keyover thereversed length rather than
max_by_key, so a tie still goes to the first file in sort order as it did before;ties are reachable, since
x.guardandx.rulesetshare a prefix.A test file matching no rules file was discarded without a word. That is what a rename leaves behind.
s3_bucket.guardbesidetests/s3_tests.yml, left over from when the rules file wass3.guard:test -dprinted only that the rules file had no tests associated, which reads as benign, and exited 0. Renaming the
test file and changing nothing else gives exit 7. Each unmatched file is now named on stderr with its path.
An unchecked expectation was invisible to every structured reporter. An expectation naming a rule the file
does not define is not checked; the plaintext reporter says so on stderr and
json,yamlandjunitsaidnothing. A test file whose every expectation names a nonexistent rule produced
<testsuites tests="0" failures="0" errors="0">and exited 0, so a pipeline gating on that report had nothingto gate on. Reported now as a skip in a field of its own,
unchecked_expectations, not folded intoskipped_rules-- those are opposite directions of one mismatch, rules with no expectation againstexpectations with no rule, and a consumer reading one array could not tell which had happened. A skip and not a
failure, because escalating it changes the exit code of a shipped CLI for every user with a typo in a test
file, and that is a maintainer's call; making the fact visible is not. The field is omitted when empty, so
every report over a suite with nothing unchecked is byte for byte what it was.
Run against the AWS rule registry at
7f7340c, unmodified, the two diagnostics name three test files thathave never run and eleven expectation names that answer to no rule. Two of those three files are the only test
file their rules file has, so
s3_bucket_default_lock_enabledandcloudfront_accesslogs_enabledhave no testcoverage at all: one filename drops a trailing
d, the other begins with a space. Both suites pass oncewired up. Those are fixed in that repository rather than here.
Merging alongside #719
Both PRs merge into
origin/maincleanly on their own. Only the combination conflicts, and no singleresolution direction is correct for it — of the five conflicted paths, three want this branch's side, one
wants #719's, and two want a union. One of them is dangerous to get wrong and the damage is silent.
Measured by trial merge into
origin/mainat3e265bb, withrereredisabled so a recorded resolutioncould not report a false clean, and judged by the merge output text rather than by the unmerged-path list.
guard/src/rules/parser.rsextract_messagegrammarfind_substring(">>")guard/src/rules/path_value.rsQueryResolverand its twoselectfunctionsguard/src/rules/mod.rsadd_merged_capture_keytrait methodguard/src/commands/reporters/validate/common.rsFileReportandMessagesguard/src/commands/reporters/test/structured.rshas_unchecked_expectationsandnumber_of_unchecked_expectationsbuild_junit_test_caseswith an elided lifetimeparser.rsis the one that matters. #719 branched before any of the three attempts atextract_messageand its
Apply rustfmtcommit reformatted the signature, which is what makes it conflict rather than merge.Its side is the original unbounded search. Resolving that hunk in #719's favour reinstates the defect where one
forgotten
>>consumes every following rule as message text and the run reports PASS at exit 0 — the headlinefinding of this PR, and silent when it recurs.
path_value.rsis the mirror image: this branch carries that engine only because it does not delete it, anddeleting it is #719's entire purpose. It does not touch the duplicate-key detection added here, which sits
earlier in the file, well outside the conflict region.
The order matters and only one order is dangerous. If #719 merges first, this branch rebases onto it and
the resolution happens here, so no trap reaches anyone. If this branch merges first, the conflict lands in
#719 and the
parser.rshunk is the one that must not be resolved the obvious way.This table is re-derived rather than carried forward: it named two conflicted paths when this branch was 27
commits shorter, and both the count and the resolution directions have changed since. Re-derive it again if
either branch moves.
A rule could not be composed out of other rules
rule MAIN { H_A H_B }failed whenever a rule it referenced was not applicable. Two helper rules eachguarded by a
whenon their own resource type, and a template carrying one of those types but not the other,gave
H_APASS,H_BSKIP andMAINFAIL at exit 19 — reporting a violation on a template that violatesnothing. A template carrying neither type failed as well.
eval_guard_named_clauseclaimed every assertion SKIP as FAIL, in both polarities, which also made the armbelow it unreachable for assertions.
eval_parameterized_rule_callcarried the identical line forr(...),with a comment claiming to mirror the first; both are fixed together and one test asserts both, so a change to
one cannot pass on the other's coverage.
This is why decomposing a ruleset over disjoint resource types has not been done: neither workaround holds. A
conjunction of references fails whenever any type is absent. A disjunction returns PASS when any one helper
passes, so a template with a violating IAM Role beside a clean DynamoDB table passes a rule that should fail
it — a false negative in a compliance rule. The AWS rule registry's existing helper idiom composes with
ORonly because its helpers are alternative paths to one conclusion, and that does not transfer.
A negated reference is included:
rule deny when Resources.*.Type exists { not inner }failed on a templatewith one S3 bucket and no KMS key. It now reports
the rule did not apply because it referenced rule [inner], which did not apply to this input. A negated reference used as awhengate is deliberately unchanged,because a gate that closes silently disables the rule it guards; the registry's one negated reference is a
gate, and that decision is what protects it.
Exit codes said the tool broke when the rules file was wrong
Two paths reported an authoring mistake as exit 255, the internal-failure code, where 5 means rules-file
error:
parse-treeon a rules file it cannot parse —validatesaid 5 for the same file, so two subcommandsdisagreed about whose fault it was.
validateon a rules file referencing a variable noletdeclares. Forgetting aletis a common mistake,and the caller was told the tool had failed. The undeclared name is now reported with
Could not resolve variable by name <name> across scopes, so it can be acted on.The reason a 255 survived here is a test that passed for the wrong reason.
guard/tests/parse_tree.rshad twocases sharing one expected output that is an I/O error. One points at
dne.guard, absent on purpose. Theother pointed at
validate/rules-dir/malformed-rule.guardwhile the file lives atvalidate/malformed-rule.guard— one directory up. So it failed atFile::open, matched the shared I/Ostring, and passed green.
parse-tree's parse-error exit code therefore had no coverage at all. The fixture,never having been run, also turned out not to contain a syntax error: it is an undeclared-variable file, which
is how the second defect above came to light. The repository had no genuinely malformed rules fixture; there
is one now, the cases assert what their names claim, and the exit codes are documented, which they were not.
A test expectation could name a rule that does not exist
cfn-guard testprintedNo rule named X is in this file, so its expectation was not checkedand exited0. An expectation naming a rule that is not there asserts nothing, so a rule renamed without its test file
being updated leaves a test that silently stops testing while the suite stays green.
It is now exit 1 —
TEST_ERROR_STATUS_CODE, notTEST_FAILURE_STATUS_CODE, following the line thisrepository already draws at
guard/tests/test_command.rs: "an expectation that could not be evaluated is adifferent answer from an expectation that was not met." All four output formats report it; junit uses its
<error>element, which consumers treat as red exactly as they treatfailures.The question that decided whether a blanket failure is right is whether an expectation for a rule in a
sibling rules file can legitimately be checked in directory mode. It cannot:
OrderedTestDirectory::fromreduces the candidate rules files with
min_by_key, which yields exactly one claimant, so a test file isnever evaluated against a second rules file. A directory whose test files are named to cover several rules
files at once will now go red, and it was never checking what it appeared to.
This has a consequence outside this repository, measured rather than assumed.
aws-cloudformation/aws-guard-rules-registryhas 30 such expectations inrules/aws/aws_cloudformation/tests/cfn_no_explicit_resource_names_tests.yml, across 11 distinct names, in asuite that reports clean. Its
.github/workflows/{ci,build,publish}.ymleach installcfn-guardunpinnedfrom
mainand runcfn-guard test -d ./rules/, so when this change is released that repository's CI andits publish pipeline go red until those expectations are dealt with.
aws-guard-rules-registry#286
removes them and is green under every
cfn-guardversion, released or not, so landing it first leaves no redwindow in either repository.
A regular expression's own diagnostic was built and thrown away
Both failure returns in
parse_regex_innerwerenom::Err::Error, whichalttreats as recoverable, so thefancy_regexmessage was constructed and discarded and the next value production's complaint was reportedinstead.
== /a(/exited 5 sayingexpecting either a property access "engine.core" or value like "string",with no mention of the parenthesis. It now says
Could not parse regular expression: Parsing error at position 2: Opening parenthesis without closing parenthesis. The file's own convention two functions above alreadyrequired
Failurehere and cross-referencesparse_range; two sites followed it and these did not. Twoexisting tests pinned the
Errorvariant byassert_eq!, and moving those assertions is the point of thechange rather than collateral damage.
Two changes that alter what a rules file means
Both are deliberate, both are measured, and neither is a bug fix you can take without noticing it.
A string or regex literal ending in a backslash.
parse_string_innerandparse_regex_innereach decidedwhether a delimiter was escaped by testing whether the text in front of it ended with
\, which cannot tell\from\\. When it guessed wrong the scanner resumed past the closing delimiter, so the next quote orslash anywhere in the file became the terminator — an apostrophe in a comment was enough — and every clause
and rule in between was absorbed into a string value, unevaluated and unreported, at exit 0.
Fixing it means a backslash consumes the character after it, whichever character that is. That is the rule
every language with a backslash escape uses, and it makes a backslash's meaning independent of its position.
The consequences: in a string
"a\\b"is now one backslash rather than two, and in a regex\\closesitself so a following
/ends the literal. There is no reading in which\\/both terminates in/^x\\/and does not terminate in
[A-Za-z0-9\\/+=]— the swallow and the fixture are one construct, so one of themhas to move.
Measured blast radius:
\\inside a string literal, a backslash immediately before a quote, and\\/inside a regex each occur zero times across all 313
.guardfiles and 435 KB in this repository and theAWS rule registry. The two exceptions are the two copies of
advanced_regex_negative_lookbehind_rule.guardhere, whose
\\/is rewritten to\/— the spelling that always meant what the file intended. A rulesfile in the wild using
\\/in a regex now fails loudly at parse rather than misreading silently.Mixed range bounds,
r[0,20.5], are now accepted — and that commit is separable. It is not a defect fixand can be dropped without touching anything else.
docs/CLAUSES.mdpromises thatintegerandfloat"compare against each other as numbers. That includes range membership", but its examples are
r[5.0, 100.0]and
r[5, 100]— homogeneous bounds with a mixed value. Mixed bounds are not promised, andRangeTypeholds one type, so refusing them was a representable-shape limitation rather than a defect. The case for
accepting them is that
Size in r[1,2]holds for aSizeof1.5whileSize in r[0,20.5]did not parse atall, so the gate was narrower than the thing it gated; the integer bound widens to float and is refused if
widening would move it. Accepting more input invalidates no existing file. But it changes the accepted grammar
of a public DSL, which is a maintainer's call, so it is its own commit.
Verification
cargo test --release --no-fail-fast: 1489 pass, 0 fail, 2 ignored across 16 targets, from 1379 at thebranch point.
cargo clippy --release --all-targets -- -D warningsandcargo fmt --all -- --checkbothclean.
The whole blast radius across both repositories, and every item in it is intended:
.guardfile parsed with the branch point's binary and this one, compared on exitcode and on the sha of the full tree. 210/210 in the AWS rule registry identical. 105 of 108 here identical;
the three that differ are the deliberately-unparsable fixtures going 255 to 5, with byte-identical tree
output — the exit-code fix, not a parse change.
tests/subdirectory, run ascfn-guard test --dirat clean upstream, before and after. One changed:rules/aws/aws_cloudformation,0 to 1, stdout unchanged. That is the ghost-expectation fix firing on the 30 dead expectations described
above, and nothing else in the registry moves.
git archivesnapshots at explicit refs rather than alive checkout, after two measurements in this work turned out to be reading a tree that had moved
underneath them.
the language semantics rather than from the binary.
cfn-guard testpairs plus1,433
validatepairs — byte-identical, with sensitivity proved rather than assumed by showing a fixture inthe same shape does move across the two binaries.
A caveat on all of it, worth more than the numbers. These are regression instruments. Every one of them
reported zero changes for most of the parser fixes here — correctly, because no fixture contains a forgotten
>>, or a string ending in a backslash, or a transposed range. A differential answers "did any of theseinputs change answer", not "is there an input that gets a wrong answer". Every defect in this branch was
found by reading what a function commits to and then constructing the input that violates it, and each fix
carries a test built the same way.
Two flavours of empty selection answered differently
{}— noResourceskey at all{"Resources":{}}Rows 1 and 2 are the same logical situation as row 3 — an empty selection with
not existsover it — andanswered PASS where row 3 answered SKIP. A rule about DynamoDB tables reported compliance for a document
containing nothing, and correctly reported itself inapplicable for a document containing one unrelated resource.
Across sixteen operator spellings twelve disagreed, and the disagreement was not uniformly permissive: for
exists,!empty,==andinthe empty-document flavour answered FAIL where the other answered SKIP. Thehighest-stakes row was a
whencondition, where anot existsgate opened on one flavour and closed on theother — changing whether the guarded body ran at all rather than only what was reported.
Two fixes were implemented and rejected before this one, both caught by measurement. Treating any
all-unresolved result as an empty selection turns
Resources.*.Properties.BucketEncryption existsfrom FAIL intoSKIP — "this bucket has no encryption configured" is the entire point of a compliance rule, so that reading gives
up the tool's main job. Keying off "an expansion with query parts after it" produced every target row correctly
and then failed four existing tests that encode requirements rather than the defect: an IAM role carrying no
Tagsmust failProperties.Tags[*].Value == /.../, andsome Tags[*].Key == /PROD/overTags: []must fail.Those four tests are unchanged.
The predicate that satisfies all of it: an empty expansion yields an empty selection only when a filter is
still pending at or after the point the collection came up empty. A filter is how the language says "the subset
of things this rule is about", so a filter that has not yet run over an empty input means the rule has no
subjects. A bare wildcard is a projection over a property of a subject already chosen, so an empty projection is
an answer about that subject. Searching from the current index rather than from zero is what keeps
Resources.*[ Type == 'X' ].Properties.Tags[*].Key == /PROD/failing on an empty tag list — that filter hasalready run, the resource was selected, and its empty
Tagsbelongs to it.This routes the empty-collection case into the SKIP path that already exists for filters, which
eval.rs's own comment describes as happening "when the query has filters in them".QueryPart::Indexisexcluded deliberately:
Items[0]names one element rather than enumerating a set.Blast radius: all 44 directories of the AWS rule registry run as
cfn-guard test --dir, and every one of its210 rules files run against three empty-ish documents — 630 verdict pairs, zero changes. Every registry rule
is gated by
when %var !empty, so none takes the path this changes; the defect was reachable by hand-writtenungated rules.
Known limitations
Reviewing this branch found more than reading it did
Later passes over this branch's own commits turned up four things that no amount of reading the diff would
have reached, because each only exists when a documented command is actually run. They are worth listing
separately because three of them are in the tooling around the change rather than in the change.
cargo test --allaborted on this branch. Fifteen of the 34 tests that parse at or nearMAX_NESTING_DEPTHexceeded libtest's 2 MB thread stack in the default debug profile — SIGABRT, cargo exit101 — while
--releasepassed.pr.ymlrunscargo test --verbose, which is debug, on ubuntu, macos andwindows, so this was red on three platforms. The deep parses now run on an explicitly sized thread, so they
no longer depend on the harness's default. What hid it is worth naming:
MAX_NESTING_DEPTH's doc commentjustified the bound with "about 2.3x to spare", measured on an optimized build and written without saying so.
Unoptimized, the same ladder tops out below the bound. Every stack figure in that comment now states its
profile, and the argument for 128 rests on the corpus instead, because a corpus fact survives a change of
profile.
cfn-guard testexited 0 on a rules file it could not parse, injson,yamlandjunit, whileprinting
errors="1"in the same run. A CI job gating on the exit code saw success on a broken ruleset,which is the worst failure direction available for a policy tool.
single-line-summaryalready exited 1 onthe same bytes, so the two format families disagreed. All four now route through the same
TestResult::get_exit_code()rather than assigning codes by hand at each site.The four output formats disagreed about content and about codes in three more places, found by
enumerating the grid rather than by fixing the reported case: a stale-rule-name diagnostic was dropped in
single-line-summarywhen the same case also carried an unparseable expectation string, and thedirectory path answered 7 where three formats and the single-file path answered 1 for identical input. There
are now tests asserting that every format agrees over one rules file and over a directory — the invariant,
not an instance. Both of the earlier fixes here had been to an instance, and one of them created the same
defect pointing the other way.
The Windows registry CI step decided pass/fail from stdout.
if (<command>)in PowerShell tests thecommand's output for truthiness, not its exit code, and that command prints thousands of lines — so the step
logged "have passed" for runs that had exited non-zero, and would have failed a successful run that printed
nothing. Its bash twin uses
if <command>; then, which does test exit status, so the two legs of one checkwere checking different things. Both now test
$LASTEXITCODE. The same expression appeared in theparse-treeloop, where it was correct only by luck.Four doc comments also stated numbers that could not be reproduced, which matters here because a false
comment in this area misdirected several of these investigations. The corpus depth maximum was 6 and is 7,
reached by two files rather than four; the loader's two corpus figures were each high by one, and one of them
named "the deepest CloudFormation template" in a corpus that contains no template files. A ratio was still
derived from a threshold that had already been corrected. And a call-graph component count was removed rather
than corrected: the figure depends on how a parser passed to a higher-order combinator is attributed, four
defensible models give four different answers, and the sentence named none — so no reader could check it. Two
user-facing refusal messages that quoted corpus statistics now make their point qualitatively, because a
message asserting a maximum over a repository whose release cadence this one does not control is wrong again
on someone else's schedule.
Three questions are left open on purpose, each measured and recorded rather than settled by accident: a key
filter after
[*]versus.*; what a filter applied to an already-indexed value (Rules[0][ ... ]) shouldmean; and
::MODULEstripping in a type-block name. Each needs a decision about what the language means, andguessing one is worse than leaving it visible.