permittable 0.8.0 → 0.9.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.
data/lib/permittable.rb CHANGED
@@ -1,3 +1,10 @@
1
+ # Before active_support: on activesupport <= 7.0.8.4, requiring it raises
2
+ # NameError (ActiveSupport::LoggerThreadSafeLevel::Logger) unless `logger` is
3
+ # already loaded, because concurrent-ruby 1.3.5 stopped requiring it for them.
4
+ # One stdlib require makes `require "permittable"` work on every activesupport
5
+ # version the gemspec claims, whatever the host's own boot order.
6
+ require "logger"
7
+
1
8
  require "active_support"
2
9
  require "active_support/concern"
3
10
  require "active_support/notifications"
@@ -22,6 +29,7 @@ require "active_support/core_ext/time/calculations"
22
29
  require "bigdecimal"
23
30
  require "date"
24
31
  require "time"
32
+ require "uri" # URI::MailTo::EMAIL_REGEXP backs the :email format preset
25
33
 
26
34
  require "permittable/version"
27
35
  require "permittable/error_envelope"
@@ -74,6 +82,14 @@ require "permittable/filter_parameter_registry"
74
82
  # (db:create, assets:precompile) the check skips. In CI, one
75
83
  # `Rails.application.eager_load!` spec exercises every contract in the app.
76
84
  #
85
+ # REUSING FIELDS — `Permittable.fields { ... }` builds a FieldGroup, a frozen
86
+ # reusable field list, and the builder's `use` verb splices one in wherever
87
+ # fields are declared (a contract, a nested block, another group). `use G,
88
+ # optional: true` relaxes every spliced field, which is how an update contract
89
+ # reuses a create contract; `only:`/`except:` select a subset. Because a group
90
+ # is built by the same builder, its declarations are validated once, at the
91
+ # group. See FieldGroup.
92
+ #
77
93
  # Validation is LAZY: it runs on the first `permitted_params` call, so an
78
94
  # action that never reads params never pays. `enforce: true` installs the
79
95
  # check as a before_action instead (reject before the action body runs).
@@ -114,9 +130,14 @@ require "permittable/filter_parameter_registry"
114
130
  # absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
115
131
  # there is exactly one reading of absence and a value that normalizes to empty
116
132
  # (" " under :squish) cannot satisfy a required field by becoming "". An
117
- # authored `default:`/`example:` is stored normalized — the form it was
118
- # validated in — and deep-frozen on a copy, so no request can corrupt it for
119
- # the next.
133
+ # authored `default:`/`example:` is stored as the contract reads it —
134
+ # normalized and cast, so `default: "18"` on an :integer is 18, and an
135
+ # array's read by the request walker itself. The one exception is a field
136
+ # declaring `transform:`: its default is stored exactly AS AUTHORED, never
137
+ # cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
138
+ # so author such a default already in the shape the action should receive.
139
+ # Either way it is deep-frozen on a copy, and each request gets its own deep
140
+ # copy, so no request can corrupt it for the next.
120
141
  #
121
142
  # `nullable: true` splits that rule in two for one field, which is how a PATCH
122
143
  # clears a column: a key the client never sent stays absent (defaults apply,
@@ -166,7 +187,14 @@ require "permittable/filter_parameter_registry"
166
187
  # and validation to reshape that field's output, e.g.
167
188
  # `transform: ->(v) { v.split(",") }` turns a validated delimited String
168
189
  # into an Array. Runs only on request-supplied values: absent fields stay
169
- # absent and `default:` values are authored in final shape.
190
+ # absent, and a `default:` is handed out exactly AS AUTHORED — validated
191
+ # against the field's own contract at class load (as any default is), but
192
+ # neither cast nor transformed — so a request sending a field's default
193
+ # gets the transformed value, a request omitting it the untransformed one.
194
+ # Author a default already in the shape the action should receive:
195
+ # `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
196
+ # paths the Integer 25. A field with no `transform:` still gets its
197
+ # default cast (see the module-level default:/example: paragraph above).
170
198
  # * `finalize do |p| ... end` (once per contract) — runs after every field
171
199
  # validated cleanly, receives the result hash, and must return the
172
200
  # (possibly restructured) Hash: combine parallel fields, build value
@@ -189,6 +217,7 @@ module Permittable
189
217
  JSON_TYPE = :json
190
218
  UNKNOWN_MODES = %i[ignore log error].freeze
191
219
  MODES = %i[enforce monitor].freeze
220
+ ERROR_FORMATS = %i[envelope problem].freeze
192
221
  # Rails merges routing bookkeeping into params; a top-level (root: false)
193
222
  # unknown-keys check must not flag them.
194
223
  ROUTING_KEYS = %w[controller action format].freeze
@@ -207,6 +236,10 @@ module Permittable
207
236
  # rather than leaving a bare ROUTING_KEYS to read like an oversight.
208
237
  UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
209
238
  MONITOR_DROPPED_KEYS = ROUTING_KEYS
239
+ # The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
240
+ # rootless contract declaring the wrapper key as one of these reads the
241
+ # copy deliberately, so it is kept (see permittable_without_wrapper_copy).
242
+ WRAPPER_CONTAINER_KINDS = [:nested, JSON_TYPE].freeze
210
243
  # A log line and an exception message are PROSE, written for a person. They
211
244
  # list at most this many names and count the rest, so one request cannot
212
245
  # write a megabyte of them. The machine-readable channels — a violation's
@@ -216,6 +249,49 @@ module Permittable
216
249
  # ...and each name it does list is truncated. Capping the COUNT alone still
217
250
  # let ONE 1 MB key name write the 1 MB log line the cap exists to prevent.
218
251
  PROSE_ITEM_LIMIT = 120
252
+ # ...and a name that could break the sentence out of its line is escaped.
253
+ # The names are client-sent, and bounding their length escaped nothing: a
254
+ # key of "x\nE, [...] ERROR -- : ..." wrote a second, forged log entry.
255
+ # The set is, by Unicode property:
256
+ # - every control character (Cc: C0, DEL and C1 — C1 because U+0085 is
257
+ # NEL, a line break to many readers, and U+009B is the 8-bit CSI that
258
+ # starts a terminal escape);
259
+ # - U+2028/U+2029 (Zl, Zp), the separators a JSON-lines or JavaScript
260
+ # reader splits a line on;
261
+ # - every format character (Cf): the bidi embeddings, overrides and
262
+ # isolates, which can visually reorder a line and so move text across
263
+ # the closing quote of an escaped name, and the zero-width and marker
264
+ # characters (U+200B, U+200E/U+200F, U+061C, U+FEFF, ...), which make
265
+ # two different names print identically;
266
+ # - every space but U+0020 (Zs), so a no-break or ideographic space
267
+ # cannot make "x,<NBSP>y" pass for the ", " between two names.
268
+ PROSE_UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}&&[^ ]]/
269
+ # A name is also quoted when it merely LOOKS like prose structure. These
270
+ # characters print as they are — only the quoting marks them:
271
+ # - Unicode's own Quotation_Mark property, rather than a hand-picked list:
272
+ # it already covers the plain and fullwidth `"`, every curly quote and
273
+ # guillemet (Pi/Pf), AND the CJK corner brackets U+300C/U+300D, which
274
+ # are real quotation marks in Japanese and Chinese text but are punctuation
275
+ # category Ps/Pe, not Pi/Pf, so a Pi/Pf-only check missed them;
276
+ # - the list separator, or a fullwidth, small, ideographic or small-form-
277
+ # ideographic comma (the last, U+FE51, is U+3001's small-form sibling,
278
+ # the way U+FE50 is the plain comma's), could pass for the ", " between
279
+ # two names;
280
+ # - a name that begins "and N more" (matched case-insensitively — "And"/
281
+ # "AND" reads identically once rendered) could pass for the overflow
282
+ # count. This still only catches the literal word: a homoglyph
283
+ # substitution such as Cyrillic "а" for Latin "a" is not detected, and
284
+ # no Unicode confusable-detection is attempted here — see CHANGELOG.
285
+ PROSE_AMBIGUOUS = /\p{Quotation_Mark}|[\u{FF0C}\u{FE50}\u{3001}\u{FE51}]|, |\A(?i:and \p{Nd}+ more)/
286
+ # \n, \r and \t, which a person recognises, get their short escape; any
287
+ # other unsafe character is \uXXXX, which JSON, JavaScript and Ruby all
288
+ # read the same way. The quote and backslash are escaped too, but only
289
+ # inside an escaped (quoted) name, where they would otherwise be ambiguous.
290
+ PROSE_ESCAPES = { "\n" => '\n', "\r" => '\r', "\t" => '\t', '"' => '\"', "\\" => '\\\\' }.freeze
291
+ # How much of a name the prose ever reads. Every character the rendering
292
+ # could show lies inside it even when each one is a 4-byte sequence in a
293
+ # binary key, so a 1 MB name is converted and scanned no further than this.
294
+ PROSE_SCAN_LIMIT = PROSE_ITEM_LIMIT * 4
219
295
 
220
296
  # The single proc Permittable::Railtie appends to config.filter_parameters.
221
297
  # Declared with an optional third parameter so its own arity is -3 and Rails
@@ -227,6 +303,28 @@ module Permittable
227
303
  inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
228
304
  end.freeze
229
305
 
306
+ # Named `format:` presets — the regexps every app writes by hand, defined
307
+ # once. A preset carries something a hand-written Regexp cannot: the JSON
308
+ # Schema `format` keyword the ecosystem understands, so an exported schema
309
+ # says `"format": "uuid"` and not only a wall of pattern.
310
+ #
311
+ # :email is deliberately URI::MailTo::EMAIL_REGEXP itself, the regexp Rails
312
+ # apps already paste into their contracts, so adopting the preset cannot
313
+ # change which addresses an endpoint accepts. The rest avoid flags and
314
+ # Ruby-only constructs (no \h, no /i) so they translate to ECMA-262 and
315
+ # export as a real `pattern` rather than an x-permittable-pattern
316
+ # extension. :url and :hostname are shape checks, not reachability
317
+ # guarantees.
318
+ FORMATS = {
319
+ email: { pattern: URI::MailTo::EMAIL_REGEXP, json: "email" }.freeze,
320
+ uuid: { pattern: /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
321
+ json: "uuid" }.freeze,
322
+ url: { pattern: %r{\Ahttps?://[^\s/?\#]+[^\s]*\z}, json: "uri" }.freeze,
323
+ slug: { pattern: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }.freeze,
324
+ hostname: { pattern: /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/,
325
+ json: "hostname" }.freeze
326
+ }.freeze
327
+
230
328
  NORMALIZERS = {
231
329
  squish: ->(v) { v.squish },
232
330
  strip: ->(v) { v.strip },
@@ -326,6 +424,19 @@ module Permittable
326
424
  @sensitive_parameter_sinks ||= []
327
425
  end
328
426
 
427
+ # A reusable field list, shareable by any number of contracts — see
428
+ # FieldGroup for the whole story.
429
+ #
430
+ # AddressFields = Permittable.fields do
431
+ # required :city, :string
432
+ # optional :zip, :string, format: /\A\d{5}\z/
433
+ # end
434
+ #
435
+ # Splice it into a contract (or another group) with `use`.
436
+ def fields(&)
437
+ FieldGroup.new(&)
438
+ end
439
+
329
440
  # App-wide default for rules that don't declare their own mode:.
330
441
  # :enforce (the default) rejects violating requests; :monitor reports
331
442
  # them — same instrumentation event with payload mode: :monitor, plus a
@@ -346,6 +457,54 @@ module Permittable
346
457
  @mode = value
347
458
  end
348
459
 
460
+ # The shape of a rejection: :envelope (the default — the host's
461
+ # #render_error, or the gem's inline JSON) or :problem, which renders RFC
462
+ # 9457 Problem Details as application/problem+json. App-wide, because the
463
+ # error format of an API is a property of the API rather than of any one
464
+ # contract, and set from an initializer:
465
+ #
466
+ # Permittable.error_format = :problem
467
+ #
468
+ # See ErrorEnvelope, including why :problem opts out of #render_error.
469
+ def error_format
470
+ @error_format || :envelope
471
+ end
472
+
473
+ def error_format=(value)
474
+ value = value.to_sym
475
+ raise ArgumentError, "#{LABEL}: error_format must be one of #{ERROR_FORMATS.join(', ')}" unless ERROR_FORMATS.include?(value)
476
+
477
+ @error_format = value
478
+ end
479
+
480
+ # Base URI for problem `type` members. Unset (the default) leaves the type
481
+ # as RFC 9457's "about:blank"; set it to where the app documents its
482
+ # problem types and each type gets its own URI under it.
483
+ attr_accessor :problem_base_uri
484
+
485
+ # Whether the schema-drift guard also checks that a field's declared type
486
+ # matches its column's, on top of checking the column exists.
487
+ #
488
+ # OFF by default, deliberately. Every cross-type declaration has some
489
+ # legitimate use — a :string contract on a date column that lets
490
+ # ActiveRecord do the casting, a :boolean contract on a legacy integer
491
+ # column — and breaking those apps on an upgrade would cost more than the
492
+ # drift it catches. Turn it on and fix what it finds:
493
+ #
494
+ # Permittable.check_column_types = true
495
+ #
496
+ # It compares type GROUPS rather than exact types, and never fires on a
497
+ # column it has no faithful contract type for. See ColumnGuard.
498
+ def check_column_types
499
+ @check_column_types || false
500
+ end
501
+
502
+ def check_column_types=(value)
503
+ raise ArgumentError, "#{LABEL}: check_column_types must be true or false" unless [true, false].include?(value)
504
+
505
+ @check_column_types = value
506
+ end
507
+
349
508
  # App-wide fallback copy for a violation code, looked up through I18n
350
509
  # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
351
510
  # a validate: returned). Consulted only when the field declares no
@@ -418,22 +577,44 @@ module Permittable
418
577
  def check_scalar_rules(field, value)
419
578
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
420
579
  return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
421
- return [:error, "format"] if field[:format] && !field[:format].match?(value)
580
+ return [:error, "format"] if field[:format] && !format_match?(field[:format], value)
422
581
 
423
582
  check_custom(field[:validate], value)
424
583
  end
425
584
 
585
+ # A Regexp RAISES rather than answers when a String's encoding cannot meet
586
+ # it — a UTF-8 pattern with non-ASCII characters against UTF-16,
587
+ # Shift_JIS or binary bytes (Encoding::CompatibilityError). A value the
588
+ # pattern cannot even be applied to has not been shown to match, so it is
589
+ # a `format` violation, the answer the client can act on. An app that
590
+ # takes other encodings on purpose (`skip_parameter_encoding`) sees what
591
+ # it saw before this rule, except that the crash is now a 422.
592
+ def format_match?(pattern, value)
593
+ pattern.match?(value)
594
+ rescue EncodingError, ArgumentError
595
+ false
596
+ end
597
+
426
598
  # Free-form hash. The shape is deliberately undeclared, so the only
427
599
  # checks are the bounds the field asked for: breadth (`length:`, the
428
600
  # top-level key count, same reading as an array's element count) and
429
601
  # nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
430
602
  # checking, like check_scalar.
431
603
  def check_json(field, value)
604
+ status, code = check_json_bounds(field, value)
605
+ return [status, code] unless status == :ok
606
+
607
+ check_custom(field[:validate], value)
608
+ end
609
+
610
+ # The structural half of check_json, which the request walker runs on
611
+ # its own so that it can copy an accepted hash before app code sees it.
612
+ def check_json_bounds(field, value)
432
613
  return [:error, "invalid_type"] unless value.is_a?(Hash)
433
614
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
434
615
  return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
435
616
 
436
- check_custom(field[:validate], value)
617
+ [:ok, value]
437
618
  end
438
619
 
439
620
  # Container nesting, with the field's own hash as level 1. An Array counts
@@ -463,8 +644,76 @@ module Permittable
463
644
 
464
645
  def cast(type, value)
465
646
  return [:error, "invalid_type"] unless scalar_shaped?(value)
647
+ return public_send("cast_#{type}", value) unless value.is_a?(String)
648
+ # Bytes that are not valid in the String's OWN encoding are not text in
649
+ # any encoding: every String operation after the cast raises on them
650
+ # (`format:`, the normalize presets, an app's `validate:`). Rails'
651
+ # params builder guards a controller; a standalone Contract#call on a
652
+ # webhook payload has nothing in front of it, so `"caf\xC3"` was a 500.
653
+ return [:error, "invalid_type"] unless value.valid_encoding?
654
+ # A :string is handed back exactly as it arrived, in its own encoding.
655
+ # `skip_parameter_encoding` / `param_encoding` send a controller binary
656
+ # or Shift_JIS text ON PURPOSE, and converting it would hand the app
657
+ # something other than what it asked Rails for.
658
+ return cast_string(value) if type == :string
659
+
660
+ # A UTF-8 String IS already its own inspection copy — the check just
661
+ # above already scanned it — so utf8_text would only scan the same
662
+ # object a second time for no new answer. Every other encoding still
663
+ # goes through it: a US-ASCII or binary String is a NEW object once
664
+ # force_encoding'd, and one only `encode` could produce is not yet
665
+ # known to be valid UTF-8 at all.
666
+ text = value.encoding == Encoding::UTF_8 ? value : utf8_text(value)
667
+ text ? public_send("cast_#{type}", text) : [:error, "invalid_type"]
668
+ end
669
+
670
+ # Encodings whose bytes are READ as UTF-8 rather than converted: UTF-8
671
+ # itself, US-ASCII (a subset of it), and binary — which names no
672
+ # encoding at all, and is how a raw socket read or an unlabelled file
673
+ # hands a payload over.
674
+ UTF8_READABLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
675
+
676
+ # A UTF-8 copy of a String, for INSPECTION only — the text a number, a
677
+ # boolean or a date is parsed from — or nil when there is no UTF-8
678
+ # reading of it. The value handed back to the app is never this copy.
679
+ #
680
+ # Parsing the String itself went wrong for any encoding but UTF-8:
681
+ # `Integer()` on UTF-16 "12" raised Encoding::CompatibilityError, and
682
+ # `BigDecimal` read the same String byte by byte and returned 1 — a wrong
683
+ # answer where the other at least crashed.
684
+ #
685
+ # Binary and US-ASCII are read as UTF-8 (on a copy — the caller's String
686
+ # keeps its encoding), anything else is converted with `encode`, and a
687
+ # result that is not valid UTF-8, or a conversion that raises, is nil.
688
+ def utf8_text(value)
689
+ text = if value.encoding == Encoding::UTF_8 then value
690
+ elsif UTF8_READABLE.include?(value.encoding) then value.dup.force_encoding(Encoding::UTF_8)
691
+ else value.encode(Encoding::UTF_8)
692
+ end
693
+ text.valid_encoding? ? text : nil
694
+ rescue EncodingError
695
+ nil
696
+ end
466
697
 
467
- public_send("cast_#{type}", value)
698
+ # utf8_text for text the gem must REPORT rather than judge — a client's
699
+ # undeclared key, written into a violation's `param`, the exception
700
+ # message and the log line. Refusing is not an option there, so what
701
+ # cannot be read is replaced with U+FFFD. Otherwise the undeclared key
702
+ # "caf\xC3" was copied raw into the 422 and rendering it raised
703
+ # JSON::GeneratorError, and a UTF-16 key raised
704
+ # Encoding::CompatibilityError while its path was being interpolated.
705
+ # Only undeclared keys come here (see permittable_unknown_key_violation):
706
+ # a declared key is the contract's own UTF-8 name.
707
+ def reportable_text(value)
708
+ utf8_text(value) || scrubbed_text(value)
709
+ end
710
+
711
+ def scrubbed_text(value)
712
+ return value.dup.force_encoding(Encoding::UTF_8).scrub if UTF8_READABLE.include?(value.encoding)
713
+
714
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
715
+ rescue EncodingError
716
+ value.dup.force_encoding(Encoding::UTF_8).scrub
468
717
  end
469
718
 
470
719
  # Arrays, hashes, and nested ActionController::Parameters
@@ -476,9 +725,20 @@ module Permittable
476
725
  true
477
726
  end
478
727
 
728
+ # A String is returned as given. The request walker has already copied
729
+ # it on the way in (Permittable#permittable_normalized), before
730
+ # `normalize:` could see it — copying here as well would allocate twice
731
+ # per value and still come too late for a mutating `normalize:` proc.
479
732
  def cast_string(value)
480
733
  case value
481
734
  when String then [:ok, value]
735
+ # BigDecimal#to_s defaults to engineering notation ("0.15e1" for 1.5) —
736
+ # stdlib's own rendering, only ever masked in a host that has loaded
737
+ # Rails' active_support/core_ext/big_decimal/conversions, which patches
738
+ # the default format to "F". A :decimal default:/example: cast through
739
+ # here (a plain :string field, not :decimal itself) must render the same
740
+ # way regardless of whether that patch happens to be loaded.
741
+ when BigDecimal then [:ok, value.to_s("F")]
482
742
  when Numeric, true, false then [:ok, value.to_s]
483
743
  else [:error, "invalid_type"]
484
744
  end
@@ -487,7 +747,10 @@ module Permittable
487
747
  def cast_integer(value)
488
748
  case value
489
749
  when Integer then [:ok, value]
490
- when Float then value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
750
+ # NaN and Infinity first: `truncate` raises FloatDomainError on them (a
751
+ # RangeError, which the ArgumentError rescue below does not catch), and
752
+ # no integer is what either one sent. Same rule as finite_float.
753
+ when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
491
754
  when String then [:ok, Integer(value, 10)]
492
755
  else [:error, "invalid_type"]
493
756
  end
@@ -614,10 +877,37 @@ module Permittable
614
877
 
615
878
  # Presets only make sense on String input; a non-String value (JSON
616
879
  # numbers, booleans) skips normalization and goes straight to the cast.
880
+ #
881
+ # A String that is not valid in its own encoding is left as it came, for
882
+ # the cast to refuse: every preset raises on invalid bytes, and an app's
883
+ # own proc would be handed input it never agreed to see. It is not empty,
884
+ # so the absence rule in between cannot mistake it for a missing value.
885
+ #
886
+ # A VALID String in another encoding is normalized in that encoding —
887
+ # `:strip` works on binary and Shift_JIS alike — and when a BUILT-IN
888
+ # PRESET cannot handle the encoding (`:squish` on UTF-16 raises
889
+ # Encoding::CompatibilityError) the value is left as it is rather than
890
+ # raising.
891
+ #
892
+ # That leniency is only for the gem's own presets, identified by object
893
+ # identity against NORMALIZERS' values (resolve_normalizer! replaces
894
+ # field[:normalize] with the exact Proc from that Hash, so a preset and
895
+ # an app-supplied Proc are never the same object). An app's own Proc
896
+ # raising is never swallowed, on ANY encoding: `normalize: ->(v) { raise
897
+ # ArgumentError, "..." if ... }` is a business rule, not an encoding
898
+ # failure, and treating its raise as "this encoding defeated the
899
+ # normalizer" would have let exactly the input a UTF-8 request could not
900
+ # bypass the very check it names.
617
901
  def apply_normalize(normalizer, value)
618
902
  return value unless normalizer && value.is_a?(String)
903
+ return value unless value.valid_encoding?
904
+ return normalizer.call(value) unless NORMALIZERS.value?(normalizer)
619
905
 
620
- normalizer.call(value)
906
+ begin
907
+ normalizer.call(value)
908
+ rescue EncodingError, ArgumentError
909
+ value
910
+ end
621
911
  end
622
912
 
623
913
  # nil and "" are both ABSENT — see the module comment. The VALUE half of
@@ -628,6 +918,109 @@ module Permittable
628
918
  value.nil? || (value.is_a?(String) && value.empty?)
629
919
  end
630
920
 
921
+ # An `in:` list as the runtime holds it: every member cast by the field's
922
+ # own type, because included_in? compares the CAST request value against
923
+ # it. Comparing against the members as authored meant `in: %i[draft
924
+ # published]` on a :string field (and `in: %w[1 2 3]` on an :integer one)
925
+ # held values no cast could ever produce, and rejected every request.
926
+ #
927
+ # `normalize:` is deliberately not applied — it rewrites what a client
928
+ # sent, not what the contract author wrote. Duplicates the cast collapses
929
+ # ("1" and 1 on an :integer) are dropped, and a Set stays a Set, so an
930
+ # author who chose one for its O(1) include? keeps it. A nil member is
931
+ # dropped on a nullable field, where an explicit null is accepted before
932
+ # in: is ever consulted; anywhere else it is a member no value can equal,
933
+ # and is an error like any other.
934
+ #
935
+ # `members` is what in_list returned. Returns [:ok, cast, published] —
936
+ # `published` being what an exported enum lists, see published_in_member
937
+ # — or [:error, offending_member, code]. Shared by ContractBuilder and the
938
+ # RSpec matcher's `within` chain so the two cannot read a list differently.
939
+ def cast_in_members(type, members, nullable: false)
940
+ pairs = []
941
+ members.each do |member|
942
+ next if member.nil? && nullable
943
+
944
+ status, value = cast_in_member(type, member)
945
+ return [:error, member, value] unless status == :ok
946
+
947
+ pairs << [value, published_in_member(type, member, value)]
948
+ end
949
+ pairs = pairs.uniq(&:first)
950
+ cast_members = pairs.map(&:first)
951
+ [:ok, members.is_a?(Set) ? cast_members.to_set : cast_members, pairs.map(&:last)]
952
+ end
953
+
954
+ # The members of an `in:` that is a LIST, or nil when it is not one.
955
+ # Only Array, Set, Hash and Enumerator count, and only when the object's
956
+ # OWN class provides the collection's ordinary include? — not a Hash,
957
+ # Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
958
+ # fuzzy Set, a registry matching some other way entirely). `case allowed;
959
+ # when Hash ...` matches with ===, which for a Class is is_a?, so a
960
+ # subclass would otherwise match its ancestor's branch and have its
961
+ # override silently discarded — read for its raw keys/elements instead,
962
+ # which can invert which values it actually accepts. It is left opaque
963
+ # instead, exactly like any other object whose include? is the point
964
+ # (see resolve_in!) and enumerating it may be expensive (a DB-backed
965
+ # registry).
966
+ #
967
+ # A Hash lists its KEYS, which is what Hash#include? asks about — the
968
+ # Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
969
+ # Set, so membership stays O(1) per request.
970
+ # ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
971
+ # accepted anyway: its include? override only canonicalises the argument
972
+ # (String/Symbol) before the SAME key lookup, so its keys are still
973
+ # exactly its members — and it is what a Rails enum's own reader
974
+ # (`Post.statuses`) actually returns.
975
+ # Enumerator::Lazy is the same story on the Enumerator side: Lazy
976
+ # overrides chain methods like map and select, but not include?, so it
977
+ # is still read as a list — and forced to an Array here, once, since
978
+ # left lazy it would be cast on every request instead of at class load.
979
+ def in_list(allowed)
980
+ case allowed
981
+ when Hash then allowed.keys.to_set if plain_hash?(allowed)
982
+ when Set then allowed if allowed.instance_of?(Set)
983
+ when Array then allowed.to_a if allowed.instance_of?(Array)
984
+ when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
985
+ end
986
+ end
987
+
988
+ def plain_hash?(allowed)
989
+ allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
990
+ end
991
+
992
+ # A Symbol is read as its String: it is how Ruby spells a constant
993
+ # string, and a request never carries one, so no cast accepts it as is.
994
+ def cast_in_member(type, member)
995
+ member = member.to_s if member.is_a?(Symbol)
996
+ return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
997
+
998
+ cast(type, member)
999
+ end
1000
+
1001
+ # A Time or DateTime member of a :date field. ActiveSupport compares one
1002
+ # with a Date as INSTANTS, the Date standing for its midnight UTC, so
1003
+ # that instant is the only one that ever equalled a request's date. It
1004
+ # is read as that UTC date; any other instant never matched anything,
1005
+ # and is refused like any member no request could equal. (cast_date
1006
+ # would keep a DateTime whole — it IS a Date — and refuse a Time.)
1007
+ def instant_as_date(member)
1008
+ utc = member.to_time.getutc
1009
+ return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
1010
+
1011
+ [:ok, utc.to_date]
1012
+ end
1013
+
1014
+ # What an exported enum lists for one member: the cast value, re-encoded
1015
+ # as JSON — except a :date/:datetime member authored as a String, which
1016
+ # is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
1017
+ # so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
1018
+ # the server refuses. The authored String went through the very cast a
1019
+ # request does, so the server accepts it by construction.
1020
+ def published_in_member(type, member, value)
1021
+ member.is_a?(String) && %i[date datetime].include?(type) ? member : value
1022
+ end
1023
+
631
1024
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
632
1025
  # and the right semantics for validation.
633
1026
  def included_in?(allowed, value)
@@ -651,6 +1044,13 @@ module Permittable
651
1044
  ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
652
1045
  nullable].freeze
653
1046
 
1047
+ # One value of each scalar type as a cast produces it, for asking whether
1048
+ # an `in:` Range's endpoints can be compared with that type at all.
1049
+ RANGE_PROBES = {
1050
+ string: "", integer: 0, float: 0.0, decimal: BigDecimal("0"), boolean: true,
1051
+ date: Date.new(2000, 1, 1), datetime: Time.utc(2000)
1052
+ }.freeze
1053
+
654
1054
  attr_reader :finalizer
655
1055
 
656
1056
  def initialize
@@ -691,6 +1091,10 @@ module Permittable
691
1091
  required = opts.delete(:required) ? true : false
692
1092
 
693
1093
  field = { name: name, kind: :array, required: required, **opts }
1094
+ if field[:required] && field.key?(:default)
1095
+ raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
1096
+ end
1097
+
694
1098
  if block
695
1099
  raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
696
1100
 
@@ -708,8 +1112,69 @@ module Permittable
708
1112
  @fields << field
709
1113
  end
710
1114
 
1115
+ # Splice a reusable field group in at this point — the same fields, in the
1116
+ # same order, as if they had been typed here. Works at the top level of a
1117
+ # contract, inside a nested or array block, and inside another group.
1118
+ #
1119
+ # permit_params :create, root: :user do
1120
+ # required :name, :string
1121
+ # optional :address do
1122
+ # use AddressFields
1123
+ # end
1124
+ # end
1125
+ #
1126
+ # `optional: true` relaxes every spliced field, which is how an update
1127
+ # contract reuses a create contract: nothing is mandatory, but `default:`,
1128
+ # types and bounds all still apply. It relaxes the TOP LEVEL only — if a
1129
+ # client sends an address at all, the address's own required sub-fields
1130
+ # still hold.
1131
+ #
1132
+ # `only:`/`except:` select a subset, in the group's own order. Naming a
1133
+ # field the group doesn't declare is a class-load error, so a typo cannot
1134
+ # silently drop a field. A field declared twice still raises, so
1135
+ # overriding one field of a group is deliberate: `use G, except: [:city]`
1136
+ # and then declare `:city` yourself.
1137
+ def use(group, only: nil, except: nil, optional: false)
1138
+ fields = select_group_fields!(group_fields!(group), only: only, except: except)
1139
+ fields = fields.map { |field| field.merge(required: false).freeze } if optional
1140
+ fields.each do |field|
1141
+ field_name!(field[:name])
1142
+ @fields << field
1143
+ end
1144
+ nil
1145
+ end
1146
+
711
1147
  private
712
1148
 
1149
+ def group_fields!(group)
1150
+ return group.fields if group.respond_to?(:fields)
1151
+
1152
+ raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
1153
+ "answering #fields, such as a Permittable::Contract — got #{group.class}"
1154
+ end
1155
+
1156
+ def select_group_fields!(fields, only:, except:)
1157
+ raise ArgumentError, "#{LABEL}: use takes only: OR except:, not both" if only && except
1158
+
1159
+ if only || except
1160
+ wanted = assert_group_names!(fields, only || except, only ? "only" : "except")
1161
+ fields = only ? fields.select { |f| wanted.include?(f[:name]) } : fields.reject { |f| wanted.include?(f[:name]) }
1162
+ end
1163
+ return fields unless fields.empty?
1164
+
1165
+ raise ArgumentError, "#{LABEL}: use selects no fields from the group"
1166
+ end
1167
+
1168
+ def assert_group_names!(fields, names, label)
1169
+ wanted = Array(names).map(&:to_sym)
1170
+ declared = fields.map { |f| f[:name] }
1171
+ missing = wanted - declared
1172
+ return wanted if missing.empty?
1173
+
1174
+ raise ArgumentError, "#{LABEL}: use #{label}: names #{missing.map(&:inspect).join(', ')}, which the group " \
1175
+ "does not declare (it declares: #{declared.map(&:inspect).join(', ')})"
1176
+ end
1177
+
713
1178
  def add_field(name, type, required:, opts:, &block)
714
1179
  name = field_name!(name)
715
1180
  if block
@@ -813,25 +1278,109 @@ module Permittable
813
1278
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
814
1279
  end
815
1280
 
816
- if field.key?(:in)
817
- unless field[:in].respond_to?(:include?)
818
- raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
819
- end
820
-
821
- assert_satisfiable!(name, :in, field[:in])
822
- end
823
-
1281
+ resolve_in!(field) if field.key?(:in)
824
1282
  validate_string_only_opts!(field)
825
1283
  validate_length!(name, field[:length]) if field.key?(:length)
826
1284
  validate_required_length!(field)
827
1285
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
828
1286
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
1287
+ resolve_format!(field)
829
1288
  resolve_normalizer!(field)
830
1289
  validate_authored_value!(field, :default)
831
1290
  validate_authored_value!(field, :example)
832
1291
  validate_message!(field)
833
1292
  end
834
1293
 
1294
+ # `in:` is a Range (bounds-checked with cover?), a list of values, or an
1295
+ # object of the host's own that answers include? — kept exactly as given,
1296
+ # since nothing here can know what it accepts. It used to be anything
1297
+ # answering include?, which let a String through, and String#include? is
1298
+ # a SUBSTRING test: `in: "free pro"` accepted "e", "fr" and "ee p". A
1299
+ # String is refused here, along with anything answering neither.
1300
+ #
1301
+ # A list (see Coercion.in_list — a Hash lists its keys) is stored cast by
1302
+ # the field's type (see Coercion.cast_in_members), so request-time
1303
+ # matching, the exported enum, the RSpec matcher and the column guard's
1304
+ # enum rule all read the members the runtime compares against. A member
1305
+ # no request value could ever equal is a contract mistake, and fails here
1306
+ # rather than as an `inclusion` on every request.
1307
+ def resolve_in!(field)
1308
+ name = field[:name]
1309
+ allowed = field[:in]
1310
+ if allowed.is_a?(Range)
1311
+ assert_comparable_range!(field, allowed)
1312
+ elsif (members = Coercion.in_list(allowed))
1313
+ cast_in_members!(field, members)
1314
+ elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
1315
+ raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
1316
+ "or a Hash read as its keys), or an object answering include? " \
1317
+ "(got #{allowed.inspect})#{string_in_hint(allowed)}"
1318
+ end
1319
+ assert_satisfiable!(name, :in, field[:in])
1320
+ end
1321
+
1322
+ def string_in_hint(allowed)
1323
+ return "" unless allowed.is_a?(String)
1324
+
1325
+ " — String#include? would accept any substring; list the values instead, e.g. in: %w[#{allowed}]"
1326
+ end
1327
+
1328
+ # `published` is stored only where it differs from the cast members (a
1329
+ # String-authored :date/:datetime member), so it is read as an override.
1330
+ def cast_in_members!(field, members)
1331
+ status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
1332
+ unless status == :ok
1333
+ # cast is the offending member here, and published its error code.
1334
+ # nil is the one member written on purpose, meaning "null is allowed"
1335
+ # — but an absent value never reaches in:, so the fix is worth naming.
1336
+ hint = cast.nil? ? " — an absent value never reaches in:; declare nullable: true to accept an explicit null" : ""
1337
+ raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} contains #{cast.inspect}, " \
1338
+ "which is not a valid :#{field[:type]} (#{published})#{hint}"
1339
+ end
1340
+
1341
+ field[:in] = freeze_in_members(cast)
1342
+ field[:in_published] = freeze_authored(published) unless published == cast.to_a
1343
+ end
1344
+
1345
+ def freeze_in_members(members)
1346
+ members.is_a?(Set) ? members.to_set { |member| freeze_authored(member) }.freeze : freeze_authored(members)
1347
+ end
1348
+
1349
+ # A Range is kept exactly as written, unlike a list: casting its
1350
+ # endpoints would change what it means. `0..Float::INFINITY` on a :float
1351
+ # and `1.5..3` on an :integer are real bounds whose endpoints no cast
1352
+ # accepts, and a :decimal's `0..100` would become BigDecimal endpoints
1353
+ # that export as the STRING "0.0" where `minimum` needs a number.
1354
+ #
1355
+ # What does fail every request is an endpoint the cast value cannot be
1356
+ # compared with — `"1".."5"` on an :integer, `1..5` on a :string,
1357
+ # `.."9.99"` on a :decimal. cover? then answers false for every value, so
1358
+ # that is caught here. The probe asks exactly what cover? will — begin
1359
+ # <=> value, then value <=> end — so whatever the host's own <=> allows
1360
+ # (ActiveSupport lets a Date range bound a :datetime) is allowed here too.
1361
+ def assert_comparable_range!(field, range)
1362
+ probe = RANGE_PROBES.fetch(field[:type])
1363
+ # A NaN endpoint compares to nothing, by design, whatever it stands
1364
+ # beside — not evidence of a wrong-TYPED bound (a String range on an
1365
+ # :integer), which is what this check exists to catch. It is left
1366
+ # alone here exactly as an infinite endpoint already is (INFINITY
1367
+ # compares fine); the exporter separately omits it, since it is
1368
+ # never `finite?`.
1369
+ # Wrapped in an Array so a `false` endpoint still reads as found.
1370
+ stray = if !range.begin.nil? && !nan?(range.begin) && (range.begin <=> probe).nil? then [range.begin]
1371
+ elsif !range.end.nil? && !nan?(range.end) && (probe <=> range.end).nil? then [range.end]
1372
+ end
1373
+ return unless stray
1374
+
1375
+ raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} is a Range of #{stray.first.class} " \
1376
+ "(#{range.inspect}), which a :#{field[:type]} value cannot be compared with — " \
1377
+ "no value could satisfy it; write the bounds as :#{field[:type]} values"
1378
+ end
1379
+
1380
+ def nan?(value)
1381
+ value.respond_to?(:nan?) && value.nan?
1382
+ end
1383
+
835
1384
  def validate_json_opts!(field)
