Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
- **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. 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.

### Added
- **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)
<!-- title: nullable fields, :json, and strict dates -->

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
92 changes: 88 additions & 4 deletions lib/permittable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -751,9 +757,49 @@ def validate_string_only_opts!(field)
end

def validate_length!(name, length)
return if length.is_a?(Range) || length.is_a?(Integer)
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

assert_satisfiable!(name, :length, length)
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}: :#{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) && bound.begin && bound.end

comparison = bound.begin <=> bound.end
return false if comparison.nil?

bound.exclude_end? ? !comparison.negative? : comparison.positive?
end

raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer"
# "" 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)
spec = field[:length]
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"
end

def validate_callable!(name, opt, value)
Expand Down Expand Up @@ -796,9 +842,47 @@ 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

# 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[sub[:name]]
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)
Expand Down
15 changes: 11 additions & 4 deletions lib/permittable/json_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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-]+[:)] |
\(\?~ |
\(\?\( |
\[\[: |
[*+?]\+
[*+?]\+ |
(?<![\\\[])[\^$]
/x

# Request-body schema for one rule from `permittable_contracts` /
Expand Down
16 changes: 16 additions & 0 deletions spec/json_schema_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ def property(name, **opts, &contract)
expect(property("zip") { optional :zip, :string, format: /\A\d{5}\z/ }["pattern"]).to eq("^\\d{5}$")
end

it "refuses to translate Ruby's ^ and $, which are LINE anchors" do
# The runtime accepts "evil\n12345" for /^\d{5}$/ — Ruby anchors a line,
# ECMA-262 anchors the whole string without the m flag. Emitting the
# source verbatim would publish a pattern stricter than the server
# enforces, which is the one thing an export from contract data is
# supposed to make impossible.
prop = property("zip") { optional :zip, :string, format: /^\d{5}$/ }
expect(prop).not_to have_key("pattern")
expect(prop["x-permittable-pattern"]).to eq("/^\\d{5}$/")
end

it "still translates an ESCAPED dollar or caret, which are literals" do
expect(property("amount") { optional :amount, :string, format: /\A\$\d+\z/ }["pattern"])
.to eq("^\\$\\d+$")
end

it "falls back to x-permittable-pattern for flagged or Ruby-only regexps" do
[/abc/i, /\A\h+\z/, /(?i)x/, /[[:alpha:]]+/, /a*+b/].each do |regexp|
prop = property("a") { optional :a, :string, format: regexp }
Expand Down
Loading