From 5088b1b1c5dfb2f10d218624936f21e65ed28ecc Mon Sep 17 00:00:00 2001 From: Sang Date: Sat, 12 Sep 2026 09:57:04 +0700 Subject: [PATCH 1/2] Refuse a bound no value could satisfy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 6 ++ README.md | 7 ++- lib/permittable.rb | 102 +++++++++++++++++++++++++++++++-- lib/permittable/json_schema.rb | 15 +++-- spec/json_schema_spec.rb | 16 ++++++ spec/permittable_spec.rb | 61 ++++++++++++++++++++ 6 files changed, 196 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd9465..f74b44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **A mutable `default:` was shared by every request.** Field declarations are frozen data, but the *value* an author wrote for `default:` was not, and `HashWithIndifferentAccess` hands a non-frozen Array — and any String — to the result **by reference**. So `array :tags, of: :string, default: []` gave every request the same Array: one request appending to `permitted_params[:tags]` corrupted the default for every later request in the process, and `Model.new(tags: …)` assigns that same object, so `record.tags << x` was enough to trigger it. The corruption outlived the request and lasted for the life of the process. A `default:` (and a documentation `example:`) is now **deep-copied and frozen at class load**, so the contract cannot be corrupted, and each request is handed its own copy. The copy is the point: the object the host app passed in is never frozen, in case it is still using it. - **`normalize:` could manufacture an empty value that walked past `required`.** `""` is documented as absent, so a required field must violate — but normalization ran *after* absence had already been decided. `required :name, :string, normalize: :squish` therefore rejected `""` as `missing` and **accepted `" "` as `""`**, writing an empty string into the column: exactly the silent corruption strict coercion exists to refuse, delivered by the gem's own preset. `normalize:` now runs **first**, before the absence rule, so a value that normalizes to empty is absent like any other empty value — it takes the `nullable:` / `default:` / `missing` branch. There is still exactly one reading of absence, and a `normalize:` Proc is still called exactly once per value. Relatedly, a `default:` is now **stored** in the form it was validated in: `default: " free "` with `normalize: :squish` was checked as `"free"` and used to be handed to requests as `" free "`. - **`unknown: :error` rejected ordinary form submissions.** Only the router's `controller`/`action`/`format` were exempt from the top-level unknown-key check, but Rails also merges `authenticity_token`, `_method`, `utf8` and `commit` into a form POST — so the strictest setting was unusable outside a JSON API, and every browser form failed on four of the framework's own keys rather than on anything the client got wrong. Those four are now exempt at the top level too. The exemption covers the *check* only, and only at the top level: a form key smuggled inside a `root:` or a nested hash is still `unknown`, a standalone `Permittable::Contract` still exempts nothing (it has neither a router nor a form), and monitor mode still passes the form keys through in its raw hash, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself. +- **An exported `pattern` could be stricter than the rule the server enforces.** 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": "^\\d{5}$"` rejects it — the documentation and the enforcement disagreeing, which is the one thing an export from contract data is meant to make impossible. Such a regexp now joins the constructs that stay visible as `x-permittable-pattern` instead of being mistranslated, alongside `\Z`, `\h` and the POSIX classes. A caret straight after `[` is class negation rather than an anchor and still translates, as does an escaped `\$` or `\^`. `\A`/`\z` translate exactly and remain the anchors to reach for. Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` are unaffected. @@ -15,6 +16,11 @@ Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` - **Swapping the registry now carries the entries it already holds into the new one.** Late-binding the proc fixes redaction for contracts that load *after* a swap, but on its own it breaks the other half: nothing consults the outgoing registry again, so a `sensitive:` field registered by a contract that loaded *before* the swap would have stopped being redacted — the exact mirror image of the bug above, and the case a host gem pooling registrations is most likely to hit, since eager loading in production loads plenty of controllers before `config/initializers` runs. `Permittable.filter_parameter_registry=` now re-adds each name from the outgoing registry (read through a new duck-typed `#names`) to the incoming one, and a real Rails boot covers both halves. - **`Permittable.filter_parameter_registry=` validates what it is given.** A registry with no `#to_proc` used to be accepted silently and simply never consulted; with the proc late-bound it would instead have raised `NoMethodError` inside `process_action` on every request. It now raises `ArgumentError` at the point of the swap, naming the class. The registry's callable may take Rails' two-argument (`key, value`) or three-argument (`key, value, original_params`) proc-filter shape; both are dispatched by arity. +### Changed +- **A bound that no value could satisfy now fails at class load.** A reversed or empty `Range` excludes every value there is, so the field it bounds could never validate — and that surfaced as every request to the action failing on that field: a contract mistake reported to clients as their error, once per request, forever. `in: 65..18`, `length: 5..2`, `length: 3...3`, an empty `in: []`, a negative `length:`, and a `length:` of 0 on a `required` field (where `""` is absent and already violates as `missing`, so nothing is left to accept) are now `ArgumentError` at class load, naming the bound. Endless and beginless Ranges are legitimate and unaffected, as are endpoints that cannot be compared. **Breaking** for a contract that ships such a field, but only for one that was already failing 100% of the requests that reached it. +- **An array declared with a block now checks its `default:` too.** `validate_array_authored_value!` only checked elements against `of:`, which is nil for a block array — so `array :items, default: [{ "nonsense" => true }] do required :sku, :string end` was accepted at class load and handed to every request that omitted the key, bypassing the contract the block declares. Elements are now checked against the block's own fields (required sub-fields present, scalar ones satisfying their own contract), the same shallow check `of:` gets. **Breaking** for a contract whose block-array default was already wrong, which previously returned that value rather than rejecting it. + + ## 0.6.0 (2026-09-08) diff --git a/README.md b/README.md index ca2b730..1e34877 100644 --- a/README.md +++ b/README.md @@ -736,7 +736,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" }) Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs. -**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads. +**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern` — including one anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads. Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change. @@ -814,9 +814,10 @@ A bad contract is a programmer error, so it fails when the class loads — never - An unknown type, listing the supported ones - An unknown `normalize:` preset, listing the presets - `format:`, `length:`, or `normalize:` on a non-`:string` field -- `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?` +- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?` +- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`) - `validate:` or `transform:` that isn't callable -- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` +- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept - A `default: nil` or `example: nil` on a field that isn't `nullable:` - A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject - A `max_depth:` that isn't a positive Integer diff --git a/lib/permittable.rb b/lib/permittable.rb index fb939cc..ef1857b 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -690,12 +690,18 @@ def validate_scalar_opts!(field) if field[:required] && field.key?(:default) raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)" end - if field.key?(:in) && !field[:in].respond_to?(:include?) - raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)" + + if field.key?(:in) + unless field[:in].respond_to?(:include?) + raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)" + end + + assert_satisfiable!(name, :in, field[:in]) end validate_string_only_opts!(field) validate_length!(name, field[:length]) if field.key?(:length) + validate_required_length!(field) validate_callable!(name, :validate, field[:validate]) if field.key?(:validate) validate_callable!(name, :transform, field[:transform]) if field.key?(:transform) resolve_normalizer!(field) @@ -751,9 +757,62 @@ def validate_string_only_opts!(field) end def validate_length!(name, length) - return if length.is_a?(Range) || length.is_a?(Integer) + assert_length_shape!(name, length) + assert_satisfiable!(name, :length, length) + end + + def assert_length_shape!(name, length) + return if length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?) + + raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \ + "(got #{length.inspect})" + end + + # A reversed Range (5..2), an exclusive Range with equal endpoints + # (3...3), or an empty set (in: []) excludes every value there is, so the + # field it bounds can never validate. That used to surface as every + # request to the action failing on that field — a contract mistake + # reported as a client error, once per request, forever. Endless and + # beginless Ranges are legitimate bounds, and endpoints that cannot be + # compared are left alone rather than guessed at. + def assert_satisfiable!(name, opt, bound) + return unless unsatisfiable?(bound) - raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer" + raise ArgumentError, "#{LABEL}: :#{opt} for :#{name} is empty (#{bound.inspect}) — no value can satisfy it" + end + + def unsatisfiable?(bound) + return bound.empty? if bound.respond_to?(:empty?) + return false unless bound.is_a?(Range) + + first = bound.begin + last = bound.end + return false if first.nil? || last.nil? + + comparison = first <=> last + return false if comparison.nil? + + comparison.positive? || (comparison.zero? && bound.exclude_end?) + end + + # "" is ABSENT and an absent required field violates as missing, so a + # required string can never validly be empty: a maximum length of 0 + # leaves it nothing at all to accept. The exported schema already said + # so — minLength 1 alongside maxLength 0 — while nothing refused the + # declaration that produced it. + def validate_required_length!(field) + return unless field[:required] && field.key?(:length) + + spec = field[:length] + max = if spec.is_a?(Range) + spec.end && spec.exclude_end? ? spec.end - 1 : spec.end + else + spec + end + return unless max&.zero? + + raise ArgumentError, "#{LABEL}: :length for :#{field[:name]} is 0 on a required field — an absent or " \ + "empty value already violates as missing, so nothing could satisfy it" end def validate_callable!(name, opt, value) @@ -796,9 +855,44 @@ def validate_array_authored_value!(field, opt) raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array) validate_array_elements!(field, opt, value) if field[:of] + validate_array_element_hashes!(field, opt, value) if field[:fields] field[opt] = freeze_authored(value) end + # The nested-block counterpart of the of: element check below. Without it + # `field[:of]` was nil for a block array, so its `default:` skipped + # validation entirely and whatever was authored went straight to every + # request that omitted the key. Shallow in the same way the of: check is: + # required sub-fields must be present and scalar ones must satisfy their + # own contract, which is what an authored value gets wrong. + def validate_array_element_hashes!(field, opt, value) + value.each do |element| + unless element.is_a?(Hash) + raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \ + "where the block declares a hash" + end + + field[:fields].each { |sub| validate_array_element_field!(field, opt, element, sub) } + end + end + + def validate_array_element_field!(field, opt, element, sub) + value = element.key?(sub[:name]) ? element[sub[:name]] : element[sub[:name].to_s] + if value.nil? + return unless sub[:required] + + raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \ + "which the block declares as required" + end + return unless sub[:kind] == :scalar + + status, code = Coercion.check_scalar(sub, Coercion.apply_normalize(sub[:normalize], value)) + return if status == :ok + + raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} has :#{sub[:name]} " \ + "violating its own contract (#{code})" + end + def validate_array_elements!(field, opt, value) value.each do |element| status, code = Coercion.cast(field[:of], element) diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index fdd3cd9..042a99e 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -33,16 +33,23 @@ module JsonSchema # Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G, # inline flag groups, absence operator, conditionals, POSIX classes, - # possessive quantifiers). A source matching this is left untranslated — - # the scan is deliberately over-eager on escaped lookalikes because a - # wrong pattern in published docs is worse than a missing one. + # possessive quantifiers) — and Ruby's ^ and $, which anchor a LINE where + # ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts + # "evil\n12345" at runtime, so emitting its source as `pattern` would + # publish a rule stricter than the server enforces, and an export from + # contract data is supposed to make that impossible. A caret straight + # after [ is class negation, not an anchor, and stays translatable. + # Otherwise the scan is deliberately over-eager on escaped lookalikes, + # because a wrong pattern in published docs is worse than a missing one. UNTRANSLATABLE = / \\[ZhHKRG] | \(\?[a-z-]+[:)] | \(\?~ | \(\?\( | \[\[: | - [*+?]\+ + [*+?]\+ | + (? true }] do + required :sku, :string + end + end + end + end.to raise_error(ArgumentError, /:default for array :items.*is missing :sku/) + + expect do + permittable_class do + permit_params(:create) do + array :items, default: [{ "sku" => %w[an array] }] do + required :sku, :string + end + end + end + end.to raise_error(ArgumentError, /:default for array :items.*:sku.*invalid_type/) + + expect do + permittable_class do + permit_params(:create) do + array :items, default: [{ "sku" => "a" }] do + required :sku, :string + end + end + end + end.not_to raise_error + end + it "rejects a non-callable :validate" do expect { permittable_class { permit_params(:create) { required :a, :string, validate: :nope } } } .to raise_error(ArgumentError, /:validate for field :a must be callable/) From f23c8e5542328d037958d1191d5d4a87021f9db6 Mon Sep 17 00:00:00 2001 From: Sang Date: Wed, 16 Sep 2026 22:33:35 +0700 Subject: [PATCH 2/2] Tidy the class-load strictness checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five cleanups on the code the previous commit added, no behaviour change except one documented improvement: - `validate_required_length!` re-derived a `length:` maximum, repeating the exclusive-end arithmetic that `JsonSchema.length_bounds` already carries. Ask the existing `Coercion.length_ok?` the question directly instead — "accepts 0, rejects 1" — which drops the Range/Integer branch entirely. - `unsatisfiable?` lost two single-use locals, and `positive? || (zero? && exclude_end?)` reads as a ternary on exclude_end?. - Inlined single-use `assert_length_shape!`; `assert_satisfiable!` keeps its extraction, having two callers. - The block-array default check read keys with a hand-rolled symbol-then-string lookup. Wrap the element in HashWithIndifferentAccess, exactly as `permittable_check_element` does at request time, so class load cannot drift from the runtime it mirrors. - `UNTRANSLATABLE`'s two new anchor alternations differed only in a lookbehind that both need: straight after `[`, `^` is class negation and `$` a literal. Collapsed to one. `[$]` consequently translates now instead of being flagged, which is correct — it means a literal dollar in both dialects — and the CHANGELOG says so. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- lib/permittable.rb | 40 +++++++++++++--------------------- lib/permittable/json_schema.rb | 12 +++++----- 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f74b44d..03bf9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - **A mutable `default:` was shared by every request.** Field declarations are frozen data, but the *value* an author wrote for `default:` was not, and `HashWithIndifferentAccess` hands a non-frozen Array — and any String — to the result **by reference**. So `array :tags, of: :string, default: []` gave every request the same Array: one request appending to `permitted_params[:tags]` corrupted the default for every later request in the process, and `Model.new(tags: …)` assigns that same object, so `record.tags << x` was enough to trigger it. The corruption outlived the request and lasted for the life of the process. A `default:` (and a documentation `example:`) is now **deep-copied and frozen at class load**, so the contract cannot be corrupted, and each request is handed its own copy. The copy is the point: the object the host app passed in is never frozen, in case it is still using it. - **`normalize:` could manufacture an empty value that walked past `required`.** `""` is documented as absent, so a required field must violate — but normalization ran *after* absence had already been decided. `required :name, :string, normalize: :squish` therefore rejected `""` as `missing` and **accepted `" "` as `""`**, writing an empty string into the column: exactly the silent corruption strict coercion exists to refuse, delivered by the gem's own preset. `normalize:` now runs **first**, before the absence rule, so a value that normalizes to empty is absent like any other empty value — it takes the `nullable:` / `default:` / `missing` branch. There is still exactly one reading of absence, and a `normalize:` Proc is still called exactly once per value. Relatedly, a `default:` is now **stored** in the form it was validated in: `default: " free "` with `normalize: :squish` was checked as `"free"` and used to be handed to requests as `" free "`. - **`unknown: :error` rejected ordinary form submissions.** Only the router's `controller`/`action`/`format` were exempt from the top-level unknown-key check, but Rails also merges `authenticity_token`, `_method`, `utf8` and `commit` into a form POST — so the strictest setting was unusable outside a JSON API, and every browser form failed on four of the framework's own keys rather than on anything the client got wrong. Those four are now exempt at the top level too. The exemption covers the *check* only, and only at the top level: a form key smuggled inside a `root:` or a nested hash is still `unknown`, a standalone `Permittable::Contract` still exempts nothing (it has neither a router nor a form), and monitor mode still passes the form keys through in its raw hash, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself. -- **An exported `pattern` could be stricter than the rule the server enforces.** 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": "^\\d{5}$"` rejects it — the documentation and the enforcement disagreeing, which is the one thing an export from contract data is meant to make impossible. Such a regexp now joins the constructs that stay visible as `x-permittable-pattern` instead of being mistranslated, alongside `\Z`, `\h` and the POSIX classes. A caret straight after `[` is class negation rather than an anchor and still translates, as does an escaped `\$` or `\^`. `\A`/`\z` translate exactly and remain the anchors to reach for. +- **An exported `pattern` could be stricter than the rule the server enforces.** 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": "^\\d{5}$"` rejects it — the documentation and the enforcement disagreeing, which is the one thing an export from contract data is meant to make impossible. Such a regexp now joins the constructs that stay visible as `x-permittable-pattern` instead of being mistranslated, alongside `\Z`, `\h` and the POSIX classes. Straight after `[` neither one is an anchor — `^` is class negation and `$` a literal — so `[^a]` and `[$]` still translate, as does an escaped `\$` or `\^`. `\A`/`\z` translate exactly and remain the anchors to reach for. Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` are unaffected. diff --git a/lib/permittable.rb b/lib/permittable.rb index ef1857b..e6ceba2 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -757,15 +757,12 @@ def validate_string_only_opts!(field) end def validate_length!(name, length) - assert_length_shape!(name, length) - assert_satisfiable!(name, :length, length) - end - - def assert_length_shape!(name, length) - return if length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?) + unless length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?) + raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \ + "(got #{length.inspect})" + end - raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \ - "(got #{length.inspect})" + assert_satisfiable!(name, :length, length) end # A reversed Range (5..2), an exclusive Range with equal endpoints @@ -783,16 +780,12 @@ def assert_satisfiable!(name, opt, bound) def unsatisfiable?(bound) return bound.empty? if bound.respond_to?(:empty?) - return false unless bound.is_a?(Range) - - first = bound.begin - last = bound.end - return false if first.nil? || last.nil? + return false unless bound.is_a?(Range) && bound.begin && bound.end - comparison = first <=> last + comparison = bound.begin <=> bound.end return false if comparison.nil? - comparison.positive? || (comparison.zero? && bound.exclude_end?) + bound.exclude_end? ? !comparison.negative? : comparison.positive? end # "" is ABSENT and an absent required field violates as missing, so a @@ -801,15 +794,9 @@ def unsatisfiable?(bound) # so — minLength 1 alongside maxLength 0 — while nothing refused the # declaration that produced it. def validate_required_length!(field) - return unless field[:required] && field.key?(:length) - spec = field[:length] - max = if spec.is_a?(Range) - spec.end && spec.exclude_end? ? spec.end - 1 : spec.end - else - spec - end - return unless max&.zero? + return unless field[:required] && spec + return unless Coercion.length_ok?(spec, 0) && !Coercion.length_ok?(spec, 1) raise ArgumentError, "#{LABEL}: :length for :#{field[:name]} is 0 on a required field — an absent or " \ "empty value already violates as missing, so nothing could satisfy it" @@ -872,12 +859,15 @@ def validate_array_element_hashes!(field, opt, value) "where the block declares a hash" end - field[:fields].each { |sub| validate_array_element_field!(field, opt, element, sub) } + # Wrapped the way permittable_check_element wraps an element at + # request time, so class load reads keys exactly as a request does. + indifferent = ActiveSupport::HashWithIndifferentAccess.new(element) + field[:fields].each { |sub| validate_array_element_field!(field, opt, indifferent, sub) } end end def validate_array_element_field!(field, opt, element, sub) - value = element.key?(sub[:name]) ? element[sub[:name]] : element[sub[:name].to_s] + value = element[sub[:name]] if value.nil? return unless sub[:required] diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index 042a99e..a0fe16b 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -37,10 +37,11 @@ module JsonSchema # ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts # "evil\n12345" at runtime, so emitting its source as `pattern` would # publish a rule stricter than the server enforces, and an export from - # contract data is supposed to make that impossible. A caret straight - # after [ is class negation, not an anchor, and stays translatable. - # Otherwise the scan is deliberately over-eager on escaped lookalikes, - # because a wrong pattern in published docs is worse than a missing one. + # contract data is supposed to make that impossible. Straight after a [ + # neither is an anchor — ^ is class negation and $ is a literal — so both + # stay translatable there. Otherwise the scan is deliberately over-eager + # on escaped lookalikes, because a wrong pattern in published docs is + # worse than a missing one. UNTRANSLATABLE = / \\[ZhHKRG] | \(\?[a-z-]+[:)] | @@ -48,8 +49,7 @@ module JsonSchema \(\?\( | \[\[: | [*+?]\+ | - (?