Skip to content

Refuse a bound no value could satisfy - #42

Open
VSN2015 wants to merge 1 commit into
fix/absence-and-default-integrityfrom
feature/contract-load-strictness
Open

Refuse a bound no value could satisfy#42
VSN2015 wants to merge 1 commit into
fix/absence-and-default-integrityfrom
feature/contract-load-strictness

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

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 to master.

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 Range can never match, so the field it bounds can never validate. All of these were accepted:

optional :age, :integer, in: 65..18   # every value: inclusion
optional :s,   :string,  length: 5..2 # every value: length
optional :s,   :string,  length: 3...3
optional :plan, :string, in: []
required :n,   :string,  length: 0

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: 0 on a required field is the same shape: "" is absent and an absent required field already violates as missing, so a maximum of 0 leaves nothing to accept. The exported schema already said so, emitting minLength: 1 beside maxLength: 0, while nothing refused the declaration that produced it.

Permittable: :in for :age is empty (65..18) — no value can satisfy it
Permittable: :length for :n is 0 on a required field — an absent or empty value
already violates as missing, so nothing could satisfy 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! did return 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:

array :items, default: [{ "nonsense" => true }] do
  required :sku, :string
end
# => was accepted; returned {"items"=>[{"nonsense"=>true}]}
# => now: :default for array :items is missing :sku, which the block declares as required

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 raising

My audit proposed a class-load warning here. That was the wrong place. Ruby's ^/$ anchor a line; ECMA-262's, without the m flag, anchor the whole string:

c = Contract.define { required :zip, :string, format: /^\d{5}$/ }
c.call({ "zip" => "evil\n12345" })    # valid? true      ← runtime accepts it
c.json_schema[...]["pattern"]          # "^\\d{5}$"       ← export rejects it

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-pattern rather than being mistranslated, alongside \Z, \h and 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/\z translate exactly and remain the anchors to reach for.

Verification

  • 270 examples, 0 failures; rubocop clean; coverage 97.20%.
  • All four new specs fail on Fix three silent corruptions in the absence path #39's HEAD for the right reasons, plus a passing guard that an escaped dollar still translates and that endless/beginless/single-value bounds are still accepted.
  • The golden OpenAPI fixture is byte-identical, which is the check that the anchor scan didn't over-reach into class negation.
  • One spec expectation of mine was wrong and the code was right: I had assumed { "sku" => 42 } violates a :string sub-field, but cast_string accepts 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, since String#length is 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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant