permittable 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddca88b2da7672d0995e338ab901188c4ba019f4d5928d7aaf0a5f73a7f8ba9c
4
- data.tar.gz: 1a66ef88adc7ab843798f25c7a5e3444a3e550795d1353139d52b65e05ac5005
3
+ metadata.gz: 9f13494f2345ced22c261510fd65e89b855b263931a423e6abfb3acb9c3df605
4
+ data.tar.gz: b88b5cad51b572acabe91453a322869d3167671854f37f8ec27cd80b2454d361
5
5
  SHA512:
6
- metadata.gz: 7288c663fd8a2d740ca1c1e7fe9ad61d50854fa47e0e9c4a75bb0922bd119d7c4ca9fe99b020b6ea03edfb456d0fe88322161d53cc1bdd71afb6b88f6cf6ccda
7
- data.tar.gz: a9ecd5c620fac068991a0401d76f339163eb20b6bed73ab83a33f5c3895f8bfb82479a86ab9d6385d85321732ce8ebc6c332819b32174bc8d0494c6021d1623f
6
+ metadata.gz: f3255855aee7d875af804e0984e3b1939b4018a3d4e51134e15ea61ad53fbcc549e6ec9464ffcc158d5b0a64a0e140a34806b7e37abd24f1b66fa5fbec88bec1
7
+ data.tar.gz: 658e22032c03f6413ad6343d20a8d493c07158975fea4223cac3fcda3a3db71c119d17f4744f79e1c53982560019b8690be6b03dad7a1f7e3cfe1ca68672d414
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.7.0 (2026-09-16)
4
+ <!-- title: sensitive: redaction, uncorruptible defaults, and stricter class load -->
5
+
6
+ Two silent disclosures and three silent corruptions, all in code that was doing its job wrongly rather than not at all: `sensitive:` redacted nothing unless the value was a String, a swapped filter registry stopped being consulted, a mutable `default:` was shared by every request in the process, `normalize:` could manufacture an empty value that walked past `required`, and `unknown: :error` rejected the keys Rails itself adds to a form POST. Contract mistakes that could only ever fail at request time now fail at class load instead.
7
+
8
+ ### Fixed
9
+ - **Swapping `Permittable.filter_parameter_registry` silently stopped `sensitive:` redaction.** `Permittable::Railtie` appended `filter_parameter_registry.to_proc` — a proc bound to whichever registry instance existed **at boot**. Rails runs railtie initializers *before* `config/initializers`, so a host gem or app that swaps the registry necessarily does so afterwards, leaving Rails filtering through the old instance: `sensitive:` fields registered themselves in the new registry, and the appended proc went on consulting an empty one. The parameter was logged in the clear, with nothing to indicate it. That swap is the reason the writer exists — the gem's own comment names `concerns_on_rails` as doing exactly this — so the broken ordering was the normal case rather than an exotic one. The Railtie now appends `Permittable.filter_parameter_proc`, which resolves the registry at **filter time**; it is one frozen object for the life of the process, so the Railtie's idempotence check still holds across repeated initializer runs.
10
+ - **`sensitive:` redacted nothing unless the value was a String.** The mechanism was a proc filter, and ActiveSupport's `ParameterFilter` dups the value before invoking one and expects in-place mutation — so `optional :pin, :integer, sensitive: true` logged `1234` in the clear, and `sensitive: true` on a nested block logged the whole hash, since `ParameterFilter` checks `value.is_a?(Hash)` *before* the proc filters and recurses into it instead of ever calling them. Both are silent disclosures of exactly the fields a contract marked as the ones not to print. Each registered name is now **also added to `config.filter_parameters` by name**, which redacts a value of any type, while the proc stays for what only it can reach (consumers that snapshot the array at boot). A real Rails boot covers an `:integer` field, a sensitive nested block, and `#inspect`, with `precompile_filter_parameters` on — the modern default, and the reason the mechanism has to be a name rather than a live matcher object: precompilation joins filters by pattern source and discards anything whose matching is decided at filter time. `Permittable.on_sensitive_parameter` is the seam the Railtie installs, so the registry itself stays free of Rails.
11
+ - **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.
12
+ - **`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 "`.
13
+ - **`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.
14
+ - **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.
15
+
16
+ - **A block array's `default:` was checked against a different reading of absence than the request it stands in for.** `""` is absent, so a client sending `items: [{ "sku" => "" }]` is refused with `items[0].sku missing` — but the class-load check for a block array's `default:` treated only `nil` as absent, so `default: [{ "sku" => "" }]` was accepted, and a `default:` is applied **without revalidation**: every request that omitted the key was handed the exact value the contract refuses from a client. The same held for a value that normalizes to empty, because the check normalized *after* deciding absence rather than before. That check now reads absence the way the walker does — normalize first, then `nil`/`""`, with `nullable:` splitting the rule for an explicitly-empty value exactly as it does at request time — and the value half of that rule is now one shared predicate rather than two spellings of it.
17
+
18
+ Contracts that declare no `default:`, no `normalize:`, and no `unknown: :error` are unaffected.
19
+
20
+ ### Added
21
+ - **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.
22
+ - **`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.
23
+
24
+ ### Changed
25
+ - **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.
26
+ - **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.
27
+
28
+
3
29
  ## 0.6.0 (2026-09-08)
4
30
  <!-- title: nullable fields, :json, and strict dates -->
5
31
 
data/README.md CHANGED
@@ -210,7 +210,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
210
210
  request params
211
211
 
212
212
  ├─ 1 unwrap root: params[:user] missing or not a hash → 400
213
- ├─ 2 each field normalize → cast → validate → transform
213
+ ├─ 2 each field normalize → absent? → cast → validate → transform
214
214
  ├─ 3 unknown-key check at every nesting level (unknown: :ignore | :log | :error)
215
215
  ├─ 4 finalize only when nothing violated
216
216
 
@@ -290,8 +290,8 @@ Which options are legal depends on the field kind — anything else raises at cl
290
290
  | `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
291
291
  | `format:` | ✅¹ | — | — | Regexp the value must match |
292
292
  | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
293
- | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
294
- | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
293
+ | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
294
+ | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load, then stored normalized and frozen (each request gets its own copy) |
295
295
  | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
296
296
  | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
297
297
  | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
@@ -365,7 +365,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
365
365
 
366
366
  ### Absence, defaults, and partial updates
367
367
 
368
- `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
368
+ `nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`.
369
369
 
370
370
  That single rule produces the behaviour you want from a `PATCH`:
371
371
 
@@ -487,7 +487,9 @@ Resolution order per violation: the field's own `message:` (String, or the Hash
487
487
  | `:log` | Dropped, with a `logger.warn` naming the full paths |
488
488
  | `:error` | Each undeclared key becomes an `unknown` violation |
489
489
 
490
- Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
490
+ Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token`, `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level, so `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router nor a form.
491
+
492
+ The exemption covers the *check* only. Monitor mode still hands back the form keys in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `_method` itself; only the router's three are dropped there.
491
493
 
492
494
  ### Output reshaping (`transform:` and `finalize`)
493
495
 
@@ -553,9 +555,15 @@ Mark a field `sensitive: true` and its name is registered with `Permittable.filt
553
555
  optional :ssn, :string, sensitive: true
554
556
  ```
555
557
 
556
- The indirection is deliberate. Appending plain symbols to `config.filter_parameters` at class-load time misses every consumer that snapshots the list at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers, precompiled filters. A **single proc appended once at boot, consulting a live registry at filter time**, means fields registered when a controller loads later (lazy loading in development) are still redacted. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
558
+ Two mechanisms, because neither covers the ground alone.
559
+
560
+ A **single proc appended once at boot, consulting a live registry at filter time**, is what reaches consumers that snapshot `config.filter_parameters` at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers — so a field registered when a controller loads later (lazy loading in development) is still redacted there. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
561
+
562
+ But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
563
+
564
+ Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#names`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own. `#to_proc` must return a callable of arity 2 (`key, value`) or 3 (`key, value, original_params`), matching what Rails' own parameter filtering accepts; anything that does not respond to `#to_proc` is refused at the point of the swap rather than on the next request.
557
565
 
558
- Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
566
+ **The swap works at any point**, including from `config/initializers` — which matters, because Rails runs railtie initializers *before* those, so a swap always happens after `Permittable::Railtie` has appended its filter. Two things make that safe. The appended proc (`Permittable.filter_parameter_proc`) resolves the registry at **filter time** rather than closing over whichever instance existed at boot, so whichever registry is current does the redacting. And the swap **carries the previous registry's names into the new one**, so a `sensitive:` field registered by a contract that loaded before the swap keeps being redacted afterwards. Without that, the two halves of an app would each redact only what the other did not.
559
567
 
560
568
  ### Instrumentation
561
569
 
@@ -728,7 +736,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
728
736
 
729
737
  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.
730
738
 
731
- **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.
739
+ **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.
732
740
 
733
741
  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.
734
742
 
@@ -782,7 +790,8 @@ Output is deterministic (fixed key order, declaration-order properties), so the
782
790
  | Constant | Purpose |
