Refuse a bound no value could satisfy - #42
Open
VSN2015 wants to merge 1 commit into
Open
Conversation
Last group from the audit. The doctrine is that a malformed contract
fails the boot and never the request; three ways it did not.
A reversed or empty Range excludes every value there is, so the field
it bounds can never validate. `in: 65..18`, `length: 5..2`,
`length: 3...3`, an empty `in: []` and a negative `length:` were all
accepted, and the mistake surfaced as every request to the action
failing on that field — a contract error reported to clients as their
error, once per request, forever. Also `length: 0` on a required
field, where "" is absent and already violates as missing, so nothing
is left to accept; the exported schema said as much, emitting
minLength 1 beside maxLength 0, while nothing refused the declaration
that produced it. Endless and beginless Ranges are legitimate bounds
and are unaffected, as are endpoints that cannot be compared.
An array declared with a BLOCK never had its default: checked:
validate_array_authored_value! only checked elements against `of:`,
which is nil in that case. So a default the block itself would reject
was accepted at class load and handed to every request that omitted
the key. Elements are now checked against the block's own fields, the
same shallow check `of:` gets.
Both are breaking for a contract that ships such a declaration — but
only one that was already broken at request time.
The third was better fixed in the exporter than by raising. Ruby's ^
and $ anchor a LINE; ECMA-262's, without the m flag, anchor the whole
string. So `format: /^\d{5}$/` accepts "evil\n12345" at runtime while
the exported pattern rejects it, which is documentation and
enforcement disagreeing — the one thing an export from contract data
is meant to make impossible. Such a source now stays visible as
x-permittable-pattern, like \Z and the POSIX classes. A caret straight
after [ is class negation and still translates, as does an escaped \$.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #39 — it rewrote
validate_array_authored_value!and the surrounding authored-value validation, which is the exact method one of these changes extends. Merge #39 first and the base retargets tomaster.Last group from the audit. The stated doctrine is that a malformed contract fails the boot and never the request — three ways it didn't.
1. Bounds that exclude every value (breaking)
A reversed or empty
Rangecan never match, so the field it bounds can never validate. All of these were accepted:validate_length!only checked that the spec was a Range or an Integer. The mistake surfaced as every request to the action failing on that field — a contract error reported to clients as their error, once per request, forever.length: 0on arequiredfield is the same shape:""is absent and an absent required field already violates asmissing, so a maximum of 0 leaves nothing to accept. The exported schema already said so, emittingminLength: 1besidemaxLength: 0, while nothing refused the declaration that produced it.Endless and beginless Ranges (
in: 18..,length: ..80) are legitimate bounds and unaffected, as is a single-value Range (5..5), and endpoints that can't be compared are left alone rather than guessed at.2. A block array's
default:was never checked (breaking)validate_array_authored_value!didreturn unless field[:of]— nil for an array declared with a block. So a default the block itself would reject was accepted at class load and handed to every request that omitted the key:Elements are now checked against the block's own fields — required sub-fields present, scalar ones satisfying their own contract — which is the same shallow check
of:already gets.Both of the above are breaking for a contract that ships such a declaration, but only for one that was already broken at request time. Noted as such in the CHANGELOG.
3.
^/$— fixed in the exporter, not by raisingMy audit proposed a class-load warning here. That was the wrong place. Ruby's
^/$anchor a line; ECMA-262's, without themflag, anchor the whole string:That is the documentation and the enforcement disagreeing, which is the one thing an export from contract data is supposed to make impossible — so the exporter is what was wrong, and raising would have broken contracts that work. Such a source now joins the constructs that stay visible as
x-permittable-patternrather than being mistranslated, alongside\Z,\hand the POSIX classes. Nothing breaks, and a real divergence closes.The scan distinguishes anchors from lookalikes: a caret straight after
[is class negation and still translates (/\A[^@\s]+@[^@\s]+\z/, the golden fixture's email pattern, is unchanged), and an escaped\$or\^is a literal (/\A\$\d+\z/→"^\\$\\d+$").\A/\ztranslate exactly and remain the anchors to reach for.Verification
270 examples, 0 failures; rubocop clean; coverage 97.20%.{ "sku" => 42 }violates a:stringsub-field, butcast_stringaccepts a Numeric by design. The spec now uses an Array, which genuinely cannot be a scalar.Scope note
A
length:Range with negative endpoints (length: -5..-1) is still accepted — it can never match, sinceString#lengthis never negative, so strictly it belongs in group 1. It's an implausible typo next to a reversed range, and catching it means special-casing the one bound whose domain the gem knows; left alone deliberately rather than missed.🤖 Generated with Claude Code