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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +65 -20
- data/lib/permittable/filter_parameter_registry.rb +11 -2
- data/lib/permittable/generator.rb +30 -2
- data/lib/permittable/json_schema.rb +11 -4
- data/lib/permittable/open_api.rb +56 -13
- data/lib/permittable/railtie.rb +17 -1
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +502 -40
- metadata +2 -2
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
|
-
|
|
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
|
-
#
|
|
254
|
-
#
|
|
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
|
|
346
|
-
when String then
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
864
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
|
|
622
865
|
|
|
623
|
-
|
|
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
|
-
|
|
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}:
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
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(*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) ||
|
|
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 -=
|
|
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
|
-
|
|
1156
|
-
|
|
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
|
|