783
791
  |---|---|
784
792
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
785
- | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
793
+ | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry; entries already registered are carried across |
794
+ | `Permittable.filter_parameter_proc` | The single proc `Permittable::Railtie` appends to `config.filter_parameters`; consults the current registry at filter time |
786
795
  | `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
787
796
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
788
797
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
@@ -805,9 +814,10 @@ A bad contract is a programmer error, so it fails when the class loads — never
805
814
  - An unknown type, listing the supported ones
806
815
  - An unknown `normalize:` preset, listing the presets
807
816
  - `format:`, `length:`, or `normalize:` on a non-`:string` field
808
- - `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
817
+ - `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?`
818
+ - 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`)
809
819
  - `validate:` or `transform:` that isn't callable
810
- - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:`
820
+ - 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
811
821
  - A `default: nil` or `example: nil` on a field that isn't `nullable:`
812
822
  - A `:json` field's `default:`/`example:` that isn't a Hash, or that its own `length:`/`max_depth:` would reject
813
823
  - A `max_depth:` that isn't a positive Integer
@@ -10,8 +10,9 @@ module Permittable
10
10
  #
11
11
  # Matching mirrors Rails symbol-filter semantics: case-insensitive substring
12
12
  # match on the parameter key. The whole object is duck-typed (#add,
13
- # #include?, #to_proc, #reset!) so a host can swap in its own registry via
14
- # `Permittable.filter_parameter_registry=` and pool registrations.
13
+ # #include?, #to_proc, #names, #reset!) so a host can swap in its own
14
+ # registry via `Permittable.filter_parameter_registry=` and pool
15
+ # registrations.
15
16
  class FilterParameterRegistry
16
17
  FILTERED = "[FILTERED]".freeze
17
18
 
@@ -55,6 +56,14 @@ module Permittable
55
56
  @proc
56
57
  end
57
58
 
59
+ # The names registered so far. Permittable.filter_parameter_registry=
60
+ # reads this off the outgoing registry and re-adds each name to the
61
+ # incoming one, so a swap never un-redacts a field that a contract
62
+ # loaded before it had already registered.
63
+ def names
64
+ @mutex.synchronize { @fields.to_a }
65
+ end
66
+
58
67
  # Spec hygiene — the registry is process-global.
59
68
  def reset!
60
69
  @mutex.synchronize do
@@ -33,16 +33,23 @@ module Permittable
33
33
 
34
34
  # Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G,
35
35
  # inline flag groups, absence operator, conditionals, POSIX classes,
36
- # possessive quantifiers). A source matching this is left untranslated
37
- # the scan is deliberately over-eager on escaped lookalikes because a
38
- # wrong pattern in published docs is worse than a missing one.
36
+ # possessive quantifiers) and Ruby's ^ and $, which anchor a LINE where
37
+ # ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts
38
+ # "evil\n12345" at runtime, so emitting its source as `pattern` would
39
+ # publish a rule stricter than the server enforces, and an export from
40
+ # contract data is supposed to make that impossible. Straight after a [
41
+ # neither is an anchor — ^ is class negation and $ is a literal — so both
42
+ # stay translatable there. Otherwise the scan is deliberately over-eager
43
+ # on escaped lookalikes, because a wrong pattern in published docs is
44
+ # worse than a missing one.
39
45
  UNTRANSLATABLE = /
40
46
  \\[ZhHKRG] |
41
47
  \(\?[a-z-]+[:)] |
