permittable 0.8.0 → 0.10.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 },
@@ -305,7 +403,14 @@ module Permittable
305
403
  # replay of #names — otherwise a sink deduplicating by value would hold
306
404
  # both :ssn and "ssn".
307
405
  name = name.to_s.downcase
308
- sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
406
+ unless name.empty?
407
+ # sensitive_parameter_sinks is the same process-global Array
408
+ # on_sensitive_parameter appends to under @registry_mutex. Reading it
409
+ # here without that lock is an unsynchronized concurrent mutation
410
+ # during iteration on any Ruby without a GVL — a thread class-loading
411
+ # a sensitive: true contract can race a thread installing a sink.
412
+ @registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
413
+ end
309
414
  nil
310
415
  end
311
416
 
@@ -326,6 +431,19 @@ module Permittable
326
431
  @sensitive_parameter_sinks ||= []
327
432
  end
328
433
 
434
+ # A reusable field list, shareable by any number of contracts — see
435
+ # FieldGroup for the whole story.
436
+ #
437
+ # AddressFields = Permittable.fields do
438
+ # required :city, :string
439
+ # optional :zip, :string, format: /\A\d{5}\z/
440
+ # end
441
+ #
442
+ # Splice it into a contract (or another group) with `use`.
443
+ def fields(&)
444
+ FieldGroup.new(&)
445
+ end
446
+
329
447
  # App-wide default for rules that don't declare their own mode:.
330
448
  # :enforce (the default) rejects violating requests; :monitor reports
331
449
  # them — same instrumentation event with payload mode: :monitor, plus a
@@ -346,6 +464,54 @@ module Permittable
346
464
  @mode = value
347
465
  end
348
466
 
467
+ # The shape of a rejection: :envelope (the default — the host's
468
+ # #render_error, or the gem's inline JSON) or :problem, which renders RFC
469
+ # 9457 Problem Details as application/problem+json. App-wide, because the
470
+ # error format of an API is a property of the API rather than of any one
471
+ # contract, and set from an initializer:
472
+ #
473
+ # Permittable.error_format = :problem
474
+ #
475
+ # See ErrorEnvelope, including why :problem opts out of #render_error.
476
+ def error_format
477
+ @error_format || :envelope
478
+ end
479
+
480
+ def error_format=(value)
481
+ value = value.to_sym
482
+ raise ArgumentError, "#{LABEL}: error_format must be one of #{ERROR_FORMATS.join(', ')}" unless ERROR_FORMATS.include?(value)
483
+
484
+ @error_format = value
485
+ end
486
+
487
+ # Base URI for problem `type` members. Unset (the default) leaves the type
488
+ # as RFC 9457's "about:blank"; set it to where the app documents its
489
+ # problem types and each type gets its own URI under it.
490
+ attr_accessor :problem_base_uri
491
+
492
+ # Whether the schema-drift guard also checks that a field's declared type
493
+ # matches its column's, on top of checking the column exists.
494
+ #
495
+ # OFF by default, deliberately. Every cross-type declaration has some
496
+ # legitimate use — a :string contract on a date column that lets
497
+ # ActiveRecord do the casting, a :boolean contract on a legacy integer
498
+ # column — and breaking those apps on an upgrade would cost more than the
499
+ # drift it catches. Turn it on and fix what it finds:
500
+ #
501
+ # Permittable.check_column_types = true
502
+ #
503
+ # It compares type GROUPS rather than exact types, and never fires on a
504
+ # column it has no faithful contract type for. See ColumnGuard.
505
+ def check_column_types
506
+ @check_column_types || false
507
+ end
508
+
509
+ def check_column_types=(value)
510
+ raise ArgumentError, "#{LABEL}: check_column_types must be true or false" unless [true, false].include?(value)
511
+
512
+ @check_column_types = value
513
+ end
514
+
349
515
  # App-wide fallback copy for a violation code, looked up through I18n
350
516
  # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
351
517
  # a validate: returned). Consulted only when the field declares no
@@ -418,22 +584,44 @@ module Permittable
418
584
  def check_scalar_rules(field, value)
419
585
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
420
586
  return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
421
- return [:error, "format"] if field[:format] && !field[:format].match?(value)
587
+ return [:error, "format"] if field[:format] && !format_match?(field[:format], value)
422
588
 
423
589
  check_custom(field[:validate], value)
424
590
  end
425
591
 
592
+ # A Regexp RAISES rather than answers when a String's encoding cannot meet
593
+ # it — a UTF-8 pattern with non-ASCII characters against UTF-16,
594
+ # Shift_JIS or binary bytes (Encoding::CompatibilityError). A value the
595
+ # pattern cannot even be applied to has not been shown to match, so it is
596
+ # a `format` violation, the answer the client can act on. An app that
597
+ # takes other encodings on purpose (`skip_parameter_encoding`) sees what
598
+ # it saw before this rule, except that the crash is now a 422.
599
+ def format_match?(pattern, value)
600
+ pattern.match?(value)
601
+ rescue EncodingError, ArgumentError
602
+ false
603
+ end
604
+
426
605
  # Free-form hash. The shape is deliberately undeclared, so the only
427
606
  # checks are the bounds the field asked for: breadth (`length:`, the
428
607
  # top-level key count, same reading as an array's element count) and
429
608
  # nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
430
609
  # checking, like check_scalar.
431
610
  def check_json(field, value)
611
+ status, code = check_json_bounds(field, value)
612
+ return [status, code] unless status == :ok
613
+
614
+ check_custom(field[:validate], value)
615
+ end
616
+
617
+ # The structural half of check_json, which the request walker runs on
618
+ # its own so that it can copy an accepted hash before app code sees it.
619
+ def check_json_bounds(field, value)
432
620
  return [:error, "invalid_type"] unless value.is_a?(Hash)
433
621
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
434
622
  return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
435
623
 
436
- check_custom(field[:validate], value)
624
+ [:ok, value]
437
625
  end
438
626
 
439
627
  # Container nesting, with the field's own hash as level 1. An Array counts
@@ -463,8 +651,76 @@ module Permittable
463
651
 
464
652
  def cast(type, value)
465
653
  return [:error, "invalid_type"] unless scalar_shaped?(value)
654
+ return public_send("cast_#{type}", value) unless value.is_a?(String)
655
+ # Bytes that are not valid in the String's OWN encoding are not text in
656
+ # any encoding: every String operation after the cast raises on them
657
+ # (`format:`, the normalize presets, an app's `validate:`). Rails'
658
+ # params builder guards a controller; a standalone Contract#call on a
659
+ # webhook payload has nothing in front of it, so `"caf\xC3"` was a 500.
660
+ return [:error, "invalid_type"] unless value.valid_encoding?
661
+ # A :string is handed back exactly as it arrived, in its own encoding.
662
+ # `skip_parameter_encoding` / `param_encoding` send a controller binary
663
+ # or Shift_JIS text ON PURPOSE, and converting it would hand the app
664
+ # something other than what it asked Rails for.
665
+ return cast_string(value) if type == :string
666
+
667
+ # A UTF-8 String IS already its own inspection copy — the check just
668
+ # above already scanned it — so utf8_text would only scan the same
669
+ # object a second time for no new answer. Every other encoding still
670
+ # goes through it: a US-ASCII or binary String is a NEW object once
671
+ # force_encoding'd, and one only `encode` could produce is not yet
672
+ # known to be valid UTF-8 at all.
673
+ text = value.encoding == Encoding::UTF_8 ? value : utf8_text(value)
674
+ text ? public_send("cast_#{type}", text) : [:error, "invalid_type"]
675
+ end
676
+
677
+ # Encodings whose bytes are READ as UTF-8 rather than converted: UTF-8
678
+ # itself, US-ASCII (a subset of it), and binary — which names no
679
+ # encoding at all, and is how a raw socket read or an unlabelled file
680
+ # hands a payload over.
681
+ UTF8_READABLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
682
+
683
+ # A UTF-8 copy of a String, for INSPECTION only — the text a number, a
684
+ # boolean or a date is parsed from — or nil when there is no UTF-8
685
+ # reading of it. The value handed back to the app is never this copy.
686
+ #
687
+ # Parsing the String itself went wrong for any encoding but UTF-8:
688
+ # `Integer()` on UTF-16 "12" raised Encoding::CompatibilityError, and
689
+ # `BigDecimal` read the same String byte by byte and returned 1 — a wrong
690
+ # answer where the other at least crashed.
691
+ #
692
+ # Binary and US-ASCII are read as UTF-8 (on a copy — the caller's String
693
+ # keeps its encoding), anything else is converted with `encode`, and a
694
+ # result that is not valid UTF-8, or a conversion that raises, is nil.
695
+ def utf8_text(value)
696
+ text = if value.encoding == Encoding::UTF_8 then value
697
+ elsif UTF8_READABLE.include?(value.encoding) then value.dup.force_encoding(Encoding::UTF_8)
698
+ else value.encode(Encoding::UTF_8)
699
+ end
700
+ text.valid_encoding? ? text : nil
701
+ rescue EncodingError
702
+ nil
703
+ end
466
704
 
467
- public_send("cast_#{type}", value)
705
+ # utf8_text for text the gem must REPORT rather than judge — a client's
706
+ # undeclared key, written into a violation's `param`, the exception
707
+ # message and the log line. Refusing is not an option there, so what
708
+ # cannot be read is replaced with U+FFFD. Otherwise the undeclared key
709
+ # "caf\xC3" was copied raw into the 422 and rendering it raised
710
+ # JSON::GeneratorError, and a UTF-16 key raised
711
+ # Encoding::CompatibilityError while its path was being interpolated.
712
+ # Only undeclared keys come here (see permittable_unknown_key_violation):
713
+ # a declared key is the contract's own UTF-8 name.
714
+ def reportable_text(value)
715
+ utf8_text(value) || scrubbed_text(value)
716
+ end
717
+
718
+ def scrubbed_text(value)
719
+ return value.dup.force_encoding(Encoding::UTF_8).scrub if UTF8_READABLE.include?(value.encoding)
720
+
721
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
722
+ rescue EncodingError
723
+ value.dup.force_encoding(Encoding::UTF_8).scrub
468
724
  end
469
725
 
470
726
  # Arrays, hashes, and nested ActionController::Parameters
@@ -476,19 +732,44 @@ module Permittable
476
732
  true
477
733
  end
478
734
 
735
+ # A String is returned as given. The request walker has already copied
736
+ # it on the way in (Permittable#permittable_normalized), before
737
+ # `normalize:` could see it — copying here as well would allocate twice
738
+ # per value and still come too late for a mutating `normalize:` proc.
479
739
  def cast_string(value)
480
740
  case value
481
741
  when String then [:ok, value]
742
+ # BigDecimal#to_s defaults to engineering notation ("0.15e1" for 1.5) —
743
+ # stdlib's own rendering, only ever masked in a host that has loaded
744
+ # Rails' active_support/core_ext/big_decimal/conversions, which patches
745
+ # the default format to "F". A :decimal default:/example: cast through
746
+ # here (a plain :string field, not :decimal itself) must render the same
747
+ # way regardless of whether that patch happens to be loaded.
748
+ when BigDecimal then [:ok, value.to_s("F")]
482
749
  when Numeric, true, false then [:ok, value.to_s]
483
750
  else [:error, "invalid_type"]
484
751
  end
485
752
  end
486
753
 
754
+ # Kernel#Integer/Float and BigDecimal() all accept underscore digit
755
+ # separators and surrounding whitespace — a convenience for a NUMBER
756
+ # LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
757
+ # client spells eighteen, and " 99 " is not how one spells ninety-nine;
758
+ # silently accepting either is the same kind of leniency as the
759
+ # NaN/Infinity/`0e10` cases below, just arriving from a different door.
760
+ # Checked against the raw String before any of those delegate, so a
761
+ # non-canonical spelling never reaches them at all.
762
+ INTEGER_FORMAT = /\A[+-]?\d+\z/
763
+ NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
764
+
487
765
  def cast_integer(value)
488
766
  case value
489
767
  when Integer then [:ok, value]
490
- when Float then value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
491
- when String then [:ok, Integer(value, 10)]
768
+ # NaN and Infinity first: `truncate` raises FloatDomainError on them (a
769
+ # RangeError, which the ArgumentError rescue below does not catch), and
770
+ # no integer is what either one sent. Same rule as finite_float.
771
+ when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
772
+ when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
492
773
  else [:error, "invalid_type"]
493
774
  end
494
775
  rescue ArgumentError
@@ -498,7 +779,7 @@ module Permittable
498
779
  def cast_float(value)
499
780
  case value
500
781
  when Numeric then finite_float(value.to_f)
501
- when String then finite_float(Float(value), source: value)
782
+ when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
502
783
  else [:error, "invalid_type"]
503
784
  end
504
785
  rescue ArgumentError
@@ -529,7 +810,8 @@ module Permittable
529
810
 
530
811
  def cast_decimal(value)
531
812
  case value
532
- when Numeric, String then finite_decimal(BigDecimal(value.to_s))
813
+ when Numeric then finite_decimal(BigDecimal(value.to_s))
814
+ when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
533
815
  else [:error, "invalid_type"]
534
816
  end
535
817
  rescue ArgumentError
@@ -614,10 +896,37 @@ module Permittable
614
896
 
615
897
  # Presets only make sense on String input; a non-String value (JSON
616
898
  # numbers, booleans) skips normalization and goes straight to the cast.
899
+ #
900
+ # A String that is not valid in its own encoding is left as it came, for
901
+ # the cast to refuse: every preset raises on invalid bytes, and an app's
902
+ # own proc would be handed input it never agreed to see. It is not empty,
903
+ # so the absence rule in between cannot mistake it for a missing value.
904
+ #
905
+ # A VALID String in another encoding is normalized in that encoding —
906
+ # `:strip` works on binary and Shift_JIS alike — and when a BUILT-IN
907
+ # PRESET cannot handle the encoding (`:squish` on UTF-16 raises
908
+ # Encoding::CompatibilityError) the value is left as it is rather than
909
+ # raising.
910
+ #
911
+ # That leniency is only for the gem's own presets, identified by object
912
+ # identity against NORMALIZERS' values (resolve_normalizer! replaces
913
+ # field[:normalize] with the exact Proc from that Hash, so a preset and
914
+ # an app-supplied Proc are never the same object). An app's own Proc
915
+ # raising is never swallowed, on ANY encoding: `normalize: ->(v) { raise
916
+ # ArgumentError, "..." if ... }` is a business rule, not an encoding
917
+ # failure, and treating its raise as "this encoding defeated the
918
+ # normalizer" would have let exactly the input a UTF-8 request could not
919
+ # bypass the very check it names.
617
920
  def apply_normalize(normalizer, value)
618
921
  return value unless normalizer && value.is_a?(String)
922
+ return value unless value.valid_encoding?
923
+ return normalizer.call(value) unless NORMALIZERS.value?(normalizer)
619
924
 
620
- normalizer.call(value)
925
+ begin
926
+ normalizer.call(value)
927
+ rescue EncodingError, ArgumentError
928
+ value
929
+ end
621
930
  end
622
931
 
623
932
  # nil and "" are both ABSENT — see the module comment. The VALUE half of
@@ -628,6 +937,109 @@ module Permittable
628
937
  value.nil? || (value.is_a?(String) && value.empty?)
629
938
  end
630
939
 
940
+ # An `in:` list as the runtime holds it: every member cast by the field's
941
+ # own type, because included_in? compares the CAST request value against
942
+ # it. Comparing against the members as authored meant `in: %i[draft
943
+ # published]` on a :string field (and `in: %w[1 2 3]` on an :integer one)
944
+ # held values no cast could ever produce, and rejected every request.
945
+ #
946
+ # `normalize:` is deliberately not applied — it rewrites what a client
947
+ # sent, not what the contract author wrote. Duplicates the cast collapses
948
+ # ("1" and 1 on an :integer) are dropped, and a Set stays a Set, so an
949
+ # author who chose one for its O(1) include? keeps it. A nil member is
950
+ # dropped on a nullable field, where an explicit null is accepted before
951
+ # in: is ever consulted; anywhere else it is a member no value can equal,
952
+ # and is an error like any other.
953
+ #
954
+ # `members` is what in_list returned. Returns [:ok, cast, published] —
955
+ # `published` being what an exported enum lists, see published_in_member
956
+ # — or [:error, offending_member, code]. Shared by ContractBuilder and the
957
+ # RSpec matcher's `within` chain so the two cannot read a list differently.
958
+ def cast_in_members(type, members, nullable: false)
959
+ pairs = []
960
+ members.each do |member|
961
+ next if member.nil? && nullable
962
+
963
+ status, value = cast_in_member(type, member)
964
+ return [:error, member, value] unless status == :ok
965
+
966
+ pairs << [value, published_in_member(type, member, value)]
967
+ end
968
+ pairs = pairs.uniq(&:first)
969
+ cast_members = pairs.map(&:first)
970
+ [:ok, members.is_a?(Set) ? cast_members.to_set : cast_members, pairs.map(&:last)]
971
+ end
972
+
973
+ # The members of an `in:` that is a LIST, or nil when it is not one.
974
+ # Only Array, Set, Hash and Enumerator count, and only when the object's
975
+ # OWN class provides the collection's ordinary include? — not a Hash,
976
+ # Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
977
+ # fuzzy Set, a registry matching some other way entirely). `case allowed;
978
+ # when Hash ...` matches with ===, which for a Class is is_a?, so a
979
+ # subclass would otherwise match its ancestor's branch and have its
980
+ # override silently discarded — read for its raw keys/elements instead,
981
+ # which can invert which values it actually accepts. It is left opaque
982
+ # instead, exactly like any other object whose include? is the point
983
+ # (see resolve_in!) and enumerating it may be expensive (a DB-backed
984
+ # registry).
985
+ #
986
+ # A Hash lists its KEYS, which is what Hash#include? asks about — the
987
+ # Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
988
+ # Set, so membership stays O(1) per request.
989
+ # ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
990
+ # accepted anyway: its include? override only canonicalises the argument
991
+ # (String/Symbol) before the SAME key lookup, so its keys are still
992
+ # exactly its members — and it is what a Rails enum's own reader
993
+ # (`Post.statuses`) actually returns.
994
+ # Enumerator::Lazy is the same story on the Enumerator side: Lazy
995
+ # overrides chain methods like map and select, but not include?, so it
996
+ # is still read as a list — and forced to an Array here, once, since
997
+ # left lazy it would be cast on every request instead of at class load.
998
+ def in_list(allowed)
999
+ case allowed
1000
+ when Hash then allowed.keys.to_set if plain_hash?(allowed)
1001
+ when Set then allowed if allowed.instance_of?(Set)
1002
+ when Array then allowed.to_a if allowed.instance_of?(Array)
1003
+ when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
1004
+ end
1005
+ end
1006
+
1007
+ def plain_hash?(allowed)
1008
+ allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
1009
+ end
1010
+
1011
+ # A Symbol is read as its String: it is how Ruby spells a constant
1012
+ # string, and a request never carries one, so no cast accepts it as is.
1013
+ def cast_in_member(type, member)
1014
+ member = member.to_s if member.is_a?(Symbol)
1015
+ return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
1016
+
1017
+ cast(type, member)
1018
+ end
1019
+
1020
+ # A Time or DateTime member of a :date field. ActiveSupport compares one
1021
+ # with a Date as INSTANTS, the Date standing for its midnight UTC, so
1022
+ # that instant is the only one that ever equalled a request's date. It
1023
+ # is read as that UTC date; any other instant never matched anything,
1024
+ # and is refused like any member no request could equal. (cast_date
1025
+ # would keep a DateTime whole — it IS a Date — and refuse a Time.)
1026
+ def instant_as_date(member)
1027
+ utc = member.to_time.getutc
1028
+ return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
1029
+
1030
+ [:ok, utc.to_date]
1031
+ end
1032
+
1033
+ # What an exported enum lists for one member: the cast value, re-encoded
1034
+ # as JSON — except a :date/:datetime member authored as a String, which
1035
+ # is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
1036
+ # so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
1037
+ # the server refuses. The authored String went through the very cast a
1038
+ # request does, so the server accepts it by construction.
1039
+ def published_in_member(type, member, value)
1040
+ member.is_a?(String) && %i[date datetime].include?(type) ? member : value
1041
+ end
1042
+
631
1043
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
632
1044
  # and the right semantics for validation.
633
1045
  def included_in?(allowed, value)
@@ -651,6 +1063,13 @@ module Permittable
651
1063
  ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
652
1064
  nullable].freeze
653
1065
 
1066
+ # One value of each scalar type as a cast produces it, for asking whether
1067
+ # an `in:` Range's endpoints can be compared with that type at all.
1068
+ RANGE_PROBES = {
1069
+ string: "", integer: 0, float: 0.0, decimal: BigDecimal("0"), boolean: true,
1070
+ date: Date.new(2000, 1, 1), datetime: Time.utc(2000)
1071
+ }.freeze
1072
+
654
1073
  attr_reader :finalizer
655
1074
 
656
1075
  def initialize
@@ -691,6 +1110,10 @@ module Permittable
691
1110
  required = opts.delete(:required) ? true : false
692
1111
 
693
1112
  field = { name: name, kind: :array, required: required, **opts }
1113
+ if field[:required] && field.key?(:default)
1114
+ raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
1115
+ end
1116
+
694
1117
  if block
695
1118
  raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
696
1119
 
@@ -708,8 +1131,69 @@ module Permittable
708
1131
  @fields << field
709
1132
  end
710
1133
 
1134
+ # Splice a reusable field group in at this point — the same fields, in the
1135
+ # same order, as if they had been typed here. Works at the top level of a
1136
+ # contract, inside a nested or array block, and inside another group.
1137
+ #
1138
+ # permit_params :create, root: :user do
1139
+ # required :name, :string
1140
+ # optional :address do
1141
+ # use AddressFields
1142
+ # end
1143
+ # end
1144
+ #
1145
+ # `optional: true` relaxes every spliced field, which is how an update
1146
+ # contract reuses a create contract: nothing is mandatory, but `default:`,
1147
+ # types and bounds all still apply. It relaxes the TOP LEVEL only — if a
1148
+ # client sends an address at all, the address's own required sub-fields
1149
+ # still hold.
1150
+ #
1151
+ # `only:`/`except:` select a subset, in the group's own order. Naming a
1152
+ # field the group doesn't declare is a class-load error, so a typo cannot
1153
+ # silently drop a field. A field declared twice still raises, so
1154
+ # overriding one field of a group is deliberate: `use G, except: [:city]`
1155
+ # and then declare `:city` yourself.
1156
+ def use(group, only: nil, except: nil, optional: false)
1157
+ fields = select_group_fields!(group_fields!(group), only: only, except: except)
1158
+ fields = fields.map { |field| field.merge(required: false).freeze } if optional
1159
+ fields.each do |field|
1160
+ field_name!(field[:name])
1161
+ @fields << field
1162
+ end
1163
+ nil
1164
+ end
1165
+
711
1166
  private
712
1167
 
1168
+ def group_fields!(group)
1169
+ return group.fields if group.respond_to?(:fields)
1170
+
1171
+ raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
1172
+ "answering #fields, such as a Permittable::Contract — got #{group.class}"
1173
+ end
1174
+
1175
+ def select_group_fields!(fields, only:, except:)
1176
+ raise ArgumentError, "#{LABEL}: use takes only: OR except:, not both" if only && except
1177
+
1178
+ if only || except
1179
+ wanted = assert_group_names!(fields, only || except, only ? "only" : "except")
1180
+ fields = only ? fields.select { |f| wanted.include?(f[:name]) } : fields.reject { |f| wanted.include?(f[:name]) }
1181
+ end
1182
+ return fields unless fields.empty?
1183
+
1184
+ raise ArgumentError, "#{LABEL}: use selects no fields from the group"
1185
+ end
1186
+
1187
+ def assert_group_names!(fields, names, label)
1188
+ wanted = Array(names).map(&:to_sym)
1189
+ declared = fields.map { |f| f[:name] }
1190
+ missing = wanted - declared
1191
+ return wanted if missing.empty?
1192
+
1193
+ raise ArgumentError, "#{LABEL}: use #{label}: names #{missing.map(&:inspect).join(', ')}, which the group " \
1194
+ "does not declare (it declares: #{declared.map(&:inspect).join(', ')})"
1195
+ end
1196
+
713
1197
  def add_field(name, type, required:, opts:, &block)
714
1198
  name = field_name!(name)
715
1199
  if block
@@ -813,25 +1297,109 @@ module Permittable
813
1297
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
814
1298
  end
815
1299
 
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
-
1300
+ resolve_in!(field) if field.key?(:in)
824
1301
  validate_string_only_opts!(field)
825
1302
  validate_length!(name, field[:length]) if field.key?(:length)
826
1303
  validate_required_length!(field)
827
1304
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
828
1305
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
1306
+ resolve_format!(field)
829
1307
  resolve_normalizer!(field)
830
1308
  validate_authored_value!(field, :default)
831
1309
  validate_authored_value!(field, :example)
832
1310
  validate_message!(field)
833
1311
  end
834
1312
 
1313
+ # `in:` is a Range (bounds-checked with cover?), a list of values, or an
1314
+ # object of the host's own that answers include? — kept exactly as given,
1315
+ # since nothing here can know what it accepts. It used to be anything
1316
+ # answering include?, which let a String through, and String#include? is
1317
+ # a SUBSTRING test: `in: "free pro"` accepted "e", "fr" and "ee p". A
1318
+ # String is refused here, along with anything answering neither.
1319
+ #
1320
+ # A list (see Coercion.in_list — a Hash lists its keys) is stored cast by
1321
+ # the field's type (see Coercion.cast_in_members), so request-time
1322
+ # matching, the exported enum, the RSpec matcher and the column guard's
1323
+ # enum rule all read the members the runtime compares against. A member
1324
+ # no request value could ever equal is a contract mistake, and fails here
1325
+ # rather than as an `inclusion` on every request.
1326
+ def resolve_in!(field)
1327
+ name = field[:name]
1328
+ allowed = field[:in]
1329
+ if allowed.is_a?(Range)
1330
+ assert_comparable_range!(field, allowed)
1331
+ elsif (members = Coercion.in_list(allowed))
1332
+ cast_in_members!(field, members)
1333
+ elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
1334
+ raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
1335
+ "or a Hash read as its keys), or an object answering include? " \
1336
+ "(got #{allowed.inspect})#{string_in_hint(allowed)}"
1337
+ end
1338
+ assert_satisfiable!(name, :in, field[:in])
1339
+ end
1340
+
1341
+ def string_in_hint(allowed)
1342
+ return "" unless allowed.is_a?(String)
1343
+
1344
+ " — String#include? would accept any substring; list the values instead, e.g. in: %w[#{allowed}]"
1345
+ end
1346
+
1347
+ # `published` is stored only where it differs from the cast members (a
1348
+ # String-authored :date/:datetime member), so it is read as an override.
1349
+ def cast_in_members!(field, members)
1350
+ status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
1351
+ unless status == :ok
1352
+ # cast is the offending member here, and published its error code.
1353
+ # nil is the one member written on purpose, meaning "null is allowed"
1354
+ # — but an absent value never reaches in:, so the fix is worth naming.
1355
+ hint = cast.nil? ? " — an absent value never reaches in:; declare nullable: true to accept an explicit null" : ""
1356
+ raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} contains #{cast.inspect}, " \
1357
+ "which is not a valid :#{field[:type]} (#{published})#{hint}"
1358
+ end
1359
+
1360
+ field[:in] = freeze_in_members(cast)
1361
+ field[:in_published] = freeze_authored(published) unless published == cast.to_a
1362
+ end
1363
+
1364
+ def freeze_in_members(members)
1365
+ members.is_a?(Set) ? members.to_set { |member| freeze_authored(member) }.freeze : freeze_authored(members)
1366
+ end
1367
+
1368
+ # A Range is kept exactly as written, unlike a list: casting its
1369
+ # endpoints would change what it means. `0..Float::INFINITY` on a :float
1370
+ # and `1.5..3` on an :integer are real bounds whose endpoints no cast
1371
+ # accepts, and a :decimal's `0..100` would become BigDecimal endpoints
1372
+ # that export as the STRING "0.0" where `minimum` needs a number.
1373
+ #
1374
+ # What does fail every request is an endpoint the cast value cannot be
1375
+ # compared with — `"1".."5"` on an :integer, `1..5` on a :string,
1376
+ # `.."9.99"` on a :decimal. cover? then answers false for every value, so
1377
+ # that is caught here. The probe asks exactly what cover? will — begin
1378
+ # <=> value, then value <=> end — so whatever the host's own <=> allows
1379
+ # (ActiveSupport lets a Date range bound a :datetime) is allowed here too.
1380
+ def assert_comparable_range!(field, range)
1381
+ probe = RANGE_PROBES.fetch(field[:type])
1382
+ # A NaN endpoint compares to nothing, by design, whatever it stands
1383
+ # beside — not evidence of a wrong-TYPED bound (a String range on an
1384
+ # :integer), which is what this check exists to catch. It is left
1385
+ # alone here exactly as an infinite endpoint already is (INFINITY
1386
+ # compares fine); the exporter separately omits it, since it is
1387
+ # never `finite?`.
1388
+ # Wrapped in an Array so a `false` endpoint still reads as found.
1389
+ stray = if !range.begin.nil? && !nan?(range.begin) && (range.begin <=> probe).nil? then [range.begin]
1390
+ elsif !range.end.nil? && !nan?(range.end) && (probe <=> range.end).nil? then [range.end]
1391
+ end
1392
+ return unless stray
1393
+
1394
+ raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} is a Range of #{stray.first.class} " \
1395
+ "(#{range.inspect}), which a :#{field[:type]} value cannot be compared with — " \
1396
+ "no value could satisfy it; write the bounds as :#{field[:type]} values"
1397
+ end
1398
+
1399
+ def nan?(value)
1400
+ value.respond_to?(:nan?) && value.nan?
1401
+ end
1402
+
835
1403
  def validate_json_opts!(field)
836
1404
  name = field[:name]
837
1405
  if field[:required] && field.key?(:default)
@@ -930,6 +1498,28 @@ module Permittable
930
1498
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
931
1499
  end
932
1500
 
1501
+ # A Symbol (or String) `format:` names a preset; a Regexp is used as
1502
+ # given. Resolving here means request-time matching stays a plain
1503
+ # Regexp#match?, and an authored `default:`/`example:` is checked against
1504
+ # the resolved pattern like any other. The preset NAME is kept on the
1505
+ # field so exporters and the RSpec matcher can speak in presets.
1506
+ def resolve_format!(field)
1507
+ preset = field[:format]
1508
+ return if preset.nil? || preset.is_a?(Regexp)
1509
+
1510
+ unless preset.is_a?(Symbol) || preset.is_a?(String)
1511
+ raise ArgumentError, "#{LABEL}: :format for field :#{field[:name]} must be a Regexp or a preset name " \
1512
+ "(presets: #{FORMATS.keys.join(', ')})"
1513
+ end
1514
+
1515
+ spec = FORMATS.fetch(preset.to_sym) do
1516
+ raise ArgumentError, "#{LABEL}: unknown :format preset :#{preset} for field :#{field[:name]} " \
1517
+ "(presets: #{FORMATS.keys.join(', ')}, or pass a Regexp)"
1518
+ end
1519
+ field[:format_name] = preset.to_sym
1520
+ field[:format] = spec[:pattern]
1521
+ end
1522
+
933
1523
  def resolve_normalizer!(field)
934
1524
  normalizer = field[:normalize]
935
1525
  return if normalizer.nil?
@@ -943,85 +1533,69 @@ module Permittable
943
1533
 
944
1534
  # An authored value (`default:`, or a documentation `example:`) must
945
1535
  # 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 ".
1536
+ # shipping it to every request (or publishing it in generated docs) —
1537
+ # which is checked here by normalizing and casting it, same as a request's
1538
+ # value. `default: " free "` with `normalize: :squish` is checked as
1539
+ # "free"; `default: "18"` on an :integer is checked as 18.
1540
+ #
1541
+ # What is STORED from that differs by whether the field has `transform:`.
1542
+ # With none, the cast result is stored — the form a request sending the
1543
+ # same value gets, and what used to be thrown away: `default: "18"` used
1544
+ # to be handed to every request omitting it as the String "18", and
1545
+ # `:boolean, default: "false"` gave the app a truthy String.
1546
+ # With a `transform:`, the value is stored exactly AS AUTHORED instead —
1547
+ # `transform:` never runs on a default (see AuthoredValues), so casting it
1548
+ # here would silently change its type out from under an author who, per
1549
+ # the README, writes such a default in the shape the action should
1550
+ # receive: `default: 25` beside `transform: ->(v) { v.to_i }` on a
1551
+ # :string field means the app gets the Integer 25 either way, whether the
1552
+ # request sent "25" (cast then transformed) or omitted the field
1553
+ # (authored as the already-final Integer).
950
1554
  def validate_authored_value!(field, opt)
951
1555
  return unless field.key?(opt)
952
1556
  return if authored_nil!(field, opt)
953
1557
 
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
-
1558
+ # Copied first, like a request's String, so a mutating `normalize:`
1559
+ # proc cannot rewrite the host's own literal.
1560
+ authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1561
+ value = Coercion.apply_normalize(field[:normalize], authored)
1562
+ status, result = Coercion.check_scalar(field, value)
1563
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1564
+
1565
+ field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
1566
+ end
1567
+
1568
+ # An array's authored value is validated by the REQUEST walker itself
1569
+ # (see AuthoredValues) exactly as validate_authored_value! validates a
1570
+ # scalar's: elements cast, nested hashes and arrays read at every depth, a
1571
+ # sub-field's own default: filled in, `""` on a nullable sub-field made
1572
+ # the explicit nil a request would get, keys the block does not declare
1573
+ # dropped (as `unknown: :ignore` drops them), and the array's own
1574
+ # `validate:` run over the result. A hand-rolled one-level check used to
1575
+ # cast only the top level of each element, and got every one of those
1576
+ # wrong.
1577
+ #
1578
+ # What is STORED follows the same split as a scalar's: without
1579
+ # `transform:`, the walker's read (exactly what a request sending it
1580
+ # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1581
+ # so a declaration mistake (an element `validate:` refuses, a sub-field
1582
+ # default out of bounds) still fails at class load, but its cast result
1583
+ # is discarded rather than stored. The array's OWN `transform:` is
1584
+ # deliberately never run on a default either way; a SUB-FIELD's
1585
+ # `transform:` still runs during that walk, so "the walker's read" above
1586
+ # really is what an equivalent request produces — see AuthoredValues.
961
1587
  def validate_array_authored_value!(field, opt)
962
1588
  value = field[opt]
963
1589
  return if authored_nil!(field, opt)
964
1590
  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
1591
 
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) }
1592
+ read, violations = AuthoredValues.read_array(field, value)
1593
+ unless violations.empty?
1594
+ raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
1595
+ "#{AuthoredValues.summary(violations)}"
991
1596
  end
992
- end
993
-
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"
1008
- end
1009
- return unless sub[:kind] == :scalar
1010
1597
 
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
1598
+ field[opt] = freeze_authored(field[:transform] ? value : read)
1025
1599
  end
1026
1600
 
1027
1601
  # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
@@ -1068,7 +1642,11 @@ module Permittable
1068
1642
  def validate_message!(field)
1069
1643
  spec = field[:message]
1070
1644
  return if spec.nil?
1071
- return if spec.is_a?(String)
1645
+
1646
+ if spec.is_a?(String)
1647
+ field[:message] = freeze_authored(spec)
1648
+ return
1649
+ end
1072
1650
 
1073
1651
  valid_hash = spec.is_a?(Hash) && !spec.empty? &&
1074
1652
  spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
@@ -1077,7 +1655,7 @@ module Permittable
1077
1655
  "or a Hash of violation code => String (e.g. { missing: \"is required\" })"
1078
1656
  end
1079
1657
 
1080
- field[:message] = spec.transform_keys(&:to_sym).freeze
1658
+ field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
1081
1659
  end
1082
1660
  end
1083
1661
 
@@ -1201,9 +1779,16 @@ module Permittable
1201
1779
  return if checked.empty?
1202
1780
 
1203
1781
  types = checked.to_h { |f| [f[:name], f[:type]] }
1782
+ allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
1204
1783
  begin
1205
- ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] }, types: types)
1784
+ ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
1785
+ types: types, check_types: Permittable.check_column_types,
1786
+ allowed: allowed)
1206
1787
  rescue ArgumentError => e
1788
+ # The type error carries its own guidance; only the missing-column one
1789
+ # needs the virtual: hint appended.
1790
+ raise e unless e.message.include?("does not exist in the database")
1791
+
1207
1792
  raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
1208
1793
  end
1209
1794
  end
@@ -1308,6 +1893,39 @@ module Permittable
1308
1893
 
1309
1894
  private
1310
1895
 
1896
+ # ParamsWrapper copies a JSON body under the controller's wrapper key
1897
+ # (`user` for UsersController) — on by default in a Rails app — and a
1898
+ # rootless contract then saw that copy as an unknown top-level key on every
1899
+ # well-formed request. Whether the copy is Rails' own has to be read HERE,
1900
+ # before ParamsWrapper#process_action (next in the chain) runs: once it has
1901
+ # wrapped, `_wrapper_enabled?` answers false, because params now carry the
1902
+ # key. Asking afterwards could not tell Rails' copy from a client that sent
1903
+ # `user` itself, which is exactly the key the check must still flag.
1904
+ # Private, like the method it wraps — a public one would become an action.
1905
+ # A plain duck has no process_action and no ParamsWrapper, so this never
1906
+ # runs there, and the guards keep an actionpack-free host inert.
1907
+ # Assigned on every request, never only when true: a controller instance
1908
+ # dispatched twice would otherwise carry one request's exemption into the
1909
+ # next, where a `user` the client did send would pass as Rails' copy.
1910
+ def process_action(*)
1911
+ @permittable_wrapper_key = permittable_wrapper_copy_key
1912
+ super
1913
+ end
1914
+
1915
+ # The wrapper key, if ParamsWrapper is about to copy the body under it;
1916
+ # nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
1917
+ # string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
1918
+ # — the form the Rails docs use — answers "not sent" even when the client
1919
+ # sent `user` itself, and Rails wraps anyway. Asking the same params for
1920
+ # the String keeps that client's key the client's, whichever spelling the
1921
+ # host chose.
1922
+ def permittable_wrapper_copy_key
1923
+ return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
1924
+
1925
+ key = _wrapper_key.to_s
1926
+ key unless request.parameters.key?(key)
1927
+ end
1928
+
1311
1929
  # The value permitted_params memoizes: the validated params, or the
1312
1930
  # InvalidParameters that rejected them. ArgumentError is deliberately NOT
1313
1931
  # memoized — a contract that does not cover the action is a bug to fix, not
@@ -1326,8 +1944,9 @@ module Permittable
1326
1944
  source = permittable_root_hash(rule, violations)
1327
1945
  result = ActiveSupport::HashWithIndifferentAccess.new
1328
1946
  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)
1947
+ checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
1948
+ result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
1949
+ unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
1331
1950
  end
1332
1951
  # finalize only sees a hash every field vouched for — never garbage.
1333
1952
  result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
@@ -1359,6 +1978,10 @@ module Permittable
1359
1978
  end
1360
1979
  return ActiveSupport::HashWithIndifferentAccess.new unless source
1361
1980
 
1981
+ # Deliberately NOT deep-copied, unlike the enforce path's result: this is
1982
+ # the pre-contract app's own params, and `params.permit` hands its Strings
1983
+ # back by reference too — copying here would change behaviour in the one
1984
+ # mode whose promise is that nothing changes.
1362
1985
  passed = ActiveSupport::HashWithIndifferentAccess.new(source)
1363
1986
  rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
1364
1987
  end
@@ -1377,25 +2000,121 @@ module Permittable
1377
2000
  end
1378
2001
 
1379
2002
  def permittable_violation_summary(violations)
2003
+ # The param is the client-controlled part; the message (or code) is the
2004
+ # developer's, so it is handed over separately and never escaped — a
2005
+ # YAML `|` message ending in "\n" must not quote every name it follows.
2006
+ #
2007
+ # An unknown-key violation's `param:` is already reportable_text — valid
2008
+ # UTF-8, but scrubbed to U+FFFD wherever the key wasn't. That is right
2009
+ # for `details`/instrumentation (a machine reads it and only needs it not
2010
+ # to crash `to_json`), but prose can do better: permittable_prose_utf8
2011
+ # keeps a legacy byte transcodable and an invalid one visible as \xNN
2012
+ # rather than replacing it, so prose reads the RAW key when one was
2013
+ # saved (see permittable_unknown_key_violation), and falls back to the
2014
+ # param for any other violation.
1380
2015
  permittable_prose_list(violations) do |v|
1381
- v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})"
2016
+ name = @permittable_unknown_key_raw&.[](v) || v[:param].to_s
2017
+ [name, v[:message] ? " #{v[:message]}" : " (#{v[:code]})"]
1382
2018
  end
1383
2019
  end
1384
2020
 
1385
2021
  # See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
1386
2022
  # undeclared keys used to produce a 50,000-item sentence — a megabyte of
1387
2023
  # 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.
2024
+ # The block returns one item's name, or [name, suffix], and is called
2025
+ # only for the items actually shown — the rest are counted, never rendered.
1390
2026
  def permittable_prose_list(items)
1391
- shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(yield(item)) }.join(", ")
2027
+ shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(*yield(item)) }.join(", ")
1392
2028
  return shown if items.length <= PROSE_LIST_LIMIT
1393
2029
 
1394
2030
  "#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
1395
2031
  end
1396
2032
 
1397
- def permittable_prose_item(item)
1398
- item.length <= PROSE_ITEM_LIMIT ? item : "#{item[0, PROSE_ITEM_LIMIT - 3]}..."
2033
+ # See PROSE_ITEM_LIMIT, PROSE_UNSAFE and PROSE_AMBIGUOUS. The item is the
2034
+ # name plus the suffix, truncated as one. Only a name that needs it is
2035
+ # quoted and escaped, judged by the part of it the truncated item would
2036
+ # SHOW — a control character past the cut is not printed, so it quotes
2037
+ # nothing. Every ordinary name therefore prints exactly as before, just
2038
+ # always as UTF-8: a Windows-1252 or binary key is converted rather than
2039
+ # written raw, and names of mixed encodings can be joined.
2040
+ def permittable_prose_item(name, suffix = "")
2041
+ text = permittable_prose_utf8(name[0, PROSE_SCAN_LIMIT])
2042
+ suffix = permittable_prose_utf8(suffix)
2043
+ more = !name[PROSE_SCAN_LIMIT].nil?
2044
+ fits = !more && text.length + suffix.length <= PROSE_ITEM_LIMIT
2045
+ shown = fits ? text : text[0, PROSE_ITEM_LIMIT - 3]
2046
+ if !shown.valid_encoding? || shown.match?(PROSE_UNSAFE) || shown.match?(PROSE_AMBIGUOUS)
2047
+ return permittable_prose_quoted(text, more, suffix)
2048
+ end
2049
+
2050
+ fits ? "#{text}#{suffix}" : "#{"#{text}#{suffix}"[0, PROSE_ITEM_LIMIT - 3]}..."
2051
+ end
2052
+
2053
+ # The name is truncated by whole escapes, never through one: cutting the
2054
+ # escaped text at a fixed width could print a dangling backslash, or half
2055
+ # of an escape. When the name itself is cut, the "..." goes outside the
2056
+ # closing quote, so the quotes still delimit exactly what is shown. A
2057
+ # quoted name that fits the limit on its own is never cut: the suffix is
2058
+ # cut instead, to whatever room is left — possibly none — and the "..."
2059
+ # that marks it may then run up to three characters past the limit. A
2060
+ # dropped developer suffix is better flagged than hidden, and the name is
2061
+ # the part a reader is there for. Only as many characters are escaped as
2062
+ # can be shown.
2063
+ def permittable_prose_quoted(text, more, suffix)
2064
+ budget = PROSE_ITEM_LIMIT - 2 # the two quotes
2065
+ pieces = []
2066
+ length = 0
2067
+ text.each_char do |char|
2068
+ pieces << permittable_prose_escape(char)
2069
+ length += pieces.last.length
2070
+ break if length > budget
2071
+ end
2072
+ if !more && length <= budget
2073
+ quoted = "\"#{pieces.join}\""
2074
+ return "#{quoted}#{suffix}" if quoted.length + suffix.length <= PROSE_ITEM_LIMIT
2075
+
2076
+ return "#{quoted}#{suffix[0, [PROSE_ITEM_LIMIT - 3 - quoted.length, 0].max]}..."
2077
+ end
2078
+
2079
+ length -= pieces.pop.length while length > budget - 3
2080
+ "\"#{pieces.join}\"..."
2081
+ end
2082
+
2083
+ # A byte that is not valid UTF-8 is shown as \xNN rather than passed
2084
+ # through: it is not a character a person can read, and a lone 0x85 or
2085
+ # 0x9B is NEL or CSI to a Latin-1 terminal. A character beyond the BMP
2086
+ # (the Cf tag characters) is \u{XXXXX}, since \uXXXX holds only four digits.
2087
+ def permittable_prose_escape(char)
2088
+ return char.bytes.map { |byte| format('\x%02X', byte) }.join unless char.valid_encoding?
2089
+
2090
+ PROSE_ESCAPES.fetch(char) do
2091
+ next char unless char.match?(PROSE_UNSAFE)
2092
+
2093
+ char.ord > 0xFFFF ? format('\u{%X}', char.ord) : format('\u%04X', char.ord)
2094
+ end
2095
+ end
2096
+
2097
+ # PROSE_UNSAFE is a UTF-8 pattern, and matching it against a binary key
2098
+ # with high bytes raises Encoding::CompatibilityError — a log line must
2099
+ # never be what fails a request. A binary key has no charset to convert
2100
+ # from, and Rack hands UTF-8 bytes over as binary, so it is read as UTF-8.
2101
+ # A key in a real encoding is transcoded character by character: what
2102
+ # maps is converted, and only a byte that does not (Windows-1252 leaves
2103
+ # 0x81, 0x8D, 0x8F, 0x90 and 0x9D undefined) is kept as an invalid byte,
2104
+ # which the escaper then shows as \xNN — rather than reading the whole
2105
+ # key as UTF-8, which turned a mappable é into \xE9 and mojibake into
2106
+ # characters the client never sent.
2107
+ def permittable_prose_utf8(item)
2108
+ return item if item.encoding == Encoding::UTF_8
2109
+ return item.dup.force_encoding(Encoding::UTF_8) if item.encoding == Encoding::BINARY || item.ascii_only?
2110
+
2111
+ converter = Encoding::Converter.new(item.encoding, Encoding::UTF_8)
2112
+ source = item.dup
2113
+ out = String.new(encoding: Encoding::UTF_8)
2114
+ out << converter.primitive_errinfo[3].force_encoding(Encoding::UTF_8) until converter.primitive_convert(source, out) == :finished
2115
+ out
2116
+ rescue EncodingError # no converter, as for a dummy encoding such as UTF-7
2117
+ item.dup.force_encoding(Encoding::UTF_8)
1399
2118
  end
