permittable 0.6.0 → 0.8.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
@@ -4,8 +4,21 @@ require "active_support/notifications"
4
4
  require "active_support/hash_with_indifferent_access"
5
5
  require "active_support/core_ext/hash/indifferent_access" # nested plain Hashes inside HWIA.new
6
6
  require "active_support/core_ext/class/attribute"
7
+ require "active_support/core_ext/object/deep_dup" # authored default:/example: values are copied before freezing
7
8
  require "active_support/core_ext/string/inflections"
8
9
  require "active_support/core_ext/string/filters"
10
+ # cast_datetime names ActiveSupport::TimeWithZone, which activesupport does not
11
+ # load by default. A Rails app has it via active_support/time at boot; a
12
+ # standalone host (a Contract validating a webhook payload or a job argument)
13
+ # has nothing that loads it, and every :datetime cast raised NameError there.
14
+ #
15
+ # The Time core extensions come with it, and are not optional: TimeWithZone is
16
+ # present but not self-sufficient. Converting one goes through
17
+ # TimeZone#utc_to_local, which calls Time#sec_fraction — defined in
18
+ # core_ext/time/calculations, which time_with_zone.rb does not itself require.
19
+ # Without this line a real TimeWithZone raises NoMethodError in a bare host on
20
+ # activesupport 8.1, having merely traded one crash for another.
21
+ require "active_support/core_ext/time/calculations"
9
22
  require "bigdecimal"
10
23
  require "date"
11
24
  require "time"
@@ -98,7 +111,12 @@ require "permittable/filter_parameter_registry"
98
111
  # guess. nil and "" are both treated as ABSENT (the query-param convention):
99
112
  # absent optional fields are OMITTED from the result (so partial updates never
100
113
  # nil-out columns), absent required fields violate, and `default:` fills
101
- # absence.
114
+ # absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
115
+ # there is exactly one reading of absence and a value that normalizes to empty
116
+ # (" " 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.
102
120
  #
103
121
  # `nullable: true` splits that rule in two for one field, which is how a PATCH
104
122
  # clears a column: a key the client never sent stays absent (defaults apply,
@@ -127,7 +145,19 @@ require "permittable/filter_parameter_registry"
127
145
  # `sensitive: true` registers the field name with
128
146
  # Permittable.filter_parameter_registry (swappable — a host gem can point it
129
147
  # at its own registry), consulted at filter time by the proc
130
- # Permittable::Railtie appends to `config.filter_parameters`.
148
+ # Permittable::Railtie appends to `config.filter_parameters`. The name is
149
+ # ALSO published to that Railtie by name (see register_sensitive_parameter),
150
+ # because a proc filter can only redact String values — ActiveSupport dups
151
+ # the value and expects in-place mutation, and never calls the proc at all
152
+ # for a Hash — so a name in config.filter_parameters is what covers an
153
+ # :integer field or a sensitive nested block.
154
+ # Permittable::Railtie appends to `config.filter_parameters`. On a nested or
155
+ # array field it CASCADES to every field inside, because Rails' filtering
156
+ # asks about the leaf key it is looking at rather than the path to it; a
157
+ # sub-field opts out with `sensitive: false`, since matching is a substring
158
+ # match and a generic cascaded name would redact half the app's logs. The
159
+ # cascade is resolved onto the field data at class load — see
160
+ # ContractBuilder#cascade_sensitive.
131
161
  #
132
162
  # OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
133
163
  # Two layers, both operating on the validated COPY (the request's `params` is
@@ -162,6 +192,40 @@ module Permittable
162
192
  # Rails merges routing bookkeeping into params; a top-level (root: false)
163
193
  # unknown-keys check must not flag them.
164
194
  ROUTING_KEYS = %w[controller action format].freeze
195
+ # Nor the keys an ordinary form POST carries — the CSRF token, the verb
196
+ # override, the encoding probe, and the submit button's name. Without this
197
+ # `unknown: :error` was unusable outside a JSON API: every browser form
198
+ # failed on the framework's own keys rather than on anything the client got
199
+ # wrong. Exempt from the CHECK only: unlike the routing keys these are NOT
200
+ # stripped from monitor mode's raw pass-through, where handing back an
201
+ # untouched params hash is the whole promise and a legacy action may well
202
+ # read `_method` itself.
203
+ FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze
204
+ # ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
205
+ # is DECIDED with them, which is what the call sites care about — and the
206
+ # asymmetry between them is the deliberate point, so spell it once here
207
+ # rather than leaving a bare ROUTING_KEYS to read like an oversight.
208
+ UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
209
+ MONITOR_DROPPED_KEYS = ROUTING_KEYS
210
+ # A log line and an exception message are PROSE, written for a person. They
211
+ # list at most this many names and count the rest, so one request cannot
212
+ # write a megabyte of them. The machine-readable channels — a violation's
213
+ # `details` and the instrumentation payload — stay complete; only the
214
+ # sentence is bounded.
215
+ PROSE_LIST_LIMIT = 10
216
+ # ...and each name it does list is truncated. Capping the COUNT alone still
217
+ # let ONE 1 MB key name write the 1 MB log line the cap exists to prevent.
218
+ PROSE_ITEM_LIMIT = 120
219
+
220
+ # The single proc Permittable::Railtie appends to config.filter_parameters.
221
+ # Declared with an optional third parameter so its own arity is -3 and Rails
222
+ # passes `original_params`; the registry's callable is then invoked by ITS
223
+ # arity, so both the 2- and 3-argument proc-filter shapes Rails accepts work
224
+ # as a swapped-in registry's #to_proc.
225
+ FILTER_PARAMETER_PROC = lambda do |key, value, original = nil|
226
+ inner = filter_parameter_registry.to_proc
227
+ inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
228
+ end.freeze
165
229
 
166
230
  NORMALIZERS = {
167
231
  squish: ->(v) { v.squish },
@@ -183,7 +247,84 @@ module Permittable
183
247
  end
184
248
  end
185
249
 
186
- attr_writer :filter_parameter_registry
250
+ # Swapping registries must not un-redact anything. Contracts that loaded
251
+ # BEFORE the swap registered on the outgoing registry, and after the swap
252
+ # nothing consults it any more — so its entries are carried into the new
253
+ # one, which is the mirror image of the bug that made the proc late-bound
254
+ # in the first place. Validated here rather than at filter time: a
255
+ # registry with no #to_proc used to be silently never consulted, and
256
+ # late-binding it would instead raise NoMethodError on every request.
257
+ def filter_parameter_registry=(registry)
258
+ unless registry.nil? || registry.respond_to?(:to_proc)
259
+ raise ArgumentError,
260
+ "#{LABEL}: filter_parameter_registry must respond to #to_proc (got #{registry.class})"
261
+ end
262
+
263
+ @registry_mutex.synchronize do
264
+ previous = @filter_parameter_registry
265
+ @filter_parameter_registry = registry
266
+ next unless registry && previous.respond_to?(:names) && registry.respond_to?(:add)
267
+
268
+ previous.names.each { |name| registry.add(name) }
269
+ end
270
+ registry
271
+ end
272
+
273
+ # The proc Permittable::Railtie appends to config.filter_parameters.
274
+ #
275
+ # It resolves the registry at FILTER time rather than closing over
276
+ # whichever instance existed at boot. Rails runs railtie initializers
277
+ # BEFORE config/initializers, so an app or host gem that swaps the
278
+ # registry — the pooling the writer exists for — necessarily does so
279
+ # after the Railtie has already appended its proc. A proc bound to the old
280
+ # instance would go on consulting an empty registry and silently redact
281
+ # nothing, while `sensitive:` fields registered themselves in the new one.
282
+ #
283
+ # One frozen object for the life of the process, so the Railtie's
284
+ # idempotence check (include? before <<) holds across repeated initializer
285
+ # runs with no memo to synchronise.
286
+ def filter_parameter_proc
287
+ FILTER_PARAMETER_PROC
288
+ end
289
+
290
+ # Every `sensitive:` registration, published to whatever is listening.
291
+ #
292
+ # A proc filter cannot be the whole mechanism: ActiveSupport's
293
+ # ParameterFilter dups the value and expects in-place mutation, so a proc
294
+ # can only redact Strings — and it is never even CALLED for a Hash value,
295
+ # because ParameterFilter checks `value.is_a?(Hash)` first and recurses.
296
+ # So `optional :pin, :integer, sensitive: true` and `sensitive:` on a
297
+ # nested block both logged in the clear. What redacts any value type is a
298
+ # NAME in config.filter_parameters, which only Rails can be told about —
299
+ # hence a sink, installed by Permittable::Railtie, rather than Rails
300
+ # knowledge in this file or in the registry.
301
+ def register_sensitive_parameter(name)
302
+ filter_parameter_registry.add(name)
303
+ # Normalized the way the registry normalizes, so the name a sink sees is
304
+ # the same whether it arrives here or through on_sensitive_parameter's
305
+ # replay of #names — otherwise a sink deduplicating by value would hold
306
+ # both :ssn and "ssn".
307
+ name = name.to_s.downcase
308
+ sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
309
+ nil
310
+ end
311
+
312
+ # Install a sink. It is replayed over the names already registered, since
313
+ # a contract can be declared before the Railtie's initializer runs (a
314
+ # Permittable::Contract at require time, an eager-loaded controller) and
315
+ # would otherwise never reach it.
316
+ def on_sensitive_parameter(&sink)
317
+ @registry_mutex.synchronize { sensitive_parameter_sinks << sink }
318
+ registry = filter_parameter_registry
319
+ registry.names.each { |name| sink.call(name) } if registry.respond_to?(:names)
320
+ sink
321
+ end
322
+
323
+ # The installed sinks. Process-global, like the registry — specs that
324
+ # install one `.clear` this afterwards.
325
+ def sensitive_parameter_sinks
326
+ @sensitive_parameter_sinks ||= []
327
+ end
187
328
 
188
329
  # App-wide default for rules that don't declare their own mode:.
189
330
  # :enforce (the default) rejects violating requests; :monitor reports
@@ -250,20 +391,34 @@ module Permittable
250
391
  TRUE_VALUES = [true, "true", "1", 1].freeze
251
392
  FALSE_VALUES = [false, "false", "0", 0].freeze
252
393
 
253
- # Full pipeline for one scalar field: normalize → cast → in / format /
254
- # length / validate.
394
+ # Pipeline for one scalar field: cast → in / format / length / validate.
395
+ # `normalize:` is NOT applied here — it is its own stage, run by the
396
+ # caller before the absence rule (a value that normalizes to "" is absent
397
+ # like any other empty value), so normalizing again here would call a
398
+ # host's `normalize:` proc twice per value.
255
399
  def check_scalar(field, value)
256
- value = apply_normalize(field[:normalize], value)
257
400
  status, value = cast(field[:type], value)
258
401
  return [status, value] unless status == :ok
259
402
 
260
403
  check_scalar_rules(field, value)
261
404
  end
262
405
 
406
+ # `length:` first, deliberately. It is an O(1) read of a String's size,
407
+ # while `format:` runs a regexp over the whole value and `validate:` runs
408
+ # arbitrary app code — so checking the cheap bound last meant a value the
409
+ # bound already excluded still paid for the expensive ones. A 5 MB string
410
+ # against `length: 1..80` scanned all 5 MB with the field's regexp before
411
+ # being rejected on its length, and an app regexp with poor worst-case
412
+ # behaviour turns that from waste into a lever.
413
+ #
414
+ # The only observable change is which code a value violating BOTH reports:
415
+ # `length` now, rather than `inclusion`/`format`. Reporting the structural
416
+ # failure first is the better answer anyway — a client cannot act on
417
+ # "wrong format" for a value that is also far too long.
263
418
  def check_scalar_rules(field, value)
419
+ return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
264
420
  return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
265
421
  return [:error, "format"] if field[:format] && !field[:format].match?(value)
266
- return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
267
422
 
268
423
  check_custom(field[:validate], value)
269
424
  end
@@ -342,23 +497,54 @@ module Permittable
342
497
 
343
498
  def cast_float(value)
344
499
  case value
345
- when Numeric then [:ok, value.to_f]
346
- when String then [:ok, Float(value)]
500
+ when Numeric then finite_float(value.to_f)
501
+ when String then finite_float(Float(value), source: value)
347
502
  else [:error, "invalid_type"]
348
503
  end
349
504
  rescue ArgumentError
350
505
  [:error, "invalid_type"]
351
506
  end
352
507
 
508
+ # A Float that is not finite does not represent what was sent. "1e400"
509
+ # overflows to Infinity and "1e-400" underflows to zero — both silently,
510
+ # and both leaving a value no column can faithfully store.
511
+ #
512
+ # Underflow is only visible against the source text, since the result is
513
+ # an ordinary 0.0: a zero result is rejected when the string it came from
514
+ # named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
515
+ # genuine zero whose exponent digits say nothing about the value — as are
516
+ # "0", "0.0" and "0.0000".
517
+ def finite_float(result, source: nil)
518
+ return [:error, "invalid_type"] unless result.finite?
519
+ return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
520
+
521
+ [:ok, result]
522
+ end
523
+
524
+ def nonzero_significand?(source)
525
+ return false unless source
526
+
527
+ source.split(/[eE]/, 2).first.match?(/[1-9]/)
528
+ end
529
+
353
530
  def cast_decimal(value)
354
531
  case value
355
- when Numeric, String then [:ok, BigDecimal(value.to_s)]
532
+ when Numeric, String then finite_decimal(BigDecimal(value.to_s))
356
533
  else [:error, "invalid_type"]
357
534
  end
358
535
  rescue ArgumentError
359
536
  [:error, "invalid_type"]
360
537
  end
361
538
 
539
+ # BigDecimal has no exponent limit, so a :decimal cannot overflow — but
540
+ # BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
541
+ # raises, so a client could send the literal string "NaN" for a price and
542
+ # have it stored. Nothing else in the gem disagreed with itself this
543
+ # loudly: :float rejected those strings and :decimal did not.
544
+ def finite_decimal(result)
545
+ result.finite? ? [:ok, result] : [:error, "invalid_type"]
546
+ end
547
+
362
548
  def cast_boolean(value)
363
549
  return [:ok, true] if TRUE_VALUES.include?(value)
364
550
  return [:ok, false] if FALSE_VALUES.include?(value)
@@ -402,7 +588,12 @@ module Permittable
402
588
  def cast_datetime(value)
403
589
  case value
404
590
  # DateTime is listed here, ahead of Date, because it subclasses Date.
405
- when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
591
+ # `getutc` rather than `utc`: `Time#utc` converts the RECEIVER, and
592
+ # `Time#to_time` returns self, so `value.to_time.utc` silently rewrote
593
+ # the caller's own object. A TimeWithZone's `getutc` hands back the
594
+ # instance it caches internally, so that one is duped.
595
+ when ActiveSupport::TimeWithZone then [:ok, value.getutc.dup]
596
+ when Time, DateTime then [:ok, value.to_time.getutc]
406
597
  when Date then [:ok, Time.utc(value.year, value.month, value.day)]
407
598
  when String
408
599
  # Same rule as :date — the DATE part must be named in full, or it is
@@ -429,6 +620,14 @@ module Permittable
429
620
  normalizer.call(value)
430
621
  end
431
622
 
623
+ # nil and "" are both ABSENT — see the module comment. The VALUE half of
624
+ # that rule (the walker adds the key-presence half), shared with
625
+ # macro-time `default:`/`example:` checking so a default cannot be held
626
+ # to a different reading of absence than the request it stands in for.
627
+ def absent_value?(value)
628
+ value.nil? || (value.is_a?(String) && value.empty?)
629
+ end
630
+
432
631
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
433
632
  # and the right semantics for validation.
434
633
  def included_in?(allowed, value)
@@ -495,7 +694,7 @@ module Permittable
495
694
  if block
496
695
  raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
497
696
 
498
- field[:fields] = nested_fields!(name, &block)
697
+ field[:fields] = cascade_sensitive(nested_fields!(name, &block), field[:sensitive])
499
698
  field.delete(:of)
500
699
  else
501
700
  field[:of] = scalar_type!(name, opts[:of] || :string)
@@ -519,6 +718,7 @@ module Permittable
519
718
  assert_opts!(name, opts, NESTED_OPTS)
520
719
  field = { name: name, kind: :nested, required: required,
521
720
  fields: nested_fields!(name, &block), **opts }
721
+ field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
522
722
  validate_message!(field)
523
723
  elsif type&.to_sym == JSON_TYPE
524
724
  assert_opts!(name, opts, JSON_OPTS)
@@ -570,17 +770,60 @@ module Permittable
570
770
  fields
571
771
  end
572
772
 
773
+ # `sensitive: true` on a nested or array field CASCADES to every field
774
+ # inside it, and the cascade is resolved HERE, at class load, so that
775
+ # `field[:sensitive]` stays the single source of truth every reader
776
+ # consults: the filter registry, the exported schema's `writeOnly`, and
777
+ # the RSpec matcher's `.sensitive` chain. Resolving it privately inside
778
+ # the registry walk would have redacted a cascaded child at runtime
779
+ # while the schema and the matcher went on calling it public.
780
+ #
781
+ # It has to cascade: ActiveSupport::ParameterFilter recurses into Hash
782
+ # and Array values itself and consults proc filters only for the LEAVES,
783
+ # handing each one the leaf's own key and never the path that led there.
784
+ # So registering only `payment` is asked about `card_number`, which it
785
+ # does not match, and redacts nothing inside the container.
786
+ #
787
+ # A sub-field opts out with an explicit `sensitive: false`, because
788
+ # matching is a case-insensitive SUBSTRING match and cascading a generic
789
+ # name (:id, :name) would redact every parameter app-wide that contains
790
+ # it. Only `false` opts out; `sensitive: nil` reads as "not stated" and
791
+ # still inherits.
792
+ def cascade_sensitive(fields, inherited)
793
+ updated = fields.map { |field| cascade_field_sensitive(field, inherited) }
794
+ updated.zip(fields).all? { |new_field, old| new_field.equal?(old) } ? fields : updated.freeze
795
+ end
796
+
797
+ def cascade_field_sensitive(field, inherited)
798
+ declared = field[:sensitive]
799
+ effective = declared.nil? ? inherited : declared
800
+ children = field[:fields] ? cascade_sensitive(field[:fields], effective) : nil
801
+ unchanged = (effective ? declared == true : declared == false || !field.key?(:sensitive)) &&
802
+ (children.nil? || children.equal?(field[:fields]))
803
+ return field if unchanged
804
+
805
+ updated = field.merge(sensitive: effective)
806
+ updated[:fields] = children if children
807
+ updated.freeze
808
+ end
809
+
573
810
  def validate_scalar_opts!(field)
574
811
  name = field[:name]
575
812
  if field[:required] && field.key?(:default)
576
813
  raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
577
814
  end
578
- if field.key?(:in) && !field[:in].respond_to?(:include?)
579
- raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
815
+
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])
580
822
  end
581
823
 
582
824
  validate_string_only_opts!(field)
583
825
  validate_length!(name, field[:length]) if field.key?(:length)
826
+ validate_required_length!(field)
584
827
  validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
585
828
  validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
586
829
  resolve_normalizer!(field)
@@ -618,9 +861,9 @@ module Permittable
618
861
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
619
862
 
620
863
  status, code = Coercion.check_json(field, field[opt])
621
- return if status == :ok
864
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
622
865
 
623
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
866
+ field[opt] = freeze_authored(field[opt])
624
867
  end
625
868
 
626
869
  # format / length / normalize reason about characters; on any other
@@ -636,9 +879,49 @@ module Permittable
636
879
  end
637
880
 
638
881
  def validate_length!(name, length)
639
- return if length.is_a?(Range) || length.is_a?(Integer)
882
+ unless length.is_a?(Range) || (length.is_a?(Integer) && !length.negative?)
883
+ raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
884
+ "(got #{length.inspect})"
885
+ end
886
+
887
+ assert_satisfiable!(name, :length, length)
888
+ end
889
+
890
+ # A reversed Range (5..2), an exclusive Range with equal endpoints
891
+ # (3...3), or an empty set (in: []) excludes every value there is, so the
892
+ # field it bounds can never validate. That used to surface as every
893
+ # request to the action failing on that field — a contract mistake
894
+ # reported as a client error, once per request, forever. Endless and
895
+ # beginless Ranges are legitimate bounds, and endpoints that cannot be
896
+ # compared are left alone rather than guessed at.
897
+ def assert_satisfiable!(name, opt, bound)
898
+ return unless unsatisfiable?(bound)
640
899
 
641
- raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer"
900
+ raise ArgumentError, "#{LABEL}: :#{opt} for :#{name} is empty (#{bound.inspect}) — no value can satisfy it"
901
+ end
902
+
903
+ def unsatisfiable?(bound)
904
+ return bound.empty? if bound.respond_to?(:empty?)
905
+ return false unless bound.is_a?(Range) && bound.begin && bound.end
906
+
907
+ comparison = bound.begin <=> bound.end
908
+ return false if comparison.nil?
909
+
910
+ bound.exclude_end? ? !comparison.negative? : comparison.positive?
911
+ end
912
+
913
+ # "" is ABSENT and an absent required field violates as missing, so a
914
+ # required string can never validly be empty: a maximum length of 0
915
+ # leaves it nothing at all to accept. The exported schema already said
916
+ # so — minLength 1 alongside maxLength 0 — while nothing refused the
917
+ # declaration that produced it.
918
+ def validate_required_length!(field)
919
+ spec = field[:length]
920
+ return unless field[:required] && spec
921
+ return unless Coercion.length_ok?(spec, 0) && !Coercion.length_ok?(spec, 1)
922
+
923
+ raise ArgumentError, "#{LABEL}: :length for :#{field[:name]} is 0 on a required field — an absent or " \
924
+ "empty value already violates as missing, so nothing could satisfy it"
642
925
  end
643
926
 
644
927
  def validate_callable!(name, opt, value)
@@ -661,22 +944,78 @@ module Permittable
661
944
  # An authored value (`default:`, or a documentation `example:`) must
662
945
  # satisfy the field's own contract — catching a lie at class load beats
663
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 ".
664
950
  def validate_authored_value!(field, opt)
665
951
  return unless field.key?(opt)
666
952
  return if authored_nil!(field, opt)
667
953
 
668
- status, code = Coercion.check_scalar(field, field[opt])
669
- return if status == :ok
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
670
957
 
671
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})"
958
+ field[opt] = freeze_authored(value)
672
959
  end
673
960
 
674
961
  def validate_array_authored_value!(field, opt)
675
962
  value = field[opt]
676
963
  return if authored_nil!(field, opt)
677
964
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
678
- return unless field[:of]
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
+
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
+
1011
+ status, code = Coercion.check_scalar(sub, value)
1012
+ return if status == :ok
679
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)
680
1019
  value.each do |element|
681
1020
  status, code = Coercion.cast(field[:of], element)
682
1021
  next if status == :ok
@@ -685,6 +1024,29 @@ module Permittable
685
1024
  end
686
1025
  end
687
1026
 
1027
+ # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
1028
+ # field hashes — an authored `default:` or `example:` value stayed
1029
+ # mutable, and HashWithIndifferentAccess hands a non-frozen Array (and
1030
+ # any String) to the result BY REFERENCE. So one request appending to
1031
+ # `permitted_params[:tags]` corrupted the default for every later request
1032
+ # in the process. Freezing a COPY fixes that without freezing an object
1033
+ # the host app passed in and may still be using.
1034
+ def freeze_authored(value)
1035
+ deep_freeze(value.deep_dup)
1036
+ end
1037
+
1038
+ def deep_freeze(value)
1039
+ case value
1040
+ when Hash
1041
+ value.each_pair do |key, element|
1042
+ deep_freeze(key)
1043
+ deep_freeze(element)
1044
+ end
1045
+ when Array then value.each { |element| deep_freeze(element) }
1046
+ end
1047
+ value.freeze
1048
+ end
1049
+
688
1050
  # An authored nil is only meaningful on a nullable field, where it says
689
1051
  # "absent means clear" (PUT semantics) rather than "no default". On any
690
1052
  # other field it is a value nil could never satisfy, so it fails at class
@@ -846,9 +1208,24 @@ module Permittable
846
1208
  end
847
1209
  end
848
1210
 
1211
+ # `sensitive: true` on a nested or array field CASCADES to everything
1212
+ # inside it, because Rails' parameter filtering matches the leaf key it is
1213
+ # currently looking at — never the path that led there. Registering only
1214
+ # the container's own name therefore redacted nothing it promised: the
1215
+ # filter is handed ("payment", {...}), a Hash is not a String so nothing
1216
+ # is replaced, and it then recurses and asks about "card_number", which
1217
+ # was never registered.
1218
+ #
1219
+ # A sub-field opts out with an explicit `sensitive: false`. That escape
1220
+ # hatch exists because matching is a case-insensitive SUBSTRING match, so
1221
+ # cascading a generic name (:id, :name) would redact every parameter
1222
+ # app-wide that happens to contain it — occasionally a worse outcome than
1223
+ # the leak it prevents.
1224
+ # The cascade is already resolved on the field data (see
1225
+ # ContractBuilder#cascade_sensitive), so this only has to read it.
849
1226
  def register_sensitive_params(fields)
850
1227
  fields.each do |field|
851
- Permittable.filter_parameter_registry.add(field[:name]) if field[:sensitive]
1228
+ Permittable.register_sensitive_parameter(field[:name]) if field[:sensitive]
852
1229
  register_sensitive_params(field[:fields]) if field[:fields]
853
1230
  end
854
1231
  end
@@ -858,18 +1235,31 @@ module Permittable
858
1235
  # defaulted values for the given action (default: the current action).
859
1236
  # Absent optional fields are omitted. Raises InvalidParameters on
860
1237
  # violation; raises ArgumentError when no contract covers the action
861
- # (that is a programmer error, not a client error). Memoized per action.
1238
+ # (that is a programmer error, not a client error).
1239
+ #
1240
+ # Memoized per action, and the memo remembers the OUTCOME rather than only
1241
+ # a success: a rejection is stored and re-raised. Validation is therefore
1242
+ # observable exactly once per action per request, which the
1243
+ # "invalid_parameters.permittable" event depends on — memoizing only
1244
+ # successes meant a rejected request that was read twice (an action calling
1245
+ # permittable_violations before permitted_params, say) instrumented twice
1246
+ # and double-counted itself in every dashboard.
862
1247
  def permitted_params(action = nil)
863
1248
  action = (action || permittable_action_name).to_s
864
1249
  raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty?
865
1250
 
866
1251
  @permittable_validated ||= {}
867
- return @permittable_validated[action] if @permittable_validated.key?(action)
868
-
869
- rule = self.class.permit_rule_for(action)
870
- raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
1252
+ outcome = @permittable_validated.fetch(action) do
1253
+ @permittable_validated[action] = permittable_outcome_for(action)
1254
+ end
1255
+ # `cause: nil` because a memoized rejection is raised from wherever the
1256
+ # action happens to read the params next — possibly inside a `rescue` of
1257
+ # something unrelated, whose exception Ruby would otherwise adopt as this
1258
+ # error's cause for good. The object and its original backtrace (the
1259
+ # first raise site, where the violation was found) are preserved.
1260
+ raise outcome, cause: nil if outcome.is_a?(InvalidParameters)
871
1261
 
872
- @permittable_validated[action] = validate_params_contract!(rule, action)
1262
+ outcome
873
1263
  end
874
1264
 
875
1265
  # before_action entry point (public so hosts can `skip_before_action
@@ -918,6 +1308,19 @@ module Permittable
918
1308
 
919
1309
  private
920
1310
 
1311
+ # The value permitted_params memoizes: the validated params, or the
1312
+ # InvalidParameters that rejected them. ArgumentError is deliberately NOT
1313
+ # memoized — a contract that does not cover the action is a bug to fix, not
1314
+ # a verdict on this request, so it raises fresh on every call.
1315
+ def permittable_outcome_for(action)
1316
+ rule = self.class.permit_rule_for(action)
1317
+ raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
1318
+
1319
+ validate_params_contract!(rule, action)
1320
+ rescue InvalidParameters => e
1321
+ e
1322
+ end
1323
+
921
1324
  def validate_params_contract!(rule, action)
922
1325
  violations = []
923
1326
  source = permittable_root_hash(rule, violations)
@@ -957,7 +1360,7 @@ module Permittable
957
1360
  return ActiveSupport::HashWithIndifferentAccess.new unless source
958
1361
 
959
1362
  passed = ActiveSupport::HashWithIndifferentAccess.new(source)
960
- rule[:root] ? passed : passed.except(*ROUTING_KEYS)
1363
+ rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
961
1364
  end
962
1365
 
963
1366
  def raise_invalid_parameters!(violations, status:)
@@ -974,7 +1377,25 @@ module Permittable
974
1377
  end
975
1378
 
976
1379
  def permittable_violation_summary(violations)
977
- violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
1380
+ permittable_prose_list(violations) do |v|
1381
+ v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})"
1382
+ end
1383
+ end
1384
+
1385
+ # See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
1386
+ # undeclared keys used to produce a 50,000-item sentence — a megabyte of
1387
+ # 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.
1390
+ def permittable_prose_list(items)
1391
+ shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(yield(item)) }.join(", ")
1392
+ return shown if items.length <= PROSE_LIST_LIMIT
1393
+
1394
+ "#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
1395
+ end
1396
+
1397
+ def permittable_prose_item(item)
1398
+ item.length <= PROSE_ITEM_LIMIT ? item : "#{item[0, PROSE_ITEM_LIMIT - 3]}..."
978
1399
  end
979
1400
 
980
1401
  # One violation detail entry. A field's `message:` (String, or Hash keyed
@@ -1016,12 +1437,21 @@ module Permittable
1016
1437
  raw = permittable_plain_params
1017
1438
  return raw unless rule[:root]
1018
1439
 
1019
- value = raw[rule[:root].to_s]
1440
+ key = rule[:root].to_s
1441
+ value = raw[key]
1020
1442
  return value if value.is_a?(Hash)
1021
1443
 
1444
+ # A root that is absent and a root sent with the wrong shape
1445
+ # ({"user": "bob"}) are different client mistakes, and telling a client
1446
+ # that the key it just sent is "missing" sends it looking in the wrong
1447
+ # place. Absence is the gem's own definition of it, so `{"user": ""}`
1448
+ # still reads as missing. Either way the envelope is malformed, so both
1449
+ # remain a 400.
1450
+ #
1022
1451
  # No field declares the root, so message resolution can only come from
1023
1452
  # I18n ({} has no :message).