836
1385
  name = field[:name]
837
1386
  if field[:required] && field.key?(:default)
@@ -930,6 +1479,28 @@ module Permittable
930
1479
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
931
1480
  end
932
1481
 
1482
+ # A Symbol (or String) `format:` names a preset; a Regexp is used as
1483
+ # given. Resolving here means request-time matching stays a plain
1484
+ # Regexp#match?, and an authored `default:`/`example:` is checked against
1485
+ # the resolved pattern like any other. The preset NAME is kept on the
1486
+ # field so exporters and the RSpec matcher can speak in presets.
1487
+ def resolve_format!(field)
1488
+ preset = field[:format]
1489
+ return if preset.nil? || preset.is_a?(Regexp)
1490
+
1491
+ unless preset.is_a?(Symbol) || preset.is_a?(String)
1492
+ raise ArgumentError, "#{LABEL}: :format for field :#{field[:name]} must be a Regexp or a preset name " \
1493
+ "(presets: #{FORMATS.keys.join(', ')})"
1494
+ end
1495
+
1496
+ spec = FORMATS.fetch(preset.to_sym) do
1497
+ raise ArgumentError, "#{LABEL}: unknown :format preset :#{preset} for field :#{field[:name]} " \
1498
+ "(presets: #{FORMATS.keys.join(', ')}, or pass a Regexp)"
1499
+ end
1500
+ field[:format_name] = preset.to_sym
1501
+ field[:format] = spec[:pattern]
1502
+ end
1503
+
933
1504
  def resolve_normalizer!(field)
934
1505
  normalizer = field[:normalize]
935
1506
  return if normalizer.nil?
@@ -943,85 +1514,67 @@ module Permittable
943
1514
 
944
1515
  # An authored value (`default:`, or a documentation `example:`) must
945
1516
  # satisfy the field's own contract — catching a lie at class load beats
946
- # shipping it to every request (or publishing it in generated docs).
947
- # The authored value is STORED normalized, because that is the form it was
948
- # validated in: `default: " free "` with `normalize: :squish` was
949
- # checked as "free" and used to be handed to requests as " free ".
1517
+ # shipping it to every request (or publishing it in generated docs) —
1518
+ # which is checked here by normalizing and casting it, same as a request's
1519
+ # value. `default: " free "` with `normalize: :squish` is checked as
1520
+ # "free"; `default: "18"` on an :integer is checked as 18.
1521
+ #
1522
+ # What is STORED from that differs by whether the field has `transform:`.
1523
+ # With none, the cast result is stored — the form a request sending the
1524
+ # same value gets, and what used to be thrown away: `default: "18"` used
1525
+ # to be handed to every request omitting it as the String "18", and
1526
+ # `:boolean, default: "false"` gave the app a truthy String.
1527
+ # With a `transform:`, the value is stored exactly AS AUTHORED instead —
1528
+ # `transform:` never runs on a default (see AuthoredValues), so casting it
1529
+ # here would silently change its type out from under an author who, per
1530
+ # the README, writes such a default in the shape the action should
1531
+ # receive: `default: 25` beside `transform: ->(v) { v.to_i }` on a
1532
+ # :string field means the app gets the Integer 25 either way, whether the
1533
+ # request sent "25" (cast then transformed) or omitted the field
1534
+ # (authored as the already-final Integer).
950
1535
  def validate_authored_value!(field, opt)
951
1536
  return unless field.key?(opt)
952
1537
  return if authored_nil!(field, opt)
953
1538
 
954
- value = Coercion.apply_normalize(field[:normalize], field[opt])
955
- status, code = Coercion.check_scalar(field, value)
956
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
957
-
958
- field[opt] = freeze_authored(value)
959
- end
960
-
1539
+ # Copied first, like a request's String, so a mutating `normalize:`
1540
+ # proc cannot rewrite the host's own literal.
1541
+ authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1542
+ value = Coercion.apply_normalize(field[:normalize], authored)
1543
+ status, result = Coercion.check_scalar(field, value)
1544
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1545
+
1546
+ field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
1547
+ end
1548
+
1549
+ # An array's authored value is validated by the REQUEST walker itself
1550
+ # (see AuthoredValues) exactly as validate_authored_value! validates a
1551
+ # scalar's: elements cast, nested hashes and arrays read at every depth, a
1552
+ # sub-field's own default: filled in, `""` on a nullable sub-field made
1553
+ # the explicit nil a request would get, keys the block does not declare
1554
+ # dropped (as `unknown: :ignore` drops them), and the array's own
1555
+ # `validate:` run over the result. A hand-rolled one-level check used to
1556
+ # cast only the top level of each element, and got every one of those
1557
+ # wrong.
1558
+ #
1559
+ # What is STORED follows the same split as a scalar's: without
1560
+ # `transform:`, the walker's read (exactly what a request sending it
1561
+ # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1562
+ # so a declaration mistake (an element `validate:` refuses, a sub-field
1563
+ # default out of bounds) still fails at class load, but its cast result
1564
+ # is discarded rather than stored. `transform:` itself is deliberately
1565
+ # never run on a default either way — see AuthoredValues.
961
1566
  def validate_array_authored_value!(field, opt)
962
1567
  value = field[opt]
963
1568
  return if authored_nil!(field, opt)
964
1569
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
965
- if field[:length] && !Coercion.length_ok?(field[:length], value.length)
966
- raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract (length)"
967
- end
968
-
969
- validate_array_elements!(field, opt, value) if field[:of]
970
- validate_array_element_hashes!(field, opt, value) if field[:fields]
971
- field[opt] = freeze_authored(value)
972
- end
973
-
974
- # The nested-block counterpart of the of: element check below. Without it
975
- # `field[:of]` was nil for a block array, so its `default:` skipped
976
- # validation entirely and whatever was authored went straight to every
977
- # request that omitted the key. Shallow in the same way the of: check is:
978
- # required sub-fields must be present and scalar ones must satisfy their
979
- # own contract, which is what an authored value gets wrong.
980
- def validate_array_element_hashes!(field, opt, value)
981
- value.each do |element|
982
- unless element.is_a?(Hash)
983
- raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \
984
- "where the block declares a hash"
985
- end
986
-
987
- # Wrapped the way permittable_check_element wraps an element at
988
- # request time, so class load reads keys exactly as a request does.
989
- indifferent = ActiveSupport::HashWithIndifferentAccess.new(element)
990
- field[:fields].each { |sub| validate_array_element_field!(field, opt, indifferent, sub) }
991
- end
992
- end
993
1570
 
994
- def validate_array_element_field!(field, opt, element, sub)
995
- # Normalized before absence is read, and absence read with the runtime's
996
- # own rule: a default: is applied WITHOUT revalidation, so anything this
997
- # check waves through is handed to the app unexamined — and "" here used
998
- # to mean a default could carry the very value a client is refused.
999
- value = Coercion.apply_normalize(sub[:normalize], element[sub[:name]])
1000
- if Coercion.absent_value?(value)
1001
- # nullable: splits that rule exactly as permittable_explicit_null?
1002
- # does — a key present but empty is an explicit null, not an absence.
1003
- return if sub[:nullable] && element.key?(sub[:name])
1004
- return unless sub[:required]
1005
-
1006
- raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \
1007
- "which the block declares as required"
1571
+ read, violations = AuthoredValues.read_array(field, value)
1572
+ unless violations.empty?
1573
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
1574
+ "#{AuthoredValues.summary(violations)}"
1008
1575
  end
1009
- return unless sub[:kind] == :scalar
1010
1576
 
1011
- status, code = Coercion.check_scalar(sub, value)
1012
- return if status == :ok
1013
-
1014
- raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} has :#{sub[:name]} " \
1015
- "violating its own contract (#{code})"
1016
- end
1017
-
1018
- def validate_array_elements!(field, opt, value)
1019
- value.each do |element|
1020
- status, code = Coercion.cast(field[:of], element)
1021
- next if status == :ok
1022
-
1023
- raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
1024
- end
1577
+ field[opt] = freeze_authored(field[:transform] ? value : read)
1025
1578
  end
1026
1579
 
1027
1580
  # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
@@ -1201,9 +1754,16 @@ module Permittable
1201
1754
  return if checked.empty?
1202
1755
 
1203
1756
  types = checked.to_h { |f| [f[:name], f[:type]] }
1757
+ allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
1204
1758
  begin
1205
- ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] }, types: types)
1759
+ ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
1760
+ types: types, check_types: Permittable.check_column_types,
1761
+ allowed: allowed)
1206
1762
  rescue ArgumentError => e
1763
+ # The type error carries its own guidance; only the missing-column one
1764
+ # needs the virtual: hint appended.
1765
+ raise e unless e.message.include?("does not exist in the database")
1766
+
1207
1767
  raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
1208
1768
  end
1209
1769
  end
@@ -1308,6 +1868,39 @@ module Permittable
1308
1868
 
1309
1869
  private
1310
1870
 
1871
+ # ParamsWrapper copies a JSON body under the controller's wrapper key
1872
+ # (`user` for UsersController) — on by default in a Rails app — and a
1873
+ # rootless contract then saw that copy as an unknown top-level key on every
1874
+ # well-formed request. Whether the copy is Rails' own has to be read HERE,
1875
+ # before ParamsWrapper#process_action (next in the chain) runs: once it has
1876
+ # wrapped, `_wrapper_enabled?` answers false, because params now carry the
1877
+ # key. Asking afterwards could not tell Rails' copy from a client that sent
1878
+ # `user` itself, which is exactly the key the check must still flag.
1879
+ # Private, like the method it wraps — a public one would become an action.
1880
+ # A plain duck has no process_action and no ParamsWrapper, so this never
1881
+ # runs there, and the guards keep an actionpack-free host inert.
1882
+ # Assigned on every request, never only when true: a controller instance
1883
+ # dispatched twice would otherwise carry one request's exemption into the
1884
+ # next, where a `user` the client did send would pass as Rails' copy.
1885
+ def process_action(*)
1886
+ @permittable_wrapper_key = permittable_wrapper_copy_key
1887
+ super
1888
+ end
1889
+
1890
+ # The wrapper key, if ParamsWrapper is about to copy the body under it;
1891
+ # nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
1892
+ # string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
1893
+ # — the form the Rails docs use — answers "not sent" even when the client
1894
+ # sent `user` itself, and Rails wraps anyway. Asking the same params for
1895
+ # the String keeps that client's key the client's, whichever spelling the
1896
+ # host chose.
1897
+ def permittable_wrapper_copy_key
1898
+ return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
1899
+
1900
+ key = _wrapper_key.to_s
1901
+ key unless request.parameters.key?(key)
1902
+ end
1903
+
1311
1904
  # The value permitted_params memoizes: the validated params, or the
1312
1905
  # InvalidParameters that rejected them. ArgumentError is deliberately NOT
1313
1906
  # memoized — a contract that does not cover the action is a bug to fix, not
@@ -1326,8 +1919,9 @@ module Permittable
1326
1919
  source = permittable_root_hash(rule, violations)
1327
1920
  result = ActiveSupport::HashWithIndifferentAccess.new
1328
1921
  if source
1329
- result = permittable_check_hash(rule[:fields], source, path: rule[:root] ? rule[:root].to_s : nil,
1330
- unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1922
+ checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
1923
+ result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
1924
+ unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1331
1925
  end
1332
1926
  # finalize only sees a hash every field vouched for — never garbage.
1333
1927
  result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
@@ -1359,6 +1953,10 @@ module Permittable
1359
1953
  end
1360
1954
  return ActiveSupport::HashWithIndifferentAccess.new unless source
1361
1955
 
1956
+ # Deliberately NOT deep-copied, unlike the enforce path's result: this is
1957
+ # the pre-contract app's own params, and `params.permit` hands its Strings
1958
+ # back by reference too — copying here would change behaviour in the one
1959
+ # mode whose promise is that nothing changes.
1362
1960
  passed = ActiveSupport::HashWithIndifferentAccess.new(source)
1363
1961
  rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
1364
1962
  end
@@ -1377,25 +1975,121 @@ module Permittable
1377
1975
  end
1378
1976
 
1379
1977
  def permittable_violation_summary(violations)
1978
+ # The param is the client-controlled part; the message (or code) is the
1979
+ # developer's, so it is handed over separately and never escaped — a
1980
+ # YAML `|` message ending in "\n" must not quote every name it follows.
1981
+ #
1982
+ # An unknown-key violation's `param:` is already reportable_text — valid
1983
+ # UTF-8, but scrubbed to U+FFFD wherever the key wasn't. That is right
1984
+ # for `details`/instrumentation (a machine reads it and only needs it not
1985
+ # to crash `to_json`), but prose can do better: permittable_prose_utf8
1986
+ # keeps a legacy byte transcodable and an invalid one visible as \xNN
1987
+ # rather than replacing it, so prose reads the RAW key when one was
1988
+ # saved (see permittable_unknown_key_violation), and falls back to the
1989
+ # param for any other violation.
1380
1990
  permittable_prose_list(violations) do |v|
1381
- v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})"
1991
+ name = @permittable_unknown_key_raw&.[](v) || v[:param].to_s
1992
+ [name, v[:message] ? " #{v[:message]}" : " (#{v[:code]})"]
1382
1993
  end
1383
1994
  end
1384
1995
 
1385
1996
  # See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
1386
1997
  # undeclared keys used to produce a 50,000-item sentence — a megabyte of
1387
1998
  # log line, or of exception message handed to every error tracker.
1388
- # The block formats one item, and is called only for the items actually
1389
- # shown — the rest are counted, never rendered.
1999
+ # The block returns one item's name, or [name, suffix], and is called
2000
+ # only for the items actually shown — the rest are counted, never rendered.
1390
2001
  def permittable_prose_list(items)
1391
- shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(yield(item)) }.join(", ")
2002
+ shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(*yield(item)) }.join(", ")
1392
2003
  return shown if items.length <= PROSE_LIST_LIMIT
1393
2004
 
1394
2005
  "#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
1395
2006
  end
1396
2007
 
1397
- def permittable_prose_item(item)
1398
- item.length <= PROSE_ITEM_LIMIT ? item : "#{item[0, PROSE_ITEM_LIMIT - 3]}..."
2008
+ # See PROSE_ITEM_LIMIT, PROSE_UNSAFE and PROSE_AMBIGUOUS. The item is the
2009
+ # name plus the suffix, truncated as one. Only a name that needs it is
2010
+ # quoted and escaped, judged by the part of it the truncated item would
2011
+ # SHOW — a control character past the cut is not printed, so it quotes
2012
+ # nothing. Every ordinary name therefore prints exactly as before, just
2013
+ # always as UTF-8: a Windows-1252 or binary key is converted rather than
2014
+ # written raw, and names of mixed encodings can be joined.
2015
+ def permittable_prose_item(name, suffix = "")
2016
+ text = permittable_prose_utf8(name[0, PROSE_SCAN_LIMIT])
2017
+ suffix = permittable_prose_utf8(suffix)
2018
+ more = !name[PROSE_SCAN_LIMIT].nil?
2019
+ fits = !more && text.length + suffix.length <= PROSE_ITEM_LIMIT
2020
+ shown = fits ? text : text[0, PROSE_ITEM_LIMIT - 3]
2021
+ if !shown.valid_encoding? || shown.match?(PROSE_UNSAFE) || shown.match?(PROSE_AMBIGUOUS)
2022
+ return permittable_prose_quoted(text, more, suffix)
2023
+ end
2024
+
2025
+ fits ? "#{text}#{suffix}" : "#{"#{text}#{suffix}"[0, PROSE_ITEM_LIMIT - 3]}..."
2026
+ end
2027
+
2028
+ # The name is truncated by whole escapes, never through one: cutting the
2029
+ # escaped text at a fixed width could print a dangling backslash, or half
2030
+ # of an escape. When the name itself is cut, the "..." goes outside the
2031
+ # closing quote, so the quotes still delimit exactly what is shown. A
2032
+ # quoted name that fits the limit on its own is never cut: the suffix is
2033
+ # cut instead, to whatever room is left — possibly none — and the "..."
2034
+ # that marks it may then run up to three characters past the limit. A
2035
+ # dropped developer suffix is better flagged than hidden, and the name is
2036
+ # the part a reader is there for. Only as many characters are escaped as
2037
+ # can be shown.
2038
+ def permittable_prose_quoted(text, more, suffix)
2039
+ budget = PROSE_ITEM_LIMIT - 2 # the two quotes
2040
+ pieces = []
2041
+ length = 0
2042
+ text.each_char do |char|
2043
+ pieces << permittable_prose_escape(char)
2044
+ length += pieces.last.length
2045
+ break if length > budget
2046
+ end
2047
+ if !more && length <= budget
2048
+ quoted = "\"#{pieces.join}\""
2049
+ return "#{quoted}#{suffix}" if quoted.length + suffix.length <= PROSE_ITEM_LIMIT
2050
+
2051
+ return "#{quoted}#{suffix[0, [PROSE_ITEM_LIMIT - 3 - quoted.length, 0].max]}..."
2052
+ end
2053
+
2054
+ length -= pieces.pop.length while length > budget - 3
2055
+ "\"#{pieces.join}\"..."
2056
+ end
2057
+
2058
+ # A byte that is not valid UTF-8 is shown as \xNN rather than passed
2059
+ # through: it is not a character a person can read, and a lone 0x85 or
2060
+ # 0x9B is NEL or CSI to a Latin-1 terminal. A character beyond the BMP
2061
+ # (the Cf tag characters) is \u{XXXXX}, since \uXXXX holds only four digits.
2062
+ def permittable_prose_escape(char)
2063
+ return char.bytes.map { |byte| format('\x%02X', byte) }.join unless char.valid_encoding?
2064
+
2065
+ PROSE_ESCAPES.fetch(char) do
2066
+ next char unless char.match?(PROSE_UNSAFE)
2067
+
2068
+ char.ord > 0xFFFF ? format('\u{%X}', char.ord) : format('\u%04X', char.ord)
2069
+ end
2070
+ end
2071
+
2072
+ # PROSE_UNSAFE is a UTF-8 pattern, and matching it against a binary key
2073
+ # with high bytes raises Encoding::CompatibilityError — a log line must
2074
+ # never be what fails a request. A binary key has no charset to convert
2075
+ # from, and Rack hands UTF-8 bytes over as binary, so it is read as UTF-8.
2076
+ # A key in a real encoding is transcoded character by character: what
2077
+ # maps is converted, and only a byte that does not (Windows-1252 leaves
2078
+ # 0x81, 0x8D, 0x8F, 0x90 and 0x9D undefined) is kept as an invalid byte,
2079
+ # which the escaper then shows as \xNN — rather than reading the whole
2080
+ # key as UTF-8, which turned a mappable é into \xE9 and mojibake into
2081
+ # characters the client never sent.
2082
+ def permittable_prose_utf8(item)
2083
+ return item if item.encoding == Encoding::UTF_8
2084
+ return item.dup.force_encoding(Encoding::UTF_8) if item.encoding == Encoding::BINARY || item.ascii_only?
2085
+
2086
+ converter = Encoding::Converter.new(item.encoding, Encoding::UTF_8)
2087
+ source = item.dup
2088
+ out = String.new(encoding: Encoding::UTF_8)
2089
+ out << converter.primitive_errinfo[3].force_encoding(Encoding::UTF_8) until converter.primitive_convert(source, out) == :finished
2090
+ out
2091
+ rescue EncodingError # no converter, as for a dummy encoding such as UTF-7
2092
+ item.dup.force_encoding(Encoding::UTF_8)
1399
2093
  end
1400
2094
 
1401
2095
  # One violation detail entry. A field's `message:` (String, or Hash keyed
@@ -1495,7 +2189,7 @@ module Permittable
1495
2189
  # Already normalized by permittable_normalized, before the absence rule.
1496
2190
  permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
1497
2191
  when :json
1498
- permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
2192
+ permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
1499
2193
  when :nested
1500
2194
  if value.is_a?(Hash)
1501
2195
  result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
@@ -1512,14 +2206,27 @@ module Permittable
1512
2206
  end
1513
2207
  end
1514
2208
 
2209
+ # Coercion.check_json, with the copy the result needs made in the middle.
2210
+ # The opaque hash is handed over whole, and HashWithIndifferentAccess
2211
+ # rebuilt its containers but not the Strings inside them, so it is
2212
+ # deep-copied for the reason permittable_normalized copies a String — but
2213
+ # only once it is within its bounds. Copying first meant a megabyte
2214
+ # payload refused on `length:` or `max_depth:` was copied in full just to
2215
+ # be refused, undoing the early exit those bounds exist for.
2216
+ def permittable_check_json(field, value)
2217
+ status, code = Coercion.check_json_bounds(field, value)
2218
+ return [status, code] unless status == :ok
2219
+
2220
+ Coercion.check_custom(field[:validate], value.deep_dup)
2221
+ end
2222
+
1515
2223
  # The shared tail of the two kinds whose entire value is checked in one
1516
2224
  # call — a scalar, or an opaque hash. A clean value is transformed into the
1517
2225
  # result; anything else records its code.
1518
2226
  def permittable_check_whole(field, outcome, full, result, violations:)
1519
2227
  status, out = outcome
1520
2228
  if status == :ok
1521
- out = field[:transform].call(out) if field[:transform]
1522
- result[field[:name].to_s] = out
2229
+ result[field[:name].to_s] = permittable_transform(field, out)
1523
2230
  else
1524
2231
  violations << permittable_violation(field, full, out)
1525
2232
  end
@@ -1541,14 +2248,40 @@ module Permittable
1541
2248
  out = value.each_with_index.map do |element, index|
1542
2249
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
1543
2250
  end
1544
- if field[:validate]
2251
+ # validate: and transform: see only a fully-valid array. A partially-nil
2252
+ # one (element violations) would hand user code garbage it never agreed
2253
+ # to see — and for validate: that was a crash, not just garbage:
2254
+ # `validate: ->(a) { a.sum < 100 }` sent `["x", 2]` raised TypeError on
2255
+ # the nil where "x" failed to cast, turning the element's 422 into a 500.
2256
+ #
2257
+ # The cost is real and accepted: the whole-array verdict is no longer
2258
+ # reported ALONGSIDE element violations. `[1, "x", 1]` against a
2259
+ # uniqueness validator reports only `ids[1]`; the client fixes it,
2260
+ # resends, and only then learns of the duplicate. Running app code over
2261
+ # nils it never agreed to handle is the worse failure.
2262
+ #
2263
+ # An undeclared key inside an element (`unknown: :error`) is not such a
2264
+ # violation: it removes nothing from the element validate: sees, so it
2265
+ # does not stop validate: from running.
2266
+ #
2267
+ # transform: is stricter, as on the scalar path: it runs only when
2268
+ # NOTHING violated, validate: included — a transform may rely on what
2269
+ # validate: checked (`Math.sqrt` after "all positive").
2270
+ elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
2271
+ if field[:validate] && elements_valid
1545
2272
  status, code = Coercion.check_custom(field[:validate], out)
1546
2273
  violations << permittable_violation(field, path, code) unless status == :ok
1547
2274
  end
1548
2275
  # Transform only a fully-valid array — a partially-nil one (element
1549
2276
  # violations) would hand user code garbage it never agreed to see.
1550
- out = field[:transform].call(out) if field[:transform] && violations.length == before
1551
- out
2277
+ violations.length == before ? permittable_transform(field, out) : out
2278
+ end
2279
+
2280
+ # The one place a field's `transform:` is applied — a seam, so that
2281
+ # AuthoredValues can walk an authored default through this same walker
2282
+ # without running app code over it at class load.
2283
+ def permittable_transform(field, value)
2284
+ field[:transform] ? field[:transform].call(value) : value
1552
2285
  end