1400
2119
 
1401
2120
  # One violation detail entry. A field's `message:` (String, or Hash keyed
@@ -1495,7 +2214,7 @@ module Permittable
1495
2214
  # Already normalized by permittable_normalized, before the absence rule.
1496
2215
  permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
1497
2216
  when :json
1498
- permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
2217
+ permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
1499
2218
  when :nested
1500
2219
  if value.is_a?(Hash)
1501
2220
  result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
@@ -1512,14 +2231,27 @@ module Permittable
1512
2231
  end
1513
2232
  end
1514
2233
 
2234
+ # Coercion.check_json, with the copy the result needs made in the middle.
2235
+ # The opaque hash is handed over whole, and HashWithIndifferentAccess
2236
+ # rebuilt its containers but not the Strings inside them, so it is
2237
+ # deep-copied for the reason permittable_normalized copies a String — but
2238
+ # only once it is within its bounds. Copying first meant a megabyte
2239
+ # payload refused on `length:` or `max_depth:` was copied in full just to
2240
+ # be refused, undoing the early exit those bounds exist for.
2241
+ def permittable_check_json(field, value)
2242
+ status, code = Coercion.check_json_bounds(field, value)
2243
+ return [status, code] unless status == :ok
2244
+
2245
+ Coercion.check_custom(field[:validate], value.deep_dup)
2246
+ end
2247
+
1515
2248
  # The shared tail of the two kinds whose entire value is checked in one
1516
2249
  # call — a scalar, or an opaque hash. A clean value is transformed into the
1517
2250
  # result; anything else records its code.
1518
2251
  def permittable_check_whole(field, outcome, full, result, violations:)
1519
2252
  status, out = outcome
1520
2253
  if status == :ok
1521
- out = field[:transform].call(out) if field[:transform]
1522
- result[field[:name].to_s] = out
2254
+ result[field[:name].to_s] = permittable_transform(field, out)
1523
2255
  else
1524
2256
  violations << permittable_violation(field, full, out)
1525
2257
  end
@@ -1541,14 +2273,40 @@ module Permittable
1541
2273
  out = value.each_with_index.map do |element, index|
1542
2274
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
1543
2275
  end
1544
- if field[:validate]
2276
+ # validate: and transform: see only a fully-valid array. A partially-nil
2277
+ # one (element violations) would hand user code garbage it never agreed
2278
+ # to see — and for validate: that was a crash, not just garbage:
2279
+ # `validate: ->(a) { a.sum < 100 }` sent `["x", 2]` raised TypeError on
2280
+ # the nil where "x" failed to cast, turning the element's 422 into a 500.
2281
+ #
2282
+ # The cost is real and accepted: the whole-array verdict is no longer
2283
+ # reported ALONGSIDE element violations. `[1, "x", 1]` against a
2284
+ # uniqueness validator reports only `ids[1]`; the client fixes it,
2285
+ # resends, and only then learns of the duplicate. Running app code over
2286
+ # nils it never agreed to handle is the worse failure.
2287
+ #
2288
+ # An undeclared key inside an element (`unknown: :error`) is not such a
2289
+ # violation: it removes nothing from the element validate: sees, so it
2290
+ # does not stop validate: from running.
2291
+ #
2292
+ # transform: is stricter, as on the scalar path: it runs only when
2293
+ # NOTHING violated, validate: included — a transform may rely on what
2294
+ # validate: checked (`Math.sqrt` after "all positive").
2295
+ elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
2296
+ if field[:validate] && elements_valid
1545
2297
  status, code = Coercion.check_custom(field[:validate], out)
1546
2298
  violations << permittable_violation(field, path, code) unless status == :ok
1547
2299
  end
1548
2300
  # Transform only a fully-valid array — a partially-nil one (element
1549
2301
  # 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
2302
+ violations.length == before ? permittable_transform(field, out) : out
2303
+ end
2304
+
2305
+ # The one place a field's `transform:` is applied — a seam, so that
2306
+ # AuthoredValues can walk an authored default through this same walker
2307
+ # without running app code over it at class load.
2308
+ def permittable_transform(field, value)
2309
+ field[:transform] ? field[:transform].call(value) : value
1552
2310
  end
1553
2311
 
1554
2312
  def permittable_check_element(field, element, path, unknown:, violations:)
@@ -1561,7 +2319,7 @@ module Permittable
1561
2319
  path: path, unknown: unknown, top_level: false, violations: violations)
1562
2320
  end
1563
2321
 
1564
- status, out = Coercion.cast(field[:of], element)
2322
+ status, out = Coercion.cast(field[:of], permittable_own(element))
1565
2323
  return out if status == :ok
1566
2324
 
1567
2325
  violations << permittable_violation(field, path, out)
@@ -1575,18 +2333,34 @@ module Permittable
1575
2333
  # corruption strict coercion exists to refuse, delivered by the gem's own
1576
2334
  # preset. Only scalars take normalize:, and apply_normalize is itself a
1577
2335
  # no-op without one, so it owns that decision for every caller.
2336
+ #
2337
+ # It is also where a request's String stops being the request's. Nothing
2338
+ # the walker hands to app code — `normalize:`, `validate:`, `transform:`,
2339
+ # the result — may alias the caller's params, or `permitted_params[:name]
2340
+ # << "x"` (or `normalize: ->(v) { v.strip! || v }`) rewrites the caller's
2341
+ # Hash or ActionController::Parameters behind the app's back. Copying at
2342
+ # the walker's input, ahead of normalize:, makes it one copy per String;
2343
+ # String#dup shares a long String's buffer copy-on-write, so the copy is
2344
+ # cheap until someone writes to it. `of:` elements get the same treatment
2345
+ # in permittable_check_element, and a :json hash in permittable_check_json.
1578
2346
  def permittable_normalized(field, value)
1579
- Coercion.apply_normalize(field[:normalize], value)
2347
+ Coercion.apply_normalize(field[:normalize], permittable_own(value))
2348
+ end
2349
+
2350
+ def permittable_own(value)
2351
+ value.is_a?(String) ? value.dup : value
1580
2352
  end
1581
2353
 
1582
2354
  # 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
2355
+ # ContractBuilder#freeze_authored), so every request gets a deep copy of it.
2356
+ # Copying only a top-level String was not enough: HashWithIndifferentAccess
2357
+ # copies a frozen Array or Hash as it assigns it, but not what is INSIDE
2358
+ # one, so `permitted_params[:tags].first << "x"` on an `of: :string`
2359
+ # default — or any edit to a String in a :json default — raised
2360
+ # FrozenError. A deep copy leaves every value in the result the app's own
1586
2361
  # to mutate.
1587
2362
  def permittable_default(field)
1588
- value = field[:default]
1589
- value.is_a?(String) ? value.dup : value
2363
+ field[:default].deep_dup
1590
2364
  end
1591
2365
 
1592
2366
  # nil and "" are both ABSENT — see the module comment.
@@ -1608,21 +2382,105 @@ module Permittable
1608
2382
 
1609
2383
  declared = fields.map { |f| f[:name].to_s }
1610
2384
  extra = hash.keys.map(&:to_s) - declared
1611
- extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
2385
+ if top_level
2386
+ extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
2387
+ extra -= [permittable_configured_csrf_key].compact
2388
+ end
1612
2389
  return if extra.empty?
1613
2390
 
1614
2391
  if unknown == :error
1615
- extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
2392
+ extra.each { |key| violations << permittable_unknown_key_violation(path, key) }
1616
2393
  elsif respond_to?(:logger) && logger
1617
- listed = permittable_prose_list(extra) { |key| permittable_path(path, key) }
2394
+ listed = permittable_prose_list(extra) { |key| permittable_path(path, permittable_prose_utf8(key)) }
1618
2395
  logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
1619
2396
  end
1620
2397
  end
1621
2398
 
2399
+ # The top-level keys THIS request's router put into params, beyond the
2400
+ # fixed UNCHECKED_TOP_LEVEL_KEYS: whatever it matched out of the URL
2401
+ # (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a
2402
+ # path parameter, not a body field). A controller only — a plain params
2403
+ # duck and a standalone Contract have no request, so they exempt nothing
2404
+ # extra. Subtracted from the undeclared keys, so a contract that DECLARES
2405
+ # `id` still has that field checked like any other: the value is real, the
2406
+ # URL carried it.
2407
+ def permittable_request_supplied_keys
2408
+ return [] unless respond_to?(:request) && request.respond_to?(:path_parameters)
2409
+
2410
+ request.path_parameters.keys.map(&:to_s)
2411
+ end
2412
+
2413
+ # FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
2414
+ # name — but `config.action_controller.request_forgery_protection_token`
2415
+ # lets an app rename it, and an app that does trips `unknown: :error` on
2416
+ # every ordinary form submission: exactly the bug FORM_KEYS exists to
2417
+ # prevent, just spelled with the app's own key instead of the default one.
2418
+ # The configured name isn't knowable at class-load time (it can vary per
2419
+ # controller, and Rails may not have finished initializing yet), so it's
2420
+ # read fresh here off the live controller instead of folded into a frozen
2421
+ # constant. `request_forgery_protection_token` comes from
2422
+ # ActionController::RequestForgeryProtection, included by ActionController
2423
+ # ::Base; a plain params duck or a standalone Contract has no such method
2424
+ # and exempts nothing beyond FORM_KEYS's own "authenticity_token".
2425
+ def permittable_configured_csrf_key
2426
+ return nil unless respond_to?(:request_forgery_protection_token)
2427
+
2428
+ token = request_forgery_protection_token
2429
+ token && token.to_s
2430
+ end
2431
+
2432
+ # A rootless contract's input without ParamsWrapper's copy of the body,
2433
+ # when Rails made one (see process_action). Removed rather than merely
2434
+ # exempted from the unknown-keys check, because the client never sent that
2435
+ # key: a contract that happens to declare a scalar or array field of the
2436
+ # wrapper's name (`optional :feedback, :string` on FeedbackController)
2437
+ # would otherwise validate Rails' copy of the whole body as that field — a
2438
+ # false 422 invalid_type for a well-formed request.
2439
+ #
2440
+ # Kept, though, when the contract declares that key as a hash container (a
2441
+ # nested block or :json): that rootless contract is reading the copy ON
2442
+ # PURPOSE, a root: spelled as a field, and it worked that way before the
2443
+ # copy was ever dropped — dropping it would turn every such request into
2444
+ # `user missing`. Top level only, where the copy lives; a rooted contract
2445
+ # reads the copy as its root, which is exactly what ParamsWrapper is for.
2446
+ # What is checked changes, not what monitor mode hands back: its raw
2447
+ # pass-through still carries the copy, as the pre-contract app's params did.
2448
+ def permittable_without_wrapper_copy(fields, source)
2449
+ key = @permittable_wrapper_key
2450
+ return source unless key
2451
+ return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
2452
+
2453
+ source.except(key)
2454
+ end
2455
+
1622
2456
  def permittable_path(path, key)
1623
2457
  path ? "#{path}.#{key}" : key
1624
2458
  end
1625
2459
 
2460
+ # The one place a CLIENT's key enters a path, so the only one converted to
2461
+ # reportable UTF-8 (see Coercion.reportable_text) — every declared key a
2462
+ # request walks through is the contract's own name and is left alone.
2463
+ #
2464
+ # The entry is also remembered by identity, which is how
2465
+ # permittable_check_array tells an undeclared key from a sub-field that
2466
+ # failed. The code alone cannot: a sub-field's validate: may itself
2467
+ # return :unknown.
2468
+ def permittable_unknown_key_violation(path, key)
2469
+ entry = permittable_violation({}, permittable_path(path, Coercion.reportable_text(key)), "unknown")
2470
+ (@permittable_unknown_key_violations ||= {}.compare_by_identity)[entry] = true
2471
+ # Prose (the exception message) gets the richer transcoding instead of
2472
+ # `param:`'s scrubbed-to-U+FFFD text — see permittable_violation_summary.
2473
+ # permittable_prose_utf8, not Coercion.reportable_text, is what keeps the
2474
+ # concatenation with `path` (the contract's own UTF-8 field names) from
2475
+ # raising Encoding::CompatibilityError, the same as the :log line below.
2476
+ (@permittable_unknown_key_raw ||= {}.compare_by_identity)[entry] = permittable_path(path, permittable_prose_utf8(key))
2477
+ entry
2478
+ end
2479
+
2480
+ def permittable_unknown_key_violation?(entry)
2481
+ @permittable_unknown_key_violations&.key?(entry) || false
2482
+ end
2483
+
1626
2484
  def permittable_action_name
1627
2485
  respond_to?(:action_name) && action_name ? action_name.to_s : nil
1628
2486
  end
@@ -1634,6 +2492,10 @@ module Permittable
1634
2492
  end
1635
2493
  end
1636
2494
 
2495
+ # Class-load reading of an authored array default:/example: — the request
2496
+ # walker itself, so it needs the concern's body loaded.
2497
+ require "permittable/authored_values"
2498
+
1637
2499
  # Contract exporters — the other readers of the frozen contract registry.
1638
2500
  # Loaded after the module body so OpenAPI can see the concern's own methods.
1639
2501
  require "permittable/json_schema"
@@ -1646,6 +2508,13 @@ require "permittable/generator"
1646
2508
  # Standalone contracts — the same DSL callable on any Hash, no controller.
1647
2509
  require "permittable/contract"
1648
2510
 
2511
+ # Contract COVERAGE — the registry crossed with the route set, so a
2512
+ # half-covered controller is as visible as an uncovered one.
2513
+ require "permittable/audit"
2514
+
2515
+ # Reusable field lists — `Permittable.fields` + the builder's `use` verb.
2516
+ require "permittable/field_group"
2517
+
1649
2518
  # Boot-time integration (filter_parameters registration, the
1650
2519
  # permittable:openapi rake task), Rails apps only
1651
2520
  require "permittable/railtie" if defined?(Rails::Railtie)