42
48
  \(\?~ |
43
49
  \(\?\( |
44
50
  \[\[: |
45
- [*+?]\+
51
+ [*+?]\+ |
52
+ (?<![\\\[])[\^$]
46
53
  /x
47
54
 
48
55
  # Request-body schema for one rule from `permittable_contracts` /
@@ -9,8 +9,24 @@ module Permittable
9
9
  class Railtie < Rails::Railtie
10
10
  initializer "permittable.filter_parameters",
11
11
  before: "active_record.set_filter_attributes" do |app|
12
- filter = ::Permittable.filter_parameter_registry.to_proc
12
+ # Late-bound on purpose — see Permittable.filter_parameter_proc. This
13
+ # initializer runs before config/initializers, so a registry swapped
14
+ # there must still be the one consulted at filter time.
15
+ filter = ::Permittable.filter_parameter_proc
13
16
  app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
17
+
18
+ # The proc above redacts Strings, live, and survives precompilation.
19
+ # What it cannot reach is a value that is not a String — ParameterFilter
20
+ # mutates values in place for proc filters, and skips them entirely for
21
+ # a Hash — so each registered name is ALSO added by name, which redacts
22
+ # any value type. Appending later still works: precompilation `replace`s
23
+ # this array in place and ActionDispatch reads the same object per
24
+ # request, so a name registered when a controller loads is seen by the
25
+ # next request. It is the array, not a snapshot, that has to be fed.
26
+ ::Permittable.on_sensitive_parameter do |name|
27
+ filters = app.config.filter_parameters
28
+ filters << name unless filters.include?(name)
29
+ end
14
30
  end
15
31
 
16
32
  rake_tasks do
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.6.0".freeze
2
+ VERSION = "0.7.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -4,6 +4,7 @@ require "active_support/notifications"
4
4
  require "active_support/hash_with_indifferent_access"
5
5
  require "active_support/core_ext/hash/indifferent_access" # nested plain Hashes inside HWIA.new
6
6
  require "active_support/core_ext/class/attribute"
7
+ require "active_support/core_ext/object/deep_dup" # authored default:/example: values are copied before freezing
7
8
  require "active_support/core_ext/string/inflections"
8
9
  require "active_support/core_ext/string/filters"
9
10
  require "bigdecimal"
@@ -98,7 +99,12 @@ require "permittable/filter_parameter_registry"
98
99
  # guess. nil and "" are both treated as ABSENT (the query-param convention):
99
100
  # absent optional fields are OMITTED from the result (so partial updates never
100
101
  # nil-out columns), absent required fields violate, and `default:` fills
101
- # absence.
102
+ # absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
103
+ # there is exactly one reading of absence and a value that normalizes to empty
104
+ # (" " under :squish) cannot satisfy a required field by becoming "". An
105
+ # authored `default:`/`example:` is stored normalized — the form it was
106
+ # validated in — and deep-frozen on a copy, so no request can corrupt it for
107
+ # the next.
102
108
  #
103
109
  # `nullable: true` splits that rule in two for one field, which is how a PATCH
104
110
  # clears a column: a key the client never sent stays absent (defaults apply,
@@ -127,7 +133,12 @@ require "permittable/filter_parameter_registry"
127
133
  # `sensitive: true` registers the field name with
128
134
  # Permittable.filter_parameter_registry (swappable — a host gem can point it
129
135
  # at its own registry), consulted at filter time by the proc
130
- # Permittable::Railtie appends to `config.filter_parameters`.
136
+ # Permittable::Railtie appends to `config.filter_parameters`. The name is
137
+ # ALSO published to that Railtie by name (see register_sensitive_parameter),
138
+ # because a proc filter can only redact String values — ActiveSupport dups
139
+ # the value and expects in-place mutation, and never calls the proc at all
140
+ # for a Hash — so a name in config.filter_parameters is what covers an
141
+ # :integer field or a sensitive nested block.
131
142
  #
132
143
  # OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
133
144
  # Two layers, both operating on the validated COPY (the request's `params` is
@@ -162,6 +173,31 @@ module Permittable
162
173
  # Rails merges routing bookkeeping into params; a top-level (root: false)
163
174
  # unknown-keys check must not flag them.
164
175
  ROUTING_KEYS = %w[controller action format].freeze
176
+ # Nor the keys an ordinary form POST carries — the CSRF token, the verb
177
+ # override, the encoding probe, and the submit button's name. Without this
178
+ # `unknown: :error` was unusable outside a JSON API: every browser form
179
+ # failed on the framework's own keys rather than on anything the client got
180
+ # wrong. Exempt from the CHECK only: unlike the routing keys these are NOT
181
+ # stripped from monitor mode's raw pass-through, where handing back an
182
+ # untouched params hash is the whole promise and a legacy action may well
183
+ # read `_method` itself.
184
+ FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze
185
+ # ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
186
+ # is DECIDED with them, which is what the call sites care about — and the
187
+ # asymmetry between them is the deliberate point, so spell it once here
188
+ # rather than leaving a bare ROUTING_KEYS to read like an oversight.
189
+ UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
190
+ MONITOR_DROPPED_KEYS = ROUTING_KEYS
191
+
192
+ # The single proc Permittable::Railtie appends to config.filter_parameters.
193
+ # Declared with an optional third parameter so its own arity is -3 and Rails
194
+ # passes `original_params`; the registry's callable is then invoked by ITS
195
+ # arity, so both the 2- and 3-argument proc-filter shapes Rails accepts work
196
+ # as a swapped-in registry's #to_proc.
197
+ FILTER_PARAMETER_PROC = lambda do |key, value, original = nil|
198
+ inner = filter_parameter_registry.to_proc
199
+ inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
200
+ end.freeze
165
201
 
166
202
  NORMALIZERS = {
167
203
  squish: ->(v) { v.squish },
@@ -183,7 +219,84 @@ module Permittable
183
219
  end
184
220
  end
185
221
 
186
- attr_writer :filter_parameter_registry
222
+ # Swapping registries must not un-redact anything. Contracts that loaded
223
+ # BEFORE the swap registered on the outgoing registry, and after the swap
224
+ # nothing consults it any more — so its entries are carried into the new
225
+ # one, which is the mirror image of the bug that made the proc late-bound
226
+ # in the first place. Validated here rather than at filter time: a
227
+ # registry with no #to_proc used to be silently never consulted, and
228
+ # late-binding it would instead raise NoMethodError on every request.
229
+ def filter_parameter_registry=(registry)
230
+ unless registry.nil? || registry.respond_to?(:to_proc)
231
+ raise ArgumentError,
232
+ "#{LABEL}: filter_parameter_registry must respond to #to_proc (got #{registry.class})"
233
+ end
234
+
235
+ @registry_mutex.synchronize do
236
+ previous = @filter_parameter_registry
237
+ @filter_parameter_registry = registry
238
+ next unless registry && previous.respond_to?(:names) && registry.respond_to?(:add)
239
+
240
+ previous.names.each { |name| registry.add(name) }
241
+ end
242
+ registry
243
+ end
244
+
245
+ # The proc Permittable::Railtie appends to config.filter_parameters.
246
+ #
247
+ # It resolves the registry at FILTER time rather than closing over
248
+ # whichever instance existed at boot. Rails runs railtie initializers
249
+ # BEFORE config/initializers, so an app or host gem that swaps the
250
+ # registry — the pooling the writer exists for — necessarily does so
251
+ # after the Railtie has already appended its proc. A proc bound to the old
252
+ # instance would go on consulting an empty registry and silently redact
253
+ # nothing, while `sensitive:` fields registered themselves in the new one.
254
+ #
255
+ # One frozen object for the life of the process, so the Railtie's
256
+ # idempotence check (include? before <<) holds across repeated initializer
257
+ # runs with no memo to synchronise.
258
+ def filter_parameter_proc
259
+ FILTER_PARAMETER_PROC
260
+ end
261
+
262
+ # Every `sensitive:` registration, published to whatever is listening.
263
+ #
264
+ # A proc filter cannot be the whole mechanism: ActiveSupport's
265
+ # ParameterFilter dups the value and expects in-place mutation, so a proc
266
+ # can only redact Strings — and it is never even CALLED for a Hash value,
267
+ # because ParameterFilter checks `value.is_a?(Hash)` first and recurses.
268
+ # So `optional :pin, :integer, sensitive: true` and `sensitive:` on a
269
+ # nested block both logged in the clear. What redacts any value type is a
270
+ # NAME in config.filter_parameters, which only Rails can be told about —
271
+ # hence a sink, installed by Permittable::Railtie, rather than Rails
272
+ # knowledge in this file or in the registry.
273
+ def register_sensitive_parameter(name)
274
+ filter_parameter_registry.add(name)
275
+ # Normalized the way the registry normalizes, so the name a sink sees is
276
+ # the same whether it arrives here or through on_sensitive_parameter's
277
+ # replay of #names — otherwise a sink deduplicating by value would hold
278
+ # both :ssn and "ssn".
279
+ name = name.to_s.downcase
280
+ sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
281
+ nil
282
+ end
283
+
284
+ # Install a sink. It is replayed over the names already registered, since
285
+ # a contract can be declared before the Railtie's initializer runs (a
286
+ # Permittable::Contract at require time, an eager-loaded controller) and
287
+ # would otherwise never reach it.
288
+ def on_sensitive_parameter(&sink)
289
+ @registry_mutex.synchronize { sensitive_parameter_sinks << sink }
290
+ registry = filter_parameter_registry
291
+ registry.names.each { |name| sink.call(name) } if registry.respond_to?(:names)
292
+ sink
293
+ end
294
+
295
+ # The installed sinks. Process-global, like the registry — specs that
296
+ # install one `.clear` this afterwards.
297
+ def sensitive_parameter_sinks
298
+ @sensitive_parameter_sinks ||= []
299
+ end
187
300
 
188
301
  # App-wide default for rules that don't declare their own mode:.
189
302
  # :enforce (the default) rejects violating requests; :monitor reports
@@ -250,10 +363,12 @@ module Permittable
250
363
  TRUE_VALUES = [true, "true", "1", 1].freeze
251
364
  FALSE_VALUES = [false, "false", "0", 0].freeze
252
365
 
253
- # Full pipeline for one scalar field: normalize → cast → in / format /
254
- # length / validate.
366
+ # Pipeline for one scalar field: cast → in / format / length / validate.
367
+ # `normalize:` is NOT applied here — it is its own stage, run by the
368
+ # caller before the absence rule (a value that normalizes to "" is absent
369
+ # like any other empty value), so normalizing again here would call a
370
+ # host's `normalize:` proc twice per value.
255
371
  def check_scalar(field, value)
256
- value = apply_normalize(field[:normalize], value)
257
372
  status, value = cast(field[:type], value)
258
373
  return [status, value] unless status == :ok
259
374
 
@@ -429,6 +544,14 @@ module Permittable
429
544
  normalizer.call(value)
430
545
  end
431
546
 
547
+ # nil and "" are both ABSENT — see the module comment. The VALUE half of
548
+ # that rule (the walker adds the key-presence half), shared with
549
+ # macro-time `default:`/`example:` checking so a default cannot be held
550
+ # to a different reading of absence than the request it stands in for.
551
+ def absent_value?(value)
552
+ value.nil? || (value.is_a?(String) && value.empty?)
553
+ end
554
+
432
555
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
433
556
  # and the right semantics for validation.
434
557
  def included_in?(allowed, value)
@@ -575,12 +698,18 @@ module Permittable
575
698
  if field[:required] && field.key?(:default)
576
699
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
577
700
  end
578
- if field.key?(:in) && !field[:in].respond_to?(:include?)
579
- raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
701
+
702
+ if field.key?(:in)
703
+ unless field[:in].respond_to?(:include?)
704
+ raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
705
+ end
706
+
707
+ assert_satisfiable!(name, :in, field[:in])
580
708
  end
581
709
 
582
710
  validate_string_only_opts!(field)
583
711
  validate_length!(name, field[:length]) if field.key?(:length)
712
+ validate_required_length!(field)
584
713
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
585
714
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
586
715
  resolve_normalizer!(field)
@@ -618,9 +747,9 @@ module Permittable
618
747
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
619
748
 
620
749
  status, code = Coercion.check_json(field, field[opt])
621
- return if status == :ok
750
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
622
751
 
623
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
752
+ field[opt] = freeze_authored(field[opt])
624
753
  end
625
754
 
626
755
  # format / length / normalize reason about characters; on any other
@@ -636,9 +765,49 @@ module Permittable
636
765
  end
637
766
 
638
767
  def validate_length!(name, length)
639
- return if length.is_a?(Range) || length.is_a?(Integer)
768
+ unless length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?)
769
+ raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
770
+ "(got #{length.inspect})"
771
+ end
772
+
773
+ assert_satisfiable!(name, :length, length)
774
+ end
640
775
 
641
- raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer"
776
+ # A reversed Range (5..2), an exclusive Range with equal endpoints
777
+ # (3...3), or an empty set (in: []) excludes every value there is, so the
778
+ # field it bounds can never validate. That used to surface as every
779
+ # request to the action failing on that field — a contract mistake
780
+ # reported as a client error, once per request, forever. Endless and
781
+ # beginless Ranges are legitimate bounds, and endpoints that cannot be
782
+ # compared are left alone rather than guessed at.
783
+ def assert_satisfiable!(name, opt, bound)
784
+ return unless unsatisfiable?(bound)
785
+
786
+ raise ArgumentError, "#{LABEL}: :#{opt} for :#{name} is empty (#{bound.inspect}) — no value can satisfy it"
787
+ end
788
+
789
+ def unsatisfiable?(bound)
790
+ return bound.empty? if bound.respond_to?(:empty?)
791
+ return false unless bound.is_a?(Range) && bound.begin && bound.end
792
+
793
+ comparison = bound.begin <=> bound.end
794
+ return false if comparison.nil?
795
+
796
+ bound.exclude_end? ? !comparison.negative? : comparison.positive?
797
+ end
798
+
799
+ # "" is ABSENT and an absent required field violates as missing, so a
800
+ # required string can never validly be empty: a maximum length of 0
801
+ # leaves it nothing at all to accept. The exported schema already said
802
+ # so — minLength 1 alongside maxLength 0 — while nothing refused the
803
+ # declaration that produced it.
804
+ def validate_required_length!(field)
805
+ spec = field[:length]
806
+ return unless field[:required] && spec
807
+ return unless Coercion.length_ok?(spec, 0) && !Coercion.length_ok?(spec, 1)
808
+
809
+ raise ArgumentError, "#{LABEL}: :length for :#{field[:name]} is 0 on a required field — an absent or " \
810
+ "empty value already violates as missing, so nothing could satisfy it"
642
811
  end
643
812
 
644
813
  def validate_callable!(name, opt, value)
@@ -661,22 +830,75 @@ module Permittable
661
830
  # An authored value (`default:`, or a documentation `example:`) must
662
831
  # satisfy the field's own contract — catching a lie at class load beats
663
832
  # shipping it to every request (or publishing it in generated docs).
833
+ # The authored value is STORED normalized, because that is the form it was
834
+ # validated in: `default: " free "` with `normalize: :squish` was
835
+ # checked as "free" and used to be handed to requests as " free ".
664
836
  def validate_authored_value!(field, opt)
665
837
  return unless field.key?(opt)
666
838
  return if authored_nil!(field, opt)
667
839
 
668
- status, code = Coercion.check_scalar(field, field[opt])
669
- return if status == :ok
840
+ value = Coercion.apply_normalize(field[:normalize], field[opt])
841
+ status, code = Coercion.check_scalar(field, value)
842
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
670
843
 
671
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
844
+ field[opt] = freeze_authored(value)
672
845
  end
673
846
 
674
847
  def validate_array_authored_value!(field, opt)
675
848
  value = field[opt]
676
849
  return if authored_nil!(field, opt)
677
850
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
678
- return unless field[:of]
679
851
 
852
+ validate_array_elements!(field, opt, value) if field[:of]
853
+ validate_array_element_hashes!(field, opt, value) if field[:fields]
854
+ field[opt] = freeze_authored(value)
855
+ end
856
+
857
+ # The nested-block counterpart of the of: element check below. Without it
858
+ # `field[:of]` was nil for a block array, so its `default:` skipped
859
+ # validation entirely and whatever was authored went straight to every
860
+ # request that omitted the key. Shallow in the same way the of: check is:
861
+ # required sub-fields must be present and scalar ones must satisfy their
862
+ # own contract, which is what an authored value gets wrong.
863
+ def validate_array_element_hashes!(field, opt, value)
864
+ value.each do |element|
865
+ unless element.is_a?(Hash)
866
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \
867
+ "where the block declares a hash"
868
+ end
869
+
870
+ # Wrapped the way permittable_check_element wraps an element at
871
+ # request time, so class load reads keys exactly as a request does.
872
+ indifferent = ActiveSupport::HashWithIndifferentAccess.new(element)
873
+ field[:fields].each { |sub| validate_array_element_field!(field, opt, indifferent, sub) }
874
+ end
875
+ end
876
+
877
+ def validate_array_element_field!(field, opt, element, sub)
878
+ # Normalized before absence is read, and absence read with the runtime's
879
+ # own rule: a default: is applied WITHOUT revalidation, so anything this
880
+ # check waves through is handed to the app unexamined — and "" here used
881
+ # to mean a default could carry the very value a client is refused.
882
+ value = Coercion.apply_normalize(sub[:normalize], element[sub[:name]])
883
+ if Coercion.absent_value?(value)
884
+ # nullable: splits that rule exactly as permittable_explicit_null?
885
+ # does — a key present but empty is an explicit null, not an absence.
886
+ return if sub[:nullable] && element.key?(sub[:name])
887
+ return unless sub[:required]
888
+
889
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \
890
+ "which the block declares as required"
891
+ end
892
+ return unless sub[:kind] == :scalar
893
+
894
+ status, code = Coercion.check_scalar(sub, value)
895
+ return if status == :ok
896
+
897
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} has :#{sub[:name]} " \
898
+ "violating its own contract (#{code})"
899
+ end
900
+
901
+ def validate_array_elements!(field, opt, value)
680
902
  value.each do |element|
681
903
  status, code = Coercion.cast(field[:of], element)
682
904
  next if status == :ok
@@ -685,6 +907,29 @@ module Permittable
685
907
  end
686
908
  end
687
909
 
910
+ # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
911
+ # field hashes — an authored `default:` or `example:` value stayed
912
+ # mutable, and HashWithIndifferentAccess hands a non-frozen Array (and
913
+ # any String) to the result BY REFERENCE. So one request appending to
914
+ # `permitted_params[:tags]` corrupted the default for every later request
915
+ # in the process. Freezing a COPY fixes that without freezing an object
916
+ # the host app passed in and may still be using.
917
+ def freeze_authored(value)
918
+ deep_freeze(value.deep_dup)
919
+ end
920
+
921
+ def deep_freeze(value)
922
+ case value
923
+ when Hash
924
+ value.each_pair do |key, element|
925
+ deep_freeze(key)
926
+ deep_freeze(element)
927
+ end
928
+ when Array then value.each { |element| deep_freeze(element) }
929
+ end
930
+ value.freeze
931
+ end
932
+
688
933
  # An authored nil is only meaningful on a nullable field, where it says
689
934
  # "absent means clear" (PUT semantics) rather than "no default". On any
690
935
  # other field it is a value nil could never satisfy, so it fails at class
@@ -848,7 +1093,7 @@ module Permittable
848
1093
 
849
1094
  def register_sensitive_params(fields)
850
1095
  fields.each do |field|
851
- Permittable.filter_parameter_registry.add(field[:name]) if field[:sensitive]
1096
+ Permittable.register_sensitive_parameter(field[:name]) if field[:sensitive]
852
1097
  register_sensitive_params(field[:fields]) if field[:fields]
853
1098
  end
854
1099
  end
@@ -957,7 +1202,7 @@ module Permittable
957
1202
  return ActiveSupport::HashWithIndifferentAccess.new unless source
958
1203
 
959
1204
  passed = ActiveSupport::HashWithIndifferentAccess.new(source)
960
- rule[:root] ? passed : passed.except(*ROUTING_KEYS)
1205
+ rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
961
1206
  end
962
1207
 
963
1208
  def raise_invalid_parameters!(violations, status:)
@@ -1039,13 +1284,13 @@ module Permittable
1039
1284
  fields.each do |field|
1040
1285
  key = field[:name].to_s
1041
1286
  full = permittable_path(path, key)
1042
- value = hash[key]
1287
+ value = permittable_normalized(field, hash[key])
1043
1288
 
1044
1289
  if permittable_absent?(value, hash, key)
1045
1290
  if permittable_explicit_null?(field, hash, key)
1046
1291
  result[key] = nil
1047
1292
  elsif field.key?(:default)
1048
- result[key] = field[:default]
1293
+ result[key] = permittable_default(field)
1049
1294
  elsif field[:required]
1050
1295
  violations << permittable_violation(field, full, "missing")
1051
1296
  end
@@ -1062,6 +1307,7 @@ module Permittable
1062
1307
  key = field[:name].to_s
1063
1308
  case field[:kind]
1064
1309
  when :scalar
1310
+ # Already normalized by permittable_normalized, before the absence rule.
1065
1311
  permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
1066
1312
  when :json
1067
1313
  permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
@@ -1127,9 +1373,30 @@ module Permittable
1127
1373
  nil
1128
1374
  end
1129
1375
 
1376
+ # `normalize:` runs BEFORE the absence rule, not inside the cast, so there
1377
+ # stays exactly ONE reading of absence. Otherwise a value that normalizes to
1378
+ # empty walked straight past it: `required :name, :string, normalize:
1379
+ # :squish` rejected "" as missing but accepted " " as "" — the silent
1380
+ # corruption strict coercion exists to refuse, delivered by the gem's own
1381
+ # preset. Only scalars take normalize:, and apply_normalize is itself a
1382
+ # no-op without one, so it owns that decision for every caller.
1383
+ def permittable_normalized(field, value)
1384
+ Coercion.apply_normalize(field[:normalize], value)
1385
+ end
1386
+
1387
+ # An authored default belongs to the contract, which is frozen data (see
1388
+ # ContractBuilder#freeze_authored). HashWithIndifferentAccess copies a
1389
+ # frozen Array or Hash as it assigns it, but stores a String as-is — so
1390
+ # that one is copied here, leaving every value in the result the app's own
1391
+ # to mutate.
1392
+ def permittable_default(field)
1393
+ value = field[:default]
1394
+ value.is_a?(String) ? value.dup : value
1395
+ end
1396
+
1130
1397
  # nil and "" are both ABSENT — see the module comment.
1131
1398
  def permittable_absent?(value, hash, key)
1132
- !hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
1399
+ !hash.key?(key) || Coercion.absent_value?(value)
1133
1400
  end
1134
1401
 
1135
1402
  # `nullable: true` splits the one absence rule in two: a key the client
@@ -1146,7 +1413,7 @@ module Permittable
1146
1413
 
1147
1414
  declared = fields.map { |f| f[:name].to_s }
1148
1415
  extra = hash.keys.map(&:to_s) - declared
1149
- extra -= ROUTING_KEYS if top_level
1416
+ extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
1150
1417
  return if extra.empty?
1151
1418
 
1152
1419
  if unknown == :error
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport