Repository navigation
Conversation
This reverts commit d7f01ec. The fix was extracted to aws-cloudformation#747, which carries a patch-identical commit, so it was open in two places at once. Removing it here leaves aws-cloudformation#747 as the only one, where it can be reviewed on its own instead of inside a 153-commit branch. 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. Conflict resolved in guard/src/rules/eval/operators.rs. A later commit on this branch, e220608 ("Give an unevaluatable regex a clause verdict instead of aborting the run"), rewrote the same expression into a loop so it could read compare_eq s RegexError instead of discarding it. That arm exists only because this commit put a compare_eq call there, and compare_eq is where the range table lives, so there is no version of it that keeps the regex diagnostic and still reverts the range behaviour -- the two are the same call. The pre-fix form, rhsl.contains(rest), is restored. The two helpers e220608 added, unanswerable_reason and not_comparable_because, are kept: match_value calls both independently, so neither becomes dead. Consequence to expect: e220608 s a_regex_in_a_list_literal_fails_the_clause_instead_of_aborting covers the IN [/re/] spellings through this arm, and they no longer reach compare_eq, so it loses its subject. It does not panic -- e220608 also made PartialEq::eq answer unwrap_or(false) in guard/src/rules/path_value.rs, a separate region this revert does not touch. guard/src/rules/eval_tests.rs and guard/src/rules/path_value.rs auto-merged and are the exact inverse of what this commit did to them: +42/-48 and +3/-9 against its +48/-42 and +9/-3. This commit s path_value.rs change was doc comment only. Reverting it also restores probe_vacuous_pass_record_shape, the assertion-free test this commit removed.
…ing the run" This reverts commit e220608. Moved to aws-cloudformation#747, which now carries it as a cherry-pick with the same patch id. It was written on top of aws-cloudformation#747's `compare_eq` call in `contained_in` -- the operators.rs blob it edits is the blob aws-cloudformation#747's commit produced -- so with aws-cloudformation#747 reverted here its list-literal arm has nothing to read. Three cases of `a_regex_in_a_list_literal_fails_the_clause_instead_of_aborting` failed against this branch as a result: `IN [/re/]` no longer reaches `compare_eq`, so no reason is recorded, and `NOT IN [/re/]` answers PASS where the test asserts FAIL. On aws-cloudformation#747 the call is there, so the commit means the same thing it meant here. Reverted rather than dropped from history, for the reason the four reverts below it give: 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. Conflicts resolved in two files. guard/src/rules/eval/operators.rs: the revert wanted to restore `rhsl.iter().any(|elem| elem == rest || compare_eq(rest, elem).unwrap_or(false))`, which is aws-cloudformation#747's line and not this branch's -- 420ef28 already put `rhsl.contains(rest)` back. HEAD is kept. The other side would have reinstated the fix this branch removed, which is the one thing this revert must not do. The rest of the file reverts without argument: `unanswerable_reason` and `not_comparable_because` go, and `match_value` gets back its `Err(Error::NotComparable(reason))` arm and its `_ => unreachable!()`. guard/src/rules/eval_tests.rs: this commit appended 171 lines at an anchor that has since gained more. Only those 171 were removed, located as a contiguous block and compared line for line against what the commit inserted rather than matched by eye. One later test sits after them, `an_inapplicable_dependent_rule_does_not_fail_the_reference`, and its 114 lines stay. The resulting change to the file is 171 deletions and no insertions, the exact inverse of the insertion. guard/src/rules/path_value.rs auto-merged and is the exact inverse of what this commit did to it: the two `unwrap_or(false)` arms go back to `unwrap()` under the comment they carried before. Consequence to name: on this branch a regex that exhausts fancy_regex's backtrack budget aborts the process again, at exit 101, and takes the other rules' verdicts with it. That fix now lives on aws-cloudformation#747 alone, which is the point of moving it.
`contained_in` decided list membership with `Vec::contains`, which compares by `PartialEq`. `PartialEq`
is asked `element == value`, so for a list holding a range it asked `RangeInt == Int` -- the direction
that has no arm, and must not get one, because `eq` has to stay symmetric while membership does not.
A range nested in a list literal therefore matched nothing, in either polarity. For a `Port` of 85:
clause before fixed
Port in [r[80,90]] FAIL pass
Port not in [r[80,90]] pass FAIL
Port in [r[10,20]] FAIL FAIL <- control, 85 is outside
Port not in [r[10,20]] pass pass <- control
Port in r[80,90] pass pass <- control, unwrapped
Port in [r[10,20], 85] pass pass <- control, plain element matches
The second row is the one that matters: a denylist of forbidden port ranges admitted every port. The
same question spelled without the list was always answered correctly, because `Port in r[80,90]`
reaches `compare_eq`, and `compare_eq` is where the range table lives. Two spellings of one question
disagreed, and only one of them was ever tested.
So `contained_in` now asks `compare_eq` as well. `eq` is still asked first, and not out of caution: it
is the only one of the two that relates a range to an equal range, so `compare_eq` alone would lose
`%range in [r[80,90]]`. `compare_eq` can add a match, never remove one, which is why nothing else
moves -- an incomparable pair still answers "no match" rather than raising, as `contains` did.
The doc comment on `impl Eq` claimed the opposite of the measurement: that a range nested in a list
literal never arrives at `eq`. It does arrive, through `Vec::contains`, and that is precisely why the
removed membership arms were not reaching it -- they were written for the direction `contains` does not
ask. Corrected, since a comment that states the inverse of the behaviour is worse than none.
Also removes `probe_vacuous_pass_record_shape`, which an earlier commit of mine left behind. It has no
assertions: it prints a record tree and returns `Ok`. It was scaffolding for reading the record shape,
that shape is asserted by real tests now, and a test that cannot fail is worse than absent -- it counts
as coverage.
Verification: 891 tests, from 873 less the two the probe was contributing. Reverting just the
`contained_in` hunk fails three of the ten new cells and names them, so the table is load-bearing
rather than decorative. `clippy -- -D warnings` and `clippy --all-targets -- -D warnings` clean,
`fmt --check` and `typos` clean. The 440-cell matrix moves zero cells. Registry differential against
the merge-base: 190 pairs, 1,956 expectation checks, zero exit-code changes and zero content changes --
no published rule uses a range token at all, so this could not have moved one.
A rules file comparing a property against a regex that holds a lookaround and a nested quantifier aborted the process at exit 101. Seven spellings reached it: `== /re/`, `!= /re/`, `IN /re/` and `NOT IN /re/` panicked at operators.rs:216, and `IN [/re/]`, `NOT IN [/re/]` and `IN [/re/, 5]` panicked at path_value.rs:299. Every other rule in the file lost its verdict along with the one holding the regex, and with the regex clause in a `when` gate the file reported nothing at all. All seven now exit 19 naming the path and the reason, and a sibling rule still reports its own result. fancy_regex returns a `Result` from `is_match` rather than a `bool` because a backtracking engine can run out of budget instead of answering. A pattern needs a lookaround or a backreference to be put on that engine at all, neither being expressible in the linear automaton, and a nested quantifier then makes the number of paths to try grow with the length of the subject. `/(?!zzz)(\w+\s?)+!/` against eighty characters holding no `!` exceeds the limit while the same pattern against fifteen does not, so a rule that passes review can still abort on a longer UserData or policy document. `match_value` had arms for `Ok` and for `Error::NotComparable` and met everything else with `unreachable!()`. `compare_eq` reports this as `Error::RegexError`, which is not `NotComparable`, so it landed on the catch-all. The missing arm now routes it where `NotComparable` already went, so the clause fails and the run reports it. Those two are the only variants the five comparators produce, the four ordering ones going through `compare_values` whose every error is `NotComparable`, and the residual arm reports rather than aborts because no comparator failure justifies discarding the other rules. fancy_regex's own message is used instead of `RegexError`'s, which reads "Regex expression parse error" and would describe a pattern that parsed and then ran out of backtracking. The second site needed a decision. `PartialEq::eq` returns `bool` and cannot report an error, which is why the `unwrap` was there, under a comment reading "given that we have already validated the regular expression". That premise is false: parse-time validation proves the pattern compiles and says nothing about whether a match completes. Removing the arms was not available either, because the map key filter in `QueryResolver::select` decides `keys == /re/` through them, aws_meta_appender depends on that, and five tests fail if they stop matching. So `eq` answers `false`, and the cost is written down where it is paid: through `eq` a regex that cannot be evaluated reads as "not equal", and a key filter selects nothing for that key. docs/KNOWN_ISSUES.md already records the silent conversion of a suppressed error to `false` under PartialEq as a tracked defect. The spelling a rule author writes does not pay that cost. `IN [/re/]` reaches `contained_in`, which already consulted `compare_eq` after `eq`; it now reads `compare_eq`'s error instead of discarding it, so the bracketed and unwrapped spellings give the same verdict and the same message. An element that matches still wins over an element that could not be evaluated, the answer then not having depended on the one that failed. Only `RegexError` is promoted in that loop. `NotComparable` keeps its `unwrap_or(false)` reading deliberately: `NOT IN` against an operand of a kind it cannot be compared with currently passes, and `incomparable_membership` emits a deprecation notice for that spelling so rule authors hear about the change before a pipeline does. Promoting it here landed that change without its notice and moved cells of `every_operator_and_operand_shape_agrees_with_a_stated_oracle` and `generated_rule_shapes_hold_the_evaluator_invariants`. Tests use `catch_unwind`, so the absence of the panic is asserted rather than assumed; all seven fail against the previous build by reporting the panic, which an assertion on a returned status could not do, never being reached. Verified: 1279 tests before and 1309 after with no failures, the 30 added being 15 cases counted in each of the two suites that compile the module. clippy --release --all-targets -D warnings, fmt --check and typos are clean. The 2666-pair cross product of guard/resources/validate shows zero exit-code and zero text differences against the previous build, as does the 193-pair rule and test cross product of aws-guard-rules-registry at 6aca96e, which stays at 193 of 193 exiting 0. Ordinary regex comparisons are byte-for-byte identical across five output formats in both spellings and both polarities.
e8daafa to
25d72d0
Compare
| } else { | ||
| false | ||
| match Regex::new(r.as_str()) { | ||
| Ok(regex) => regex.is_match(s.as_str()).unwrap_or(false), |
There was a problem hiding this comment.
Blocking: a list-valued left-hand side still decides IN through PartialEq, so NOT IN [/re/] now passes
This unwrap_or(false) is still reached from the list arm of contained_in, which the PR didn't change. That arm builds diff with rhsl.contains(each) (operators.rs L322), so membership goes through Regex == String here and never reaches compare_eq.
Take Properties.Names NOT IN [/(?!x)((a+)+)b/] where Names is a list. The query doesn't flatten the list, so InOperation sends it to that list arm. On main this line panicked. With this change it returns false, so every element lands in diff. The ListIn negation then sees an empty reverse diff and reports success, and the clause passes with exit 0. IN on the same input fails, but with the generic "was not present" message instead of the regex reason.
So this spelling goes from a loud crash to a silent pass. The template author controls the value, so padding one list element past the backtrack budget gets it through a denylist.
The range fix in d22c58d has the same gap. Ports NOT IN [r[80,90]] with Ports: [85] still passes, because RangeInt == Int returns false.
This contradicts "All seven now exit 19" in the commit message and the doc comment at path_value.rs L356-361. Only the scalar arm (L350) asks compare_eq.
Suggested fix: move the loop at L382-402 into a helper and call it from both arms.
fn membership(value: &PathAwareValue, list: &[PathAwareValue]) -> Result<bool, String> {
let mut unanswerable = None;
for elem in list {
if !matches!(elem, PathAwareValue::Regex(_)) && elem == value {
return Ok(true);
}
match compare_eq(value, elem) {
Ok(true) => return Ok(true),
Err(err @ Error::RegexError(_)) => {
unanswerable.get_or_insert_with(|| unanswerable_reason(err));
}
_ => {}
}
}
unanswerable.map_or(Ok(false), Err)
}
// list arm:
// for each in lhsl {
// match membership(each, rhsl) {
// Ok(true) => {}
// Ok(false) => diff.push(Rc::new(each.clone())),
// Err(reason) => return not_comparable_because(lhs_value, rhs_value, reason),
// }
// }Skipping eq for a Regex element also stops each pattern from being compiled and matched twice, since compare_eq already handles Regex/String and Regex/Regex.
Please add eval_tests cases with a list-valued property, Ports: [85] and Names: [<30 a's>], each in both polarities. I traced this through the code at 25d72d0 but haven't run it, and those cases would confirm it.
There was a problem hiding this comment.
You're right, and thanks for tracing it through. The list arm decided membership with rhsl.contains(each), so a list-valued left-hand side never reached compare_eq: Ports NOT IN [r[80,90]] with Ports: [85] and Names NOT IN [/(?!x)((a+)+)b/] with a list-valued Names both passed at exit 0 on 25d72d0.
42e9766 moves the per-element walk into one helper that both arms call: PartialEq first, then compare_eq, an element that matches winning over one that could not be evaluated, and a RegexError with no match reported as the clause's reason instead of read as false. It adds eval_tests cases with a list-valued property for [85] against [r[80,90]] and for a thirty-a string against that pattern, in both polarities, asserting the recorded reason for the regex cells and the status for the range cells, with plain-element and out-of-range controls. On 25d72d0 nine of the twenty new cells fail; on 42e9766 all pass. The path_value.rs comment that described the scalar arm as if it were both is reworded.
The nested-list branch (rhsl[0].is_list()) is unchanged here, so Size NOT IN [[/re/]] with a list-valued Size still has the same gap; #759 rewrites that branch, and changing it here as well would widen the conflict between the two. I can fold it into this PR instead if you'd rather have it here.
There was a problem hiding this comment.
daffd2a also skips the equality pass for a regex element, as you suggested, so a pattern is compiled and matched once, in compare_eq.
…s of contained_in The list-valued arm of contained_in decided membership with Vec::contains, which is PartialEq. PartialEq has no arm that relates a range to an int, and it reads a regex whose match ran out of backtracking budget as false. So for a list-valued property, `Ports NOT IN [r[80,90]]` with `Ports: [85]` passed, and `Names NOT IN [/(?!x)((a+)+)b/]` passed too, while the scalar arm already answered both correctly. `IN` failed the clause without saying the regex could not be evaluated. Move the per-element walk into is_one_of, which asks PartialEq and then compare_eq, and call it from both arms through elements_not_matched. An element that matches wins over one that could not be evaluated, and a RegexError with no match becomes the clause's reason instead of being read as a miss. The nested-list branch is unchanged. Reword the path_value.rs comment that described the scalar arm as if it were both arms. Add eval_tests cases with a list-valued property in both polarities: a range inside a list literal and an unevaluatable regex, each with plain-element and out-of-range controls so a constant answer cannot pass.
The equality pass in is_one_of compiled and matched a regex element before compare_eq did the same work for the same answer. Skip that pass for a regex element; no verdict changes.
A range inside a list literal on the right of
inornot inwas decided byVec::contains, which compares byPartialEq, andPartialEqhas no arm relating a value to a range. So for aPortof 85,Port not in [r[80,90]]passed andPort in [r[80,90]]failed: a denylist of port ranges that admitted every port. A regex inside a list literal went down the same path into anunwrap()onfancy_regex'sis_match, and a bare regex thatcompare_eqcould not evaluate reached the_ => unreachable!()arm ofmatch_value, so a pattern that exhausted the backtrack limit aborted the whole run instead of failing its clause.What changed, one commit each:
contained_ininguard/src/rules/eval/operators.rsaskscompare_eqafterPartialEqfor each right-hand element, so a range nested in a list literal is decided by the range table; thepath_value.rschange is theimpl Eqdoc comment, which claimed a range nested in a list literal never reacheseq; it does, throughVec::contains, and the comment now says so.match_valueturns aRegexErrorinto the clause's recorded reason instead of hittingunreachable!(), the scalar arm ofcontained_inreads that error rather than discarding it, and theString/Regexarms ofPartialEqinpath_value.rschange fromunwrap()tounwrap_or(false). That last change has a cost, written beside the arms: akeys == /re/key filter whose pattern cannot be evaluated now selects nothing for that key instead of panicking.contained_in, scalar and list-valued, go through one walk,is_one_of, soPorts not in [r[80,90]]withPorts: [85]now fails as well; the branch for a left-hand list against a list of lists still usesrhsl.containsand is unchanged; itspath_value.rshunk updates theimpl Eqdoc comment to say both arms now reachcompare_eqthroughis_one_of.is_one_ofskips thePartialEqpass for a regex element, so a pattern is compiled and matched once, incompare_eq; no verdict changes.Tests, all in
guard/src/rules/eval_tests.rs:a_range_inside_a_list_literal_is_a_range(wrapped and unwrapped, both polarities, a range beside a plain element) anda_range_in_a_list_denylist_denies_a_list_valued_propertypin the range verdicts;a_regex_that_exceeds_the_backtrack_limit_fails_the_clause_instead_of_abortinganda_regex_in_a_list_literal_fails_the_clause_instead_of_abortingrun the clause undercatch_unwindand assert FAIL with a message saying the regular expression could not be evaluated;a_denylist_refuses_a_value_it_could_not_evaluate_in_either_spellingasserts the same FAIL and message for a scalar and a list-valuedSize, withoutcatch_unwind;an_ordinary_regex_comparison_is_unchangedis the control. I also removedprobe_vacuous_pass_record_shape, a test of mine that asserted nothing. The Pull Request workflow run 40575627654 passed on this head.Please review. The list-of-lists branch is unchanged here and is covered by #759.