1553
2286
 
1554
2287
  def permittable_check_element(field, element, path, unknown:, violations:)
@@ -1561,7 +2294,7 @@ module Permittable
1561
2294
  path: path, unknown: unknown, top_level: false, violations: violations)
1562
2295
  end
1563
2296
 
1564
- status, out = Coercion.cast(field[:of], element)
2297
+ status, out = Coercion.cast(field[:of], permittable_own(element))
1565
2298
  return out if status == :ok
1566
2299
 
1567
2300
  violations << permittable_violation(field, path, out)
@@ -1575,18 +2308,34 @@ module Permittable
1575
2308
  # corruption strict coercion exists to refuse, delivered by the gem's own
1576
2309
  # preset. Only scalars take normalize:, and apply_normalize is itself a
1577
2310
  # no-op without one, so it owns that decision for every caller.
2311
+ #
2312
+ # It is also where a request's String stops being the request's. Nothing
2313
+ # the walker hands to app code — `normalize:`, `validate:`, `transform:`,
2314
+ # the result — may alias the caller's params, or `permitted_params[:name]
2315
+ # << "x"` (or `normalize: ->(v) { v.strip! || v }`) rewrites the caller's
2316
+ # Hash or ActionController::Parameters behind the app's back. Copying at
2317
+ # the walker's input, ahead of normalize:, makes it one copy per String;
2318
+ # String#dup shares a long String's buffer copy-on-write, so the copy is
2319
+ # cheap until someone writes to it. `of:` elements get the same treatment
2320
+ # in permittable_check_element, and a :json hash in permittable_check_json.
1578
2321
  def permittable_normalized(field, value)
1579
- Coercion.apply_normalize(field[:normalize], value)
2322
+ Coercion.apply_normalize(field[:normalize], permittable_own(value))
2323
+ end
2324
+
2325
+ def permittable_own(value)
2326
+ value.is_a?(String) ? value.dup : value
1580
2327
  end
1581
2328
 
1582
2329
  # An authored default belongs to the contract, which is frozen data (see
1583
- # ContractBuilder#freeze_authored). HashWithIndifferentAccess copies a
1584
- # frozen Array or Hash as it assigns it, but stores a String as-is — so
1585
- # that one is copied here, leaving every value in the result the app's own
2330
+ # ContractBuilder#freeze_authored), so every request gets a deep copy of it.
2331
+ # Copying only a top-level String was not enough: HashWithIndifferentAccess
2332
+ # copies a frozen Array or Hash as it assigns it, but not what is INSIDE
2333
+ # one, so `permitted_params[:tags].first << "x"` on an `of: :string`
2334
+ # default — or any edit to a String in a :json default — raised
2335
+ # FrozenError. A deep copy leaves every value in the result the app's own
1586
2336
  # to mutate.
1587
2337
  def permittable_default(field)
1588
- value = field[:default]
1589
- value.is_a?(String) ? value.dup : value
2338
+ field[:default].deep_dup
1590
2339
  end
1591
2340
 
1592
2341
  # nil and "" are both ABSENT — see the module comment.
@@ -1608,21 +2357,83 @@ module Permittable
1608
2357
 
1609
2358
  declared = fields.map { |f| f[:name].to_s }
1610
2359
  extra = hash.keys.map(&:to_s) - declared
1611
- extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
2360
+ extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys if top_level
1612
2361
  return if extra.empty?
1613
2362
 
1614
2363
  if unknown == :error
1615
- extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
2364
+ extra.each { |key| violations << permittable_unknown_key_violation(path, key) }
1616
2365
  elsif respond_to?(:logger) && logger
1617
- listed = permittable_prose_list(extra) { |key| permittable_path(path, key) }
2366
+ listed = permittable_prose_list(extra) { |key| permittable_path(path, permittable_prose_utf8(key)) }
1618
2367
  logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
1619
2368
  end
1620
2369
  end
1621
2370
 
2371
+ # The top-level keys THIS request's router put into params, beyond the
2372
+ # fixed UNCHECKED_TOP_LEVEL_KEYS: whatever it matched out of the URL
2373
+ # (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a
2374
+ # path parameter, not a body field). A controller only — a plain params
2375
+ # duck and a standalone Contract have no request, so they exempt nothing
2376
+ # extra. Subtracted from the undeclared keys, so a contract that DECLARES
2377
+ # `id` still has that field checked like any other: the value is real, the
2378
+ # URL carried it.
2379
+ def permittable_request_supplied_keys
2380
+ return [] unless respond_to?(:request) && request.respond_to?(:path_parameters)
2381
+
2382
+ request.path_parameters.keys.map(&:to_s)
2383
+ end
2384
+
2385
+ # A rootless contract's input without ParamsWrapper's copy of the body,
2386
+ # when Rails made one (see process_action). Removed rather than merely
2387
+ # exempted from the unknown-keys check, because the client never sent that
2388
+ # key: a contract that happens to declare a scalar or array field of the
2389
+ # wrapper's name (`optional :feedback, :string` on FeedbackController)
2390
+ # would otherwise validate Rails' copy of the whole body as that field — a
2391
+ # false 422 invalid_type for a well-formed request.
2392
+ #
2393
+ # Kept, though, when the contract declares that key as a hash container (a
2394
+ # nested block or :json): that rootless contract is reading the copy ON
2395
+ # PURPOSE, a root: spelled as a field, and it worked that way before the
2396
+ # copy was ever dropped — dropping it would turn every such request into
2397
+ # `user missing`. Top level only, where the copy lives; a rooted contract
2398
+ # reads the copy as its root, which is exactly what ParamsWrapper is for.
2399
+ # What is checked changes, not what monitor mode hands back: its raw
2400
+ # pass-through still carries the copy, as the pre-contract app's params did.
2401
+ def permittable_without_wrapper_copy(fields, source)
2402
+ key = @permittable_wrapper_key
2403
+ return source unless key
2404
+ return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
2405
+
2406
+ source.except(key)
2407
+ end
2408
+
1622
2409
  def permittable_path(path, key)
1623
2410
  path ? "#{path}.#{key}" : key
1624
2411
  end
1625
2412
 
2413
+ # The one place a CLIENT's key enters a path, so the only one converted to
2414
+ # reportable UTF-8 (see Coercion.reportable_text) — every declared key a
2415
+ # request walks through is the contract's own name and is left alone.
2416
+ #
2417
+ # The entry is also remembered by identity, which is how
2418
+ # permittable_check_array tells an undeclared key from a sub-field that
2419
+ # failed. The code alone cannot: a sub-field's validate: may itself
2420
+ # return :unknown.
2421
+ def permittable_unknown_key_violation(path, key)
2422
+ entry = permittable_violation({}, permittable_path(path, Coercion.reportable_text(key)), "unknown")
2423
+ (@permittable_unknown_key_violations ||= {}.compare_by_identity)[entry] = true
2424
+ # Prose (the exception message) gets the richer transcoding instead of
2425
+ # `param:`'s scrubbed-to-U+FFFD text — see permittable_violation_summary.
2426
+ # permittable_prose_utf8, not Coercion.reportable_text, is what keeps the
2427
+ # concatenation with `path` (the contract's own UTF-8 field names) from
2428
+ # raising Encoding::CompatibilityError, the same as the :log line below.
2429
+ (@permittable_unknown_key_raw ||= {}.compare_by_identity)[entry] = permittable_path(path, permittable_prose_utf8(key))
2430
+ entry
2431
+ end
2432
+
2433
+ def permittable_unknown_key_violation?(entry)
2434
+ @permittable_unknown_key_violations&.key?(entry) || false
2435
+ end
2436
+
1626
2437
  def permittable_action_name
1627
2438
  respond_to?(:action_name) && action_name ? action_name.to_s : nil
1628
2439
  end
@@ -1634,6 +2445,10 @@ module Permittable
1634
2445
  end
1635
2446
  end
1636
2447
 
2448
+ # Class-load reading of an authored array default:/example: — the request
2449
+ # walker itself, so it needs the concern's body loaded.
2450
+ require "permittable/authored_values"
2451
+
1637
2452
  # Contract exporters — the other readers of the frozen contract registry.
1638
2453
  # Loaded after the module body so OpenAPI can see the concern's own methods.
1639
2454
  require "permittable/json_schema"
@@ -1646,6 +2461,13 @@ require "permittable/generator"
1646
2461
  # Standalone contracts — the same DSL callable on any Hash, no controller.
1647
2462
  require "permittable/contract"
1648
2463
 
2464
+ # Contract COVERAGE — the registry crossed with the route set, so a
2465
+ # half-covered controller is as visible as an uncovered one.
2466
+ require "permittable/audit"
2467
+
2468
+ # Reusable field lists — `Permittable.fields` + the builder's `use` verb.
2469
+ require "permittable/field_group"
2470
+
1649
2471
  # Boot-time integration (filter_parameters registration, the
1650
2472
  # permittable:openapi rake task), Rails apps only
1651
2473
  require "permittable/railtie" if defined?(Rails::Railtie)