1024
- violations << permittable_violation({}, rule[:root].to_s, "missing")
1453
+ code = permittable_absent?(value, raw, key) ? "missing" : "invalid_type"
1454
+ violations << permittable_violation({}, key, code)
1025
1455
  nil
1026
1456
  end
1027
1457
 
@@ -1039,13 +1469,13 @@ module Permittable
1039
1469
  fields.each do |field|
1040
1470
  key = field[:name].to_s
1041
1471
  full = permittable_path(path, key)
1042
- value = hash[key]
1472
+ value = permittable_normalized(field, hash[key])
1043
1473
 
1044
1474
  if permittable_absent?(value, hash, key)
1045
1475
  if permittable_explicit_null?(field, hash, key)
1046
1476
  result[key] = nil
1047
1477
  elsif field.key?(:default)
1048
- result[key] = field[:default]
1478
+ result[key] = permittable_default(field)
1049
1479
  elsif field[:required]
1050
1480
  violations << permittable_violation(field, full, "missing")
1051
1481
  end
@@ -1062,6 +1492,7 @@ module Permittable
1062
1492
  key = field[:name].to_s
1063
1493
  case field[:kind]
1064
1494
  when :scalar
1495
+ # Already normalized by permittable_normalized, before the absence rule.
1065
1496
  permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
1066
1497
  when :json
1067
1498
  permittable_check_whole(field, Coercion.check_json(field, value), full, result, violations: violations)
@@ -1095,8 +1526,18 @@ module Permittable
1095
1526
  end
1096
1527
 
1097
1528
  def permittable_check_array(field, value, path:, unknown:, violations:)
1529
+ # `length:` is a BOUND, not a report. An array outside it is rejected
1530
+ # whatever its contents, so checking those contents can only add work and
1531
+ # noise: a 200k-element payload against `length: 0..10` used to cast every
1532
+ # element, collect 200k more violations, and answer with a multi-megabyte
1533
+ # 422 — for a request already refused by its first check. Stopping here
1534
+ # keeps the cost of an oversized array proportional to rejecting it.
1535
+ if field[:length] && !Coercion.length_ok?(field[:length], value.length)
1536
+ violations << permittable_violation(field, path, "length")
1537
+ return nil
1538
+ end
1539
+
1098
1540
  before = violations.length
1099
- violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
1100
1541
  out = value.each_with_index.map do |element, index|
1101
1542
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
1102
1543
  end
@@ -1127,9 +1568,30 @@ module Permittable
1127
1568
  nil
1128
1569
  end
1129
1570
 
1571
+ # `normalize:` runs BEFORE the absence rule, not inside the cast, so there
1572
+ # stays exactly ONE reading of absence. Otherwise a value that normalizes to
1573
+ # empty walked straight past it: `required :name, :string, normalize:
1574
+ # :squish` rejected "" as missing but accepted " " as "" — the silent
1575
+ # corruption strict coercion exists to refuse, delivered by the gem's own
1576
+ # preset. Only scalars take normalize:, and apply_normalize is itself a
1577
+ # no-op without one, so it owns that decision for every caller.
1578
+ def permittable_normalized(field, value)
1579
+ Coercion.apply_normalize(field[:normalize], value)
1580
+ end
1581
+
1582
+ # 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
1586
+ # to mutate.
1587
+ def permittable_default(field)
1588
+ value = field[:default]
1589
+ value.is_a?(String) ? value.dup : value
1590
+ end
1591
+
1130
1592
  # nil and "" are both ABSENT — see the module comment.
1131
1593
  def permittable_absent?(value, hash, key)
1132
- !hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
1594
+ !hash.key?(key) || Coercion.absent_value?(value)
1133
1595
  end
1134
1596
 
1135
1597
  # `nullable: true` splits the one absence rule in two: a key the client
@@ -1146,14 +1608,14 @@ module Permittable
1146
1608
 
1147
1609
  declared = fields.map { |f| f[:name].to_s }
1148
1610
  extra = hash.keys.map(&:to_s) - declared
1149
- extra -= ROUTING_KEYS if top_level
1611
+ extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
1150
1612
  return if extra.empty?
1151
1613
 
1152
1614
  if unknown == :error
1153
1615
  extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
1154
1616
  elsif respond_to?(:logger) && logger
1155
- logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
1156
- "#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
1617
+ listed = permittable_prose_list(extra) { |key| permittable_path(path, key) }
1618
+ logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
1157
1619
  end
1158
1620
  end
1159
1621