permittable 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +173 -0
- data/README.md +400 -39
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +47 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +827 -47
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +226 -45
- data/lib/permittable/open_api.rb +279 -39
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +1135 -118
- metadata +9 -4
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"
|
|
@@ -7,9 +14,22 @@ require "active_support/core_ext/class/attribute"
|
|
|
7
14
|
require "active_support/core_ext/object/deep_dup" # authored default:/example: values are copied before freezing
|
|
8
15
|
require "active_support/core_ext/string/inflections"
|
|
9
16
|
require "active_support/core_ext/string/filters"
|
|
17
|
+
# cast_datetime names ActiveSupport::TimeWithZone, which activesupport does not
|
|
18
|
+
# load by default. A Rails app has it via active_support/time at boot; a
|
|
19
|
+
# standalone host (a Contract validating a webhook payload or a job argument)
|
|
20
|
+
# has nothing that loads it, and every :datetime cast raised NameError there.
|
|
21
|
+
#
|
|
22
|
+
# The Time core extensions come with it, and are not optional: TimeWithZone is
|
|
23
|
+
# present but not self-sufficient. Converting one goes through
|
|
24
|
+
# TimeZone#utc_to_local, which calls Time#sec_fraction — defined in
|
|
25
|
+
# core_ext/time/calculations, which time_with_zone.rb does not itself require.
|
|
26
|
+
# Without this line a real TimeWithZone raises NoMethodError in a bare host on
|
|
27
|
+
# activesupport 8.1, having merely traded one crash for another.
|
|
28
|
+
require "active_support/core_ext/time/calculations"
|
|
10
29
|
require "bigdecimal"
|
|
11
30
|
require "date"
|
|
12
31
|
require "time"
|
|
32
|
+
require "uri" # URI::MailTo::EMAIL_REGEXP backs the :email format preset
|
|
13
33
|
|
|
14
34
|
require "permittable/version"
|
|
15
35
|
require "permittable/error_envelope"
|
|
@@ -62,6 +82,14 @@ require "permittable/filter_parameter_registry"
|
|
|
62
82
|
# (db:create, assets:precompile) the check skips. In CI, one
|
|
63
83
|
# `Rails.application.eager_load!` spec exercises every contract in the app.
|
|
64
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
|
+
#
|
|
65
93
|
# Validation is LAZY: it runs on the first `permitted_params` call, so an
|
|
66
94
|
# action that never reads params never pays. `enforce: true` installs the
|
|
67
95
|
# check as a before_action instead (reject before the action body runs).
|
|
@@ -102,9 +130,14 @@ require "permittable/filter_parameter_registry"
|
|
|
102
130
|
# absence. `normalize:` runs BEFORE that rule rather than inside the cast, so
|
|
103
131
|
# there is exactly one reading of absence and a value that normalizes to empty
|
|
104
132
|
# (" " under :squish) cannot satisfy a required field by becoming "". An
|
|
105
|
-
# authored `default:`/`example:` is stored
|
|
106
|
-
#
|
|
107
|
-
# the
|
|
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.
|
|
108
141
|
#
|
|
109
142
|
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
110
143
|
# clears a column: a key the client never sent stays absent (defaults apply,
|
|
@@ -139,6 +172,13 @@ require "permittable/filter_parameter_registry"
|
|
|
139
172
|
# the value and expects in-place mutation, and never calls the proc at all
|
|
140
173
|
# for a Hash — so a name in config.filter_parameters is what covers an
|
|
141
174
|
# :integer field or a sensitive nested block.
|
|
175
|
+
# Permittable::Railtie appends to `config.filter_parameters`. On a nested or
|
|
176
|
+
# array field it CASCADES to every field inside, because Rails' filtering
|
|
177
|
+
# asks about the leaf key it is looking at rather than the path to it; a
|
|
178
|
+
# sub-field opts out with `sensitive: false`, since matching is a substring
|
|
179
|
+
# match and a generic cascaded name would redact half the app's logs. The
|
|
180
|
+
# cascade is resolved onto the field data at class load — see
|
|
181
|
+
# ContractBuilder#cascade_sensitive.
|
|
142
182
|
#
|
|
143
183
|
# OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
|
|
144
184
|
# Two layers, both operating on the validated COPY (the request's `params` is
|
|
@@ -147,7 +187,14 @@ require "permittable/filter_parameter_registry"
|
|
|
147
187
|
# and validation to reshape that field's output, e.g.
|
|
148
188
|
# `transform: ->(v) { v.split(",") }` turns a validated delimited String
|
|
149
189
|
# into an Array. Runs only on request-supplied values: absent fields stay
|
|
150
|
-
# absent and `default:`
|
|
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).
|
|
151
198
|
# * `finalize do |p| ... end` (once per contract) — runs after every field
|
|
152
199
|
# validated cleanly, receives the result hash, and must return the
|
|
153
200
|
# (possibly restructured) Hash: combine parallel fields, build value
|
|
@@ -170,6 +217,7 @@ module Permittable
|
|
|
170
217
|
JSON_TYPE = :json
|
|
171
218
|
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
172
219
|
MODES = %i[enforce monitor].freeze
|
|
220
|
+
ERROR_FORMATS = %i[envelope problem].freeze
|
|
173
221
|
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
174
222
|
# unknown-keys check must not flag them.
|
|
175
223
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
@@ -188,6 +236,62 @@ module Permittable
|
|
|
188
236
|
# rather than leaving a bare ROUTING_KEYS to read like an oversight.
|
|
189
237
|
UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
|
|
190
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
|
|
243
|
+
# A log line and an exception message are PROSE, written for a person. They
|
|
244
|
+
# list at most this many names and count the rest, so one request cannot
|
|
245
|
+
# write a megabyte of them. The machine-readable channels — a violation's
|
|
246
|
+
# `details` and the instrumentation payload — stay complete; only the
|
|
247
|
+
# sentence is bounded.
|
|
248
|
+
PROSE_LIST_LIMIT = 10
|
|
249
|
+
# ...and each name it does list is truncated. Capping the COUNT alone still
|
|
250
|
+
# let ONE 1 MB key name write the 1 MB log line the cap exists to prevent.
|
|
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
|
|
191
295
|
|
|
192
296
|
# The single proc Permittable::Railtie appends to config.filter_parameters.
|
|
193
297
|
# Declared with an optional third parameter so its own arity is -3 and Rails
|
|
@@ -199,6 +303,28 @@ module Permittable
|
|
|
199
303
|
inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
|
|
200
304
|
end.freeze
|
|
201
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
|
+
|
|
202
328
|
NORMALIZERS = {
|
|
203
329
|
squish: ->(v) { v.squish },
|
|
204
330
|
strip: ->(v) { v.strip },
|
|
@@ -298,6 +424,19 @@ module Permittable
|
|
|
298
424
|
@sensitive_parameter_sinks ||= []
|
|
299
425
|
end
|
|
300
426
|
|
|
427
|
+
# A reusable field list, shareable by any number of contracts — see
|
|
428
|
+
# FieldGroup for the whole story.
|
|
429
|
+
#
|
|
430
|
+
# AddressFields = Permittable.fields do
|
|
431
|
+
# required :city, :string
|
|
432
|
+
# optional :zip, :string, format: /\A\d{5}\z/
|
|
433
|
+
# end
|
|
434
|
+
#
|
|
435
|
+
# Splice it into a contract (or another group) with `use`.
|
|
436
|
+
def fields(&)
|
|
437
|
+
FieldGroup.new(&)
|
|
438
|
+
end
|
|
439
|
+
|
|
301
440
|
# App-wide default for rules that don't declare their own mode:.
|
|
302
441
|
# :enforce (the default) rejects violating requests; :monitor reports
|
|
303
442
|
# them — same instrumentation event with payload mode: :monitor, plus a
|
|
@@ -318,6 +457,54 @@ module Permittable
|
|
|
318
457
|
@mode = value
|
|
319
458
|
end
|
|
320
459
|
|
|
460
|
+
# The shape of a rejection: :envelope (the default — the host's
|
|
461
|
+
# #render_error, or the gem's inline JSON) or :problem, which renders RFC
|
|
462
|
+
# 9457 Problem Details as application/problem+json. App-wide, because the
|
|
463
|
+
# error format of an API is a property of the API rather than of any one
|
|
464
|
+
# contract, and set from an initializer:
|
|
465
|
+
#
|
|
466
|
+
# Permittable.error_format = :problem
|
|
467
|
+
#
|
|
468
|
+
# See ErrorEnvelope, including why :problem opts out of #render_error.
|
|
469
|
+
def error_format
|
|
470
|
+
@error_format || :envelope
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def error_format=(value)
|
|
474
|
+
value = value.to_sym
|
|
475
|
+
raise ArgumentError, "#{LABEL}: error_format must be one of #{ERROR_FORMATS.join(', ')}" unless ERROR_FORMATS.include?(value)
|
|
476
|
+
|
|
477
|
+
@error_format = value
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Base URI for problem `type` members. Unset (the default) leaves the type
|
|
481
|
+
# as RFC 9457's "about:blank"; set it to where the app documents its
|
|
482
|
+
# problem types and each type gets its own URI under it.
|
|
483
|
+
attr_accessor :problem_base_uri
|
|
484
|
+
|
|
485
|
+
# Whether the schema-drift guard also checks that a field's declared type
|
|
486
|
+
# matches its column's, on top of checking the column exists.
|
|
487
|
+
#
|
|
488
|
+
# OFF by default, deliberately. Every cross-type declaration has some
|
|
489
|
+
# legitimate use — a :string contract on a date column that lets
|
|
490
|
+
# ActiveRecord do the casting, a :boolean contract on a legacy integer
|
|
491
|
+
# column — and breaking those apps on an upgrade would cost more than the
|
|
492
|
+
# drift it catches. Turn it on and fix what it finds:
|
|
493
|
+
#
|
|
494
|
+
# Permittable.check_column_types = true
|
|
495
|
+
#
|
|
496
|
+
# It compares type GROUPS rather than exact types, and never fires on a
|
|
497
|
+
# column it has no faithful contract type for. See ColumnGuard.
|
|
498
|
+
def check_column_types
|
|
499
|
+
@check_column_types || false
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def check_column_types=(value)
|
|
503
|
+
raise ArgumentError, "#{LABEL}: check_column_types must be true or false" unless [true, false].include?(value)
|
|
504
|
+
|
|
505
|
+
@check_column_types = value
|
|
506
|
+
end
|
|
507
|
+
|
|
321
508
|
# App-wide fallback copy for a violation code, looked up through I18n
|
|
322
509
|
# under permittable.errors.<code> ("missing", "inclusion", or any Symbol
|
|
323
510
|
# a validate: returned). Consulted only when the field declares no
|
|
@@ -375,25 +562,59 @@ module Permittable
|
|
|
375
562
|
check_scalar_rules(field, value)
|
|
376
563
|
end
|
|
377
564
|
|
|
565
|
+
# `length:` first, deliberately. It is an O(1) read of a String's size,
|
|
566
|
+
# while `format:` runs a regexp over the whole value and `validate:` runs
|
|
567
|
+
# arbitrary app code — so checking the cheap bound last meant a value the
|
|
568
|
+
# bound already excluded still paid for the expensive ones. A 5 MB string
|
|
569
|
+
# against `length: 1..80` scanned all 5 MB with the field's regexp before
|
|
570
|
+
# being rejected on its length, and an app regexp with poor worst-case
|
|
571
|
+
# behaviour turns that from waste into a lever.
|
|
572
|
+
#
|
|
573
|
+
# The only observable change is which code a value violating BOTH reports:
|
|
574
|
+
# `length` now, rather than `inclusion`/`format`. Reporting the structural
|
|
575
|
+
# failure first is the better answer anyway — a client cannot act on
|
|
576
|
+
# "wrong format" for a value that is also far too long.
|
|
378
577
|
def check_scalar_rules(field, value)
|
|
379
|
-
return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
|
|
380
|
-
return [:error, "format"] if field[:format] && !field[:format].match?(value)
|
|
381
578
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
579
|
+
return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
|
|
580
|
+
return [:error, "format"] if field[:format] && !format_match?(field[:format], value)
|
|
382
581
|
|
|
383
582
|
check_custom(field[:validate], value)
|
|
384
583
|
end
|
|
385
584
|
|
|
585
|
+
# A Regexp RAISES rather than answers when a String's encoding cannot meet
|
|
586
|
+
# it — a UTF-8 pattern with non-ASCII characters against UTF-16,
|
|
587
|
+
# Shift_JIS or binary bytes (Encoding::CompatibilityError). A value the
|
|
588
|
+
# pattern cannot even be applied to has not been shown to match, so it is
|
|
589
|
+
# a `format` violation, the answer the client can act on. An app that
|
|
590
|
+
# takes other encodings on purpose (`skip_parameter_encoding`) sees what
|
|
591
|
+
# it saw before this rule, except that the crash is now a 422.
|
|
592
|
+
def format_match?(pattern, value)
|
|
593
|
+
pattern.match?(value)
|
|
594
|
+
rescue EncodingError, ArgumentError
|
|
595
|
+
false
|
|
596
|
+
end
|
|
597
|
+
|
|
386
598
|
# Free-form hash. The shape is deliberately undeclared, so the only
|
|
387
599
|
# checks are the bounds the field asked for: breadth (`length:`, the
|
|
388
600
|
# top-level key count, same reading as an array's element count) and
|
|
389
601
|
# nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
|
|
390
602
|
# checking, like check_scalar.
|
|
391
603
|
def check_json(field, value)
|
|
604
|
+
status, code = check_json_bounds(field, value)
|
|
605
|
+
return [status, code] unless status == :ok
|
|
606
|
+
|
|
607
|
+
check_custom(field[:validate], value)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# The structural half of check_json, which the request walker runs on
|
|
611
|
+
# its own so that it can copy an accepted hash before app code sees it.
|
|
612
|
+
def check_json_bounds(field, value)
|
|
392
613
|
return [:error, "invalid_type"] unless value.is_a?(Hash)
|
|
393
614
|
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
394
615
|
return [:error, "depth"] if field[:max_depth] && depth_exceeds?(value, field[:max_depth])
|
|
395
616
|
|
|
396
|
-
|
|
617
|
+
[:ok, value]
|
|
397
618
|
end
|
|
398
619
|
|
|
399
620
|
# Container nesting, with the field's own hash as level 1. An Array counts
|
|
@@ -423,8 +644,76 @@ module Permittable
|
|
|
423
644
|
|
|
424
645
|
def cast(type, value)
|
|
425
646
|
return [:error, "invalid_type"] unless scalar_shaped?(value)
|
|
647
|
+
return public_send("cast_#{type}", value) unless value.is_a?(String)
|
|
648
|
+
# Bytes that are not valid in the String's OWN encoding are not text in
|
|
649
|
+
# any encoding: every String operation after the cast raises on them
|
|
650
|
+
# (`format:`, the normalize presets, an app's `validate:`). Rails'
|
|
651
|
+
# params builder guards a controller; a standalone Contract#call on a
|
|
652
|
+
# webhook payload has nothing in front of it, so `"caf\xC3"` was a 500.
|
|
653
|
+
return [:error, "invalid_type"] unless value.valid_encoding?
|
|
654
|
+
# A :string is handed back exactly as it arrived, in its own encoding.
|
|
655
|
+
# `skip_parameter_encoding` / `param_encoding` send a controller binary
|
|
656
|
+
# or Shift_JIS text ON PURPOSE, and converting it would hand the app
|
|
657
|
+
# something other than what it asked Rails for.
|
|
658
|
+
return cast_string(value) if type == :string
|
|
659
|
+
|
|
660
|
+
# A UTF-8 String IS already its own inspection copy — the check just
|
|
661
|
+
# above already scanned it — so utf8_text would only scan the same
|
|
662
|
+
# object a second time for no new answer. Every other encoding still
|
|
663
|
+
# goes through it: a US-ASCII or binary String is a NEW object once
|
|
664
|
+
# force_encoding'd, and one only `encode` could produce is not yet
|
|
665
|
+
# known to be valid UTF-8 at all.
|
|
666
|
+
text = value.encoding == Encoding::UTF_8 ? value : utf8_text(value)
|
|
667
|
+
text ? public_send("cast_#{type}", text) : [:error, "invalid_type"]
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Encodings whose bytes are READ as UTF-8 rather than converted: UTF-8
|
|
671
|
+
# itself, US-ASCII (a subset of it), and binary — which names no
|
|
672
|
+
# encoding at all, and is how a raw socket read or an unlabelled file
|
|
673
|
+
# hands a payload over.
|
|
674
|
+
UTF8_READABLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
|
|
675
|
+
|
|
676
|
+
# A UTF-8 copy of a String, for INSPECTION only — the text a number, a
|
|
677
|
+
# boolean or a date is parsed from — or nil when there is no UTF-8
|
|
678
|
+
# reading of it. The value handed back to the app is never this copy.
|
|
679
|
+
#
|
|
680
|
+
# Parsing the String itself went wrong for any encoding but UTF-8:
|
|
681
|
+
# `Integer()` on UTF-16 "12" raised Encoding::CompatibilityError, and
|
|
682
|
+
# `BigDecimal` read the same String byte by byte and returned 1 — a wrong
|
|
683
|
+
# answer where the other at least crashed.
|
|
684
|
+
#
|
|
685
|
+
# Binary and US-ASCII are read as UTF-8 (on a copy — the caller's String
|
|
686
|
+
# keeps its encoding), anything else is converted with `encode`, and a
|
|
687
|
+
# result that is not valid UTF-8, or a conversion that raises, is nil.
|
|
688
|
+
def utf8_text(value)
|
|
689
|
+
text = if value.encoding == Encoding::UTF_8 then value
|
|
690
|
+
elsif UTF8_READABLE.include?(value.encoding) then value.dup.force_encoding(Encoding::UTF_8)
|
|
691
|
+
else value.encode(Encoding::UTF_8)
|
|
692
|
+
end
|
|
693
|
+
text.valid_encoding? ? text : nil
|
|
694
|
+
rescue EncodingError
|
|
695
|
+
nil
|
|
696
|
+
end
|
|
426
697
|
|
|
427
|
-
|
|
698
|
+
# utf8_text for text the gem must REPORT rather than judge — a client's
|
|
699
|
+
# undeclared key, written into a violation's `param`, the exception
|
|
700
|
+
# message and the log line. Refusing is not an option there, so what
|
|
701
|
+
# cannot be read is replaced with U+FFFD. Otherwise the undeclared key
|
|
702
|
+
# "caf\xC3" was copied raw into the 422 and rendering it raised
|
|
703
|
+
# JSON::GeneratorError, and a UTF-16 key raised
|
|
704
|
+
# Encoding::CompatibilityError while its path was being interpolated.
|
|
705
|
+
# Only undeclared keys come here (see permittable_unknown_key_violation):
|
|
706
|
+
# a declared key is the contract's own UTF-8 name.
|
|
707
|
+
def reportable_text(value)
|
|
708
|
+
utf8_text(value) || scrubbed_text(value)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def scrubbed_text(value)
|
|
712
|
+
return value.dup.force_encoding(Encoding::UTF_8).scrub if UTF8_READABLE.include?(value.encoding)
|
|
713
|
+
|
|
714
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
715
|
+
rescue EncodingError
|
|
716
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
428
717
|
end
|
|
429
718
|
|
|
430
719
|
# Arrays, hashes, and nested ActionController::Parameters
|
|
@@ -436,9 +725,20 @@ module Permittable
|
|
|
436
725
|
true
|
|
437
726
|
end
|
|
438
727
|
|
|
728
|
+
# A String is returned as given. The request walker has already copied
|
|
729
|
+
# it on the way in (Permittable#permittable_normalized), before
|
|
730
|
+
# `normalize:` could see it — copying here as well would allocate twice
|
|
731
|
+
# per value and still come too late for a mutating `normalize:` proc.
|
|
439
732
|
def cast_string(value)
|
|
440
733
|
case value
|
|
441
734
|
when String then [:ok, value]
|
|
735
|
+
# BigDecimal#to_s defaults to engineering notation ("0.15e1" for 1.5) —
|
|
736
|
+
# stdlib's own rendering, only ever masked in a host that has loaded
|
|
737
|
+
# Rails' active_support/core_ext/big_decimal/conversions, which patches
|
|
738
|
+
# the default format to "F". A :decimal default:/example: cast through
|
|
739
|
+
# here (a plain :string field, not :decimal itself) must render the same
|
|
740
|
+
# way regardless of whether that patch happens to be loaded.
|
|
741
|
+
when BigDecimal then [:ok, value.to_s("F")]
|
|
442
742
|
when Numeric, true, false then [:ok, value.to_s]
|
|
443
743
|
else [:error, "invalid_type"]
|
|
444
744
|
end
|
|
@@ -447,7 +747,10 @@ module Permittable
|
|
|
447
747
|
def cast_integer(value)
|
|
448
748
|
case value
|
|
449
749
|
when Integer then [:ok, value]
|
|
450
|
-
|
|
750
|
+
# NaN and Infinity first: `truncate` raises FloatDomainError on them (a
|
|
751
|
+
# RangeError, which the ArgumentError rescue below does not catch), and
|
|
752
|
+
# no integer is what either one sent. Same rule as finite_float.
|
|
753
|
+
when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
|
|
451
754
|
when String then [:ok, Integer(value, 10)]
|
|
452
755
|
else [:error, "invalid_type"]
|
|
453
756
|
end
|
|
@@ -457,23 +760,54 @@ module Permittable
|
|
|
457
760
|
|
|
458
761
|
def cast_float(value)
|
|
459
762
|
case value
|
|
460
|
-
when Numeric then
|
|
461
|
-
when String then
|
|
763
|
+
when Numeric then finite_float(value.to_f)
|
|
764
|
+
when String then finite_float(Float(value), source: value)
|
|
462
765
|
else [:error, "invalid_type"]
|
|
463
766
|
end
|
|
464
767
|
rescue ArgumentError
|
|
465
768
|
[:error, "invalid_type"]
|
|
466
769
|
end
|
|
467
770
|
|
|
771
|
+
# A Float that is not finite does not represent what was sent. "1e400"
|
|
772
|
+
# overflows to Infinity and "1e-400" underflows to zero — both silently,
|
|
773
|
+
# and both leaving a value no column can faithfully store.
|
|
774
|
+
#
|
|
775
|
+
# Underflow is only visible against the source text, since the result is
|
|
776
|
+
# an ordinary 0.0: a zero result is rejected when the string it came from
|
|
777
|
+
# named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
|
|
778
|
+
# genuine zero whose exponent digits say nothing about the value — as are
|
|
779
|
+
# "0", "0.0" and "0.0000".
|
|
780
|
+
def finite_float(result, source: nil)
|
|
781
|
+
return [:error, "invalid_type"] unless result.finite?
|
|
782
|
+
return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
|
|
783
|
+
|
|
784
|
+
[:ok, result]
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def nonzero_significand?(source)
|
|
788
|
+
return false unless source
|
|
789
|
+
|
|
790
|
+
source.split(/[eE]/, 2).first.match?(/[1-9]/)
|
|
791
|
+
end
|
|
792
|
+
|
|
468
793
|
def cast_decimal(value)
|
|
469
794
|
case value
|
|
470
|
-
when Numeric, String then
|
|
795
|
+
when Numeric, String then finite_decimal(BigDecimal(value.to_s))
|
|
471
796
|
else [:error, "invalid_type"]
|
|
472
797
|
end
|
|
473
798
|
rescue ArgumentError
|
|
474
799
|
[:error, "invalid_type"]
|
|
475
800
|
end
|
|
476
801
|
|
|
802
|
+
# BigDecimal has no exponent limit, so a :decimal cannot overflow — but
|
|
803
|
+
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
804
|
+
# raises, so a client could send the literal string "NaN" for a price and
|
|
805
|
+
# have it stored. Nothing else in the gem disagreed with itself this
|
|
806
|
+
# loudly: :float rejected those strings and :decimal did not.
|
|
807
|
+
def finite_decimal(result)
|
|
808
|
+
result.finite? ? [:ok, result] : [:error, "invalid_type"]
|
|
809
|
+
end
|
|
810
|
+
|
|
477
811
|
def cast_boolean(value)
|
|
478
812
|
return [:ok, true] if TRUE_VALUES.include?(value)
|
|
479
813
|
return [:ok, false] if FALSE_VALUES.include?(value)
|
|
@@ -517,7 +851,12 @@ module Permittable
|
|
|
517
851
|
def cast_datetime(value)
|
|
518
852
|
case value
|
|
519
853
|
# DateTime is listed here, ahead of Date, because it subclasses Date.
|
|
520
|
-
|
|
854
|
+
# `getutc` rather than `utc`: `Time#utc` converts the RECEIVER, and
|
|
855
|
+
# `Time#to_time` returns self, so `value.to_time.utc` silently rewrote
|
|
856
|
+
# the caller's own object. A TimeWithZone's `getutc` hands back the
|
|
857
|
+
# instance it caches internally, so that one is duped.
|
|
858
|
+
when ActiveSupport::TimeWithZone then [:ok, value.getutc.dup]
|
|
859
|
+
when Time, DateTime then [:ok, value.to_time.getutc]
|
|
521
860
|
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
522
861
|
when String
|
|
523
862
|
# Same rule as :date — the DATE part must be named in full, or it is
|
|
@@ -538,10 +877,37 @@ module Permittable
|
|
|
538
877
|
|
|
539
878
|
# Presets only make sense on String input; a non-String value (JSON
|
|
540
879
|
# numbers, booleans) skips normalization and goes straight to the cast.
|
|
880
|
+
#
|
|
881
|
+
# A String that is not valid in its own encoding is left as it came, for
|
|
882
|
+
# the cast to refuse: every preset raises on invalid bytes, and an app's
|
|
883
|
+
# own proc would be handed input it never agreed to see. It is not empty,
|
|
884
|
+
# so the absence rule in between cannot mistake it for a missing value.
|
|
885
|
+
#
|
|
886
|
+
# A VALID String in another encoding is normalized in that encoding —
|
|
887
|
+
# `:strip` works on binary and Shift_JIS alike — and when a BUILT-IN
|
|
888
|
+
# PRESET cannot handle the encoding (`:squish` on UTF-16 raises
|
|
889
|
+
# Encoding::CompatibilityError) the value is left as it is rather than
|
|
890
|
+
# raising.
|
|
891
|
+
#
|
|
892
|
+
# That leniency is only for the gem's own presets, identified by object
|
|
893
|
+
# identity against NORMALIZERS' values (resolve_normalizer! replaces
|
|
894
|
+
# field[:normalize] with the exact Proc from that Hash, so a preset and
|
|
895
|
+
# an app-supplied Proc are never the same object). An app's own Proc
|
|
896
|
+
# raising is never swallowed, on ANY encoding: `normalize: ->(v) { raise
|
|
897
|
+
# ArgumentError, "..." if ... }` is a business rule, not an encoding
|
|
898
|
+
# failure, and treating its raise as "this encoding defeated the
|
|
899
|
+
# normalizer" would have let exactly the input a UTF-8 request could not
|
|
900
|
+
# bypass the very check it names.
|
|
541
901
|
def apply_normalize(normalizer, value)
|
|
542
902
|
return value unless normalizer && value.is_a?(String)
|
|
903
|
+
return value unless value.valid_encoding?
|
|
904
|
+
return normalizer.call(value) unless NORMALIZERS.value?(normalizer)
|
|
543
905
|
|
|
544
|
-
|
|
906
|
+
begin
|
|
907
|
+
normalizer.call(value)
|
|
908
|
+
rescue EncodingError, ArgumentError
|
|
909
|
+
value
|
|
910
|
+
end
|
|
545
911
|
end
|
|
546
912
|
|
|
547
913
|
# nil and "" are both ABSENT — see the module comment. The VALUE half of
|
|
@@ -552,6 +918,109 @@ module Permittable
|
|
|
552
918
|
value.nil? || (value.is_a?(String) && value.empty?)
|
|
553
919
|
end
|
|
554
920
|
|
|
921
|
+
# An `in:` list as the runtime holds it: every member cast by the field's
|
|
922
|
+
# own type, because included_in? compares the CAST request value against
|
|
923
|
+
# it. Comparing against the members as authored meant `in: %i[draft
|
|
924
|
+
# published]` on a :string field (and `in: %w[1 2 3]` on an :integer one)
|
|
925
|
+
# held values no cast could ever produce, and rejected every request.
|
|
926
|
+
#
|
|
927
|
+
# `normalize:` is deliberately not applied — it rewrites what a client
|
|
928
|
+
# sent, not what the contract author wrote. Duplicates the cast collapses
|
|
929
|
+
# ("1" and 1 on an :integer) are dropped, and a Set stays a Set, so an
|
|
930
|
+
# author who chose one for its O(1) include? keeps it. A nil member is
|
|
931
|
+
# dropped on a nullable field, where an explicit null is accepted before
|
|
932
|
+
# in: is ever consulted; anywhere else it is a member no value can equal,
|
|
933
|
+
# and is an error like any other.
|
|
934
|
+
#
|
|
935
|
+
# `members` is what in_list returned. Returns [:ok, cast, published] —
|
|
936
|
+
# `published` being what an exported enum lists, see published_in_member
|
|
937
|
+
# — or [:error, offending_member, code]. Shared by ContractBuilder and the
|
|
938
|
+
# RSpec matcher's `within` chain so the two cannot read a list differently.
|
|
939
|
+
def cast_in_members(type, members, nullable: false)
|
|
940
|
+
pairs = []
|
|
941
|
+
members.each do |member|
|
|
942
|
+
next if member.nil? && nullable
|
|
943
|
+
|
|
944
|
+
status, value = cast_in_member(type, member)
|
|
945
|
+
return [:error, member, value] unless status == :ok
|
|
946
|
+
|
|
947
|
+
pairs << [value, published_in_member(type, member, value)]
|
|
948
|
+
end
|
|
949
|
+
pairs = pairs.uniq(&:first)
|
|
950
|
+
cast_members = pairs.map(&:first)
|
|
951
|
+
[:ok, members.is_a?(Set) ? cast_members.to_set : cast_members, pairs.map(&:last)]
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# The members of an `in:` that is a LIST, or nil when it is not one.
|
|
955
|
+
# Only Array, Set, Hash and Enumerator count, and only when the object's
|
|
956
|
+
# OWN class provides the collection's ordinary include? — not a Hash,
|
|
957
|
+
# Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
|
|
958
|
+
# fuzzy Set, a registry matching some other way entirely). `case allowed;
|
|
959
|
+
# when Hash ...` matches with ===, which for a Class is is_a?, so a
|
|
960
|
+
# subclass would otherwise match its ancestor's branch and have its
|
|
961
|
+
# override silently discarded — read for its raw keys/elements instead,
|
|
962
|
+
# which can invert which values it actually accepts. It is left opaque
|
|
963
|
+
# instead, exactly like any other object whose include? is the point
|
|
964
|
+
# (see resolve_in!) and enumerating it may be expensive (a DB-backed
|
|
965
|
+
# registry).
|
|
966
|
+
#
|
|
967
|
+
# A Hash lists its KEYS, which is what Hash#include? asks about — the
|
|
968
|
+
# Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
|
|
969
|
+
# Set, so membership stays O(1) per request.
|
|
970
|
+
# ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
|
|
971
|
+
# accepted anyway: its include? override only canonicalises the argument
|
|
972
|
+
# (String/Symbol) before the SAME key lookup, so its keys are still
|
|
973
|
+
# exactly its members — and it is what a Rails enum's own reader
|
|
974
|
+
# (`Post.statuses`) actually returns.
|
|
975
|
+
# Enumerator::Lazy is the same story on the Enumerator side: Lazy
|
|
976
|
+
# overrides chain methods like map and select, but not include?, so it
|
|
977
|
+
# is still read as a list — and forced to an Array here, once, since
|
|
978
|
+
# left lazy it would be cast on every request instead of at class load.
|
|
979
|
+
def in_list(allowed)
|
|
980
|
+
case allowed
|
|
981
|
+
when Hash then allowed.keys.to_set if plain_hash?(allowed)
|
|
982
|
+
when Set then allowed if allowed.instance_of?(Set)
|
|
983
|
+
when Array then allowed.to_a if allowed.instance_of?(Array)
|
|
984
|
+
when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
def plain_hash?(allowed)
|
|
989
|
+
allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# A Symbol is read as its String: it is how Ruby spells a constant
|
|
993
|
+
# string, and a request never carries one, so no cast accepts it as is.
|
|
994
|
+
def cast_in_member(type, member)
|
|
995
|
+
member = member.to_s if member.is_a?(Symbol)
|
|
996
|
+
return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
|
|
997
|
+
|
|
998
|
+
cast(type, member)
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# A Time or DateTime member of a :date field. ActiveSupport compares one
|
|
1002
|
+
# with a Date as INSTANTS, the Date standing for its midnight UTC, so
|
|
1003
|
+
# that instant is the only one that ever equalled a request's date. It
|
|
1004
|
+
# is read as that UTC date; any other instant never matched anything,
|
|
1005
|
+
# and is refused like any member no request could equal. (cast_date
|
|
1006
|
+
# would keep a DateTime whole — it IS a Date — and refuse a Time.)
|
|
1007
|
+
def instant_as_date(member)
|
|
1008
|
+
utc = member.to_time.getutc
|
|
1009
|
+
return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
|
|
1010
|
+
|
|
1011
|
+
[:ok, utc.to_date]
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# What an exported enum lists for one member: the cast value, re-encoded
|
|
1015
|
+
# as JSON — except a :date/:datetime member authored as a String, which
|
|
1016
|
+
# is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
|
|
1017
|
+
# so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
|
|
1018
|
+
# the server refuses. The authored String went through the very cast a
|
|
1019
|
+
# request does, so the server accepts it by construction.
|
|
1020
|
+
def published_in_member(type, member, value)
|
|
1021
|
+
member.is_a?(String) && %i[date datetime].include?(type) ? member : value
|
|
1022
|
+
end
|
|
1023
|
+
|
|
555
1024
|
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
556
1025
|
# and the right semantics for validation.
|
|
557
1026
|
def included_in?(allowed, value)
|
|
@@ -575,6 +1044,13 @@ module Permittable
|
|
|
575
1044
|
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example
|
|
576
1045
|
nullable].freeze
|
|
577
1046
|
|
|
1047
|
+
# One value of each scalar type as a cast produces it, for asking whether
|
|
1048
|
+
# an `in:` Range's endpoints can be compared with that type at all.
|
|
1049
|
+
RANGE_PROBES = {
|
|
1050
|
+
string: "", integer: 0, float: 0.0, decimal: BigDecimal("0"), boolean: true,
|
|
1051
|
+
date: Date.new(2000, 1, 1), datetime: Time.utc(2000)
|
|
1052
|
+
}.freeze
|
|
1053
|
+
|
|
578
1054
|
attr_reader :finalizer
|
|
579
1055
|
|
|
580
1056
|
def initialize
|
|
@@ -615,10 +1091,14 @@ module Permittable
|
|
|
615
1091
|
required = opts.delete(:required) ? true : false
|
|
616
1092
|
|
|
617
1093
|
field = { name: name, kind: :array, required: required, **opts }
|
|
1094
|
+
if field[:required] && field.key?(:default)
|
|
1095
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
1096
|
+
end
|
|
1097
|
+
|
|
618
1098
|
if block
|
|
619
1099
|
raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
|
|
620
1100
|
|
|
621
|
-
field[:fields] = nested_fields!(name, &block)
|
|
1101
|
+
field[:fields] = cascade_sensitive(nested_fields!(name, &block), field[:sensitive])
|
|
622
1102
|
field.delete(:of)
|
|
623
1103
|
else
|
|
624
1104
|
field[:of] = scalar_type!(name, opts[:of] || :string)
|
|
@@ -632,8 +1112,69 @@ module Permittable
|
|
|
632
1112
|
@fields << field
|
|
633
1113
|
end
|
|
634
1114
|
|
|
1115
|
+
# Splice a reusable field group in at this point — the same fields, in the
|
|
1116
|
+
# same order, as if they had been typed here. Works at the top level of a
|
|
1117
|
+
# contract, inside a nested or array block, and inside another group.
|
|
1118
|
+
#
|
|
1119
|
+
# permit_params :create, root: :user do
|
|
1120
|
+
# required :name, :string
|
|
1121
|
+
# optional :address do
|
|
1122
|
+
# use AddressFields
|
|
1123
|
+
# end
|
|
1124
|
+
# end
|
|
1125
|
+
#
|
|
1126
|
+
# `optional: true` relaxes every spliced field, which is how an update
|
|
1127
|
+
# contract reuses a create contract: nothing is mandatory, but `default:`,
|
|
1128
|
+
# types and bounds all still apply. It relaxes the TOP LEVEL only — if a
|
|
1129
|
+
# client sends an address at all, the address's own required sub-fields
|
|
1130
|
+
# still hold.
|
|
1131
|
+
#
|
|
1132
|
+
# `only:`/`except:` select a subset, in the group's own order. Naming a
|
|
1133
|
+
# field the group doesn't declare is a class-load error, so a typo cannot
|
|
1134
|
+
# silently drop a field. A field declared twice still raises, so
|
|
1135
|
+
# overriding one field of a group is deliberate: `use G, except: [:city]`
|
|
1136
|
+
# and then declare `:city` yourself.
|
|
1137
|
+
def use(group, only: nil, except: nil, optional: false)
|
|
1138
|
+
fields = select_group_fields!(group_fields!(group), only: only, except: except)
|
|
1139
|
+
fields = fields.map { |field| field.merge(required: false).freeze } if optional
|
|
1140
|
+
fields.each do |field|
|
|
1141
|
+
field_name!(field[:name])
|
|
1142
|
+
@fields << field
|
|
1143
|
+
end
|
|
1144
|
+
nil
|
|
1145
|
+
end
|
|
1146
|
+
|
|
635
1147
|
private
|
|
636
1148
|
|
|
1149
|
+
def group_fields!(group)
|
|
1150
|
+
return group.fields if group.respond_to?(:fields)
|
|
1151
|
+
|
|
1152
|
+
raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
|
|
1153
|
+
"answering #fields, such as a Permittable::Contract — got #{group.class}"
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
def select_group_fields!(fields, only:, except:)
|
|
1157
|
+
raise ArgumentError, "#{LABEL}: use takes only: OR except:, not both" if only && except
|
|
1158
|
+
|
|
1159
|
+
if only || except
|
|
1160
|
+
wanted = assert_group_names!(fields, only || except, only ? "only" : "except")
|
|
1161
|
+
fields = only ? fields.select { |f| wanted.include?(f[:name]) } : fields.reject { |f| wanted.include?(f[:name]) }
|
|
1162
|
+
end
|
|
1163
|
+
return fields unless fields.empty?
|
|
1164
|
+
|
|
1165
|
+
raise ArgumentError, "#{LABEL}: use selects no fields from the group"
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
def assert_group_names!(fields, names, label)
|
|
1169
|
+
wanted = Array(names).map(&:to_sym)
|
|
1170
|
+
declared = fields.map { |f| f[:name] }
|
|
1171
|
+
missing = wanted - declared
|
|
1172
|
+
return wanted if missing.empty?
|
|
1173
|
+
|
|
1174
|
+
raise ArgumentError, "#{LABEL}: use #{label}: names #{missing.map(&:inspect).join(', ')}, which the group " \
|
|
1175
|
+
"does not declare (it declares: #{declared.map(&:inspect).join(', ')})"
|
|
1176
|
+
end
|
|
1177
|
+
|
|
637
1178
|
def add_field(name, type, required:, opts:, &block)
|
|
638
1179
|
name = field_name!(name)
|
|
639
1180
|
if block
|
|
@@ -642,6 +1183,7 @@ module Permittable
|
|
|
642
1183
|
assert_opts!(name, opts, NESTED_OPTS)
|
|
643
1184
|
field = { name: name, kind: :nested, required: required,
|
|
644
1185
|
fields: nested_fields!(name, &block), **opts }
|
|
1186
|
+
field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
|
|
645
1187
|
validate_message!(field)
|
|
646
1188
|
elsif type&.to_sym == JSON_TYPE
|
|
647
1189
|
assert_opts!(name, opts, JSON_OPTS)
|
|
@@ -693,31 +1235,152 @@ module Permittable
|
|
|
693
1235
|
fields
|
|
694
1236
|
end
|
|
695
1237
|
|
|
1238
|
+
# `sensitive: true` on a nested or array field CASCADES to every field
|
|
1239
|
+
# inside it, and the cascade is resolved HERE, at class load, so that
|
|
1240
|
+
# `field[:sensitive]` stays the single source of truth every reader
|
|
1241
|
+
# consults: the filter registry, the exported schema's `writeOnly`, and
|
|
1242
|
+
# the RSpec matcher's `.sensitive` chain. Resolving it privately inside
|
|
1243
|
+
# the registry walk would have redacted a cascaded child at runtime
|
|
1244
|
+
# while the schema and the matcher went on calling it public.
|
|
1245
|
+
#
|
|
1246
|
+
# It has to cascade: ActiveSupport::ParameterFilter recurses into Hash
|
|
1247
|
+
# and Array values itself and consults proc filters only for the LEAVES,
|
|
1248
|
+
# handing each one the leaf's own key and never the path that led there.
|
|
1249
|
+
# So registering only `payment` is asked about `card_number`, which it
|
|
1250
|
+
# does not match, and redacts nothing inside the container.
|
|
1251
|
+
#
|
|
1252
|
+
# A sub-field opts out with an explicit `sensitive: false`, because
|
|
1253
|
+
# matching is a case-insensitive SUBSTRING match and cascading a generic
|
|
1254
|
+
# name (:id, :name) would redact every parameter app-wide that contains
|
|
1255
|
+
# it. Only `false` opts out; `sensitive: nil` reads as "not stated" and
|
|
1256
|
+
# still inherits.
|
|
1257
|
+
def cascade_sensitive(fields, inherited)
|
|
1258
|
+
updated = fields.map { |field| cascade_field_sensitive(field, inherited) }
|
|
1259
|
+
updated.zip(fields).all? { |new_field, old| new_field.equal?(old) } ? fields : updated.freeze
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
def cascade_field_sensitive(field, inherited)
|
|
1263
|
+
declared = field[:sensitive]
|
|
1264
|
+
effective = declared.nil? ? inherited : declared
|
|
1265
|
+
children = field[:fields] ? cascade_sensitive(field[:fields], effective) : nil
|
|
1266
|
+
unchanged = (effective ? declared == true : declared == false || !field.key?(:sensitive)) &&
|
|
1267
|
+
(children.nil? || children.equal?(field[:fields]))
|
|
1268
|
+
return field if unchanged
|
|
1269
|
+
|
|
1270
|
+
updated = field.merge(sensitive: effective)
|
|
1271
|
+
updated[:fields] = children if children
|
|
1272
|
+
updated.freeze
|
|
1273
|
+
end
|
|
1274
|
+
|
|
696
1275
|
def validate_scalar_opts!(field)
|
|
697
1276
|
name = field[:name]
|
|
698
1277
|
if field[:required] && field.key?(:default)
|
|
699
1278
|
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
700
1279
|
end
|
|
701
1280
|
|
|
702
|
-
if field.key?(:in)
|
|
703
|
-
unless field[:in].respond_to?(:include?)
|
|
704
|
-
raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
assert_satisfiable!(name, :in, field[:in])
|
|
708
|
-
end
|
|
709
|
-
|
|
1281
|
+
resolve_in!(field) if field.key?(:in)
|
|
710
1282
|
validate_string_only_opts!(field)
|
|
711
1283
|
validate_length!(name, field[:length]) if field.key?(:length)
|
|
712
1284
|
validate_required_length!(field)
|
|
713
1285
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
714
1286
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
1287
|
+
resolve_format!(field)
|
|
715
1288
|
resolve_normalizer!(field)
|
|
716
1289
|
validate_authored_value!(field, :default)
|
|
717
1290
|
validate_authored_value!(field, :example)
|
|
718
1291
|
validate_message!(field)
|
|
719
1292
|
end
|
|
720
1293
|
|
|
1294
|
+
# `in:` is a Range (bounds-checked with cover?), a list of values, or an
|
|
1295
|
+
# object of the host's own that answers include? — kept exactly as given,
|
|
1296
|
+
# since nothing here can know what it accepts. It used to be anything
|
|
1297
|
+
# answering include?, which let a String through, and String#include? is
|
|
1298
|
+
# a SUBSTRING test: `in: "free pro"` accepted "e", "fr" and "ee p". A
|
|
1299
|
+
# String is refused here, along with anything answering neither.
|
|
1300
|
+
#
|
|
1301
|
+
# A list (see Coercion.in_list — a Hash lists its keys) is stored cast by
|
|
1302
|
+
# the field's type (see Coercion.cast_in_members), so request-time
|
|
1303
|
+
# matching, the exported enum, the RSpec matcher and the column guard's
|
|
1304
|
+
# enum rule all read the members the runtime compares against. A member
|
|
1305
|
+
# no request value could ever equal is a contract mistake, and fails here
|
|
1306
|
+
# rather than as an `inclusion` on every request.
|
|
1307
|
+
def resolve_in!(field)
|
|
1308
|
+
name = field[:name]
|
|
1309
|
+
allowed = field[:in]
|
|
1310
|
+
if allowed.is_a?(Range)
|
|
1311
|
+
assert_comparable_range!(field, allowed)
|
|
1312
|
+
elsif (members = Coercion.in_list(allowed))
|
|
1313
|
+
cast_in_members!(field, members)
|
|
1314
|
+
elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
|
|
1315
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
|
|
1316
|
+
"or a Hash read as its keys), or an object answering include? " \
|
|
1317
|
+
"(got #{allowed.inspect})#{string_in_hint(allowed)}"
|
|
1318
|
+
end
|
|
1319
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def string_in_hint(allowed)
|
|
1323
|
+
return "" unless allowed.is_a?(String)
|
|
1324
|
+
|
|
1325
|
+
" — String#include? would accept any substring; list the values instead, e.g. in: %w[#{allowed}]"
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# `published` is stored only where it differs from the cast members (a
|
|
1329
|
+
# String-authored :date/:datetime member), so it is read as an override.
|
|
1330
|
+
def cast_in_members!(field, members)
|
|
1331
|
+
status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
|
|
1332
|
+
unless status == :ok
|
|
1333
|
+
# cast is the offending member here, and published its error code.
|
|
1334
|
+
# nil is the one member written on purpose, meaning "null is allowed"
|
|
1335
|
+
# — but an absent value never reaches in:, so the fix is worth naming.
|
|
1336
|
+
hint = cast.nil? ? " — an absent value never reaches in:; declare nullable: true to accept an explicit null" : ""
|
|
1337
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} contains #{cast.inspect}, " \
|
|
1338
|
+
"which is not a valid :#{field[:type]} (#{published})#{hint}"
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
field[:in] = freeze_in_members(cast)
|
|
1342
|
+
field[:in_published] = freeze_authored(published) unless published == cast.to_a
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
def freeze_in_members(members)
|
|
1346
|
+
members.is_a?(Set) ? members.to_set { |member| freeze_authored(member) }.freeze : freeze_authored(members)
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# A Range is kept exactly as written, unlike a list: casting its
|
|
1350
|
+
# endpoints would change what it means. `0..Float::INFINITY` on a :float
|
|
1351
|
+
# and `1.5..3` on an :integer are real bounds whose endpoints no cast
|
|
1352
|
+
# accepts, and a :decimal's `0..100` would become BigDecimal endpoints
|
|
1353
|
+
# that export as the STRING "0.0" where `minimum` needs a number.
|
|
1354
|
+
#
|
|
1355
|
+
# What does fail every request is an endpoint the cast value cannot be
|
|
1356
|
+
# compared with — `"1".."5"` on an :integer, `1..5` on a :string,
|
|
1357
|
+
# `.."9.99"` on a :decimal. cover? then answers false for every value, so
|
|
1358
|
+
# that is caught here. The probe asks exactly what cover? will — begin
|
|
1359
|
+
# <=> value, then value <=> end — so whatever the host's own <=> allows
|
|
1360
|
+
# (ActiveSupport lets a Date range bound a :datetime) is allowed here too.
|
|
1361
|
+
def assert_comparable_range!(field, range)
|
|
1362
|
+
probe = RANGE_PROBES.fetch(field[:type])
|
|
1363
|
+
# A NaN endpoint compares to nothing, by design, whatever it stands
|
|
1364
|
+
# beside — not evidence of a wrong-TYPED bound (a String range on an
|
|
1365
|
+
# :integer), which is what this check exists to catch. It is left
|
|
1366
|
+
# alone here exactly as an infinite endpoint already is (INFINITY
|
|
1367
|
+
# compares fine); the exporter separately omits it, since it is
|
|
1368
|
+
# never `finite?`.
|
|
1369
|
+
# Wrapped in an Array so a `false` endpoint still reads as found.
|
|
1370
|
+
stray = if !range.begin.nil? && !nan?(range.begin) && (range.begin <=> probe).nil? then [range.begin]
|
|
1371
|
+
elsif !range.end.nil? && !nan?(range.end) && (probe <=> range.end).nil? then [range.end]
|
|
1372
|
+
end
|
|
1373
|
+
return unless stray
|
|
1374
|
+
|
|
1375
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} is a Range of #{stray.first.class} " \
|
|
1376
|
+
"(#{range.inspect}), which a :#{field[:type]} value cannot be compared with — " \
|
|
1377
|
+
"no value could satisfy it; write the bounds as :#{field[:type]} values"
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def nan?(value)
|
|
1381
|
+
value.respond_to?(:nan?) && value.nan?
|
|
1382
|
+
end
|
|
1383
|
+
|
|
721
1384
|
def validate_json_opts!(field)
|
|
722
1385
|
name = field[:name]
|
|
723
1386
|
if field[:required] && field.key?(:default)
|
|
@@ -816,6 +1479,28 @@ module Permittable
|
|
|
816
1479
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
|
|
817
1480
|
end
|
|
818
1481
|
|
|
1482
|
+
# A Symbol (or String) `format:` names a preset; a Regexp is used as
|
|
1483
|
+
# given. Resolving here means request-time matching stays a plain
|
|
1484
|
+
# Regexp#match?, and an authored `default:`/`example:` is checked against
|
|
1485
|
+
# the resolved pattern like any other. The preset NAME is kept on the
|
|
1486
|
+
# field so exporters and the RSpec matcher can speak in presets.
|
|
1487
|
+
def resolve_format!(field)
|
|
1488
|
+
preset = field[:format]
|
|
1489
|
+
return if preset.nil? || preset.is_a?(Regexp)
|
|
1490
|
+
|
|
1491
|
+
unless preset.is_a?(Symbol) || preset.is_a?(String)
|
|
1492
|
+
raise ArgumentError, "#{LABEL}: :format for field :#{field[:name]} must be a Regexp or a preset name " \
|
|
1493
|
+
"(presets: #{FORMATS.keys.join(', ')})"
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
spec = FORMATS.fetch(preset.to_sym) do
|
|
1497
|
+
raise ArgumentError, "#{LABEL}: unknown :format preset :#{preset} for field :#{field[:name]} " \
|
|
1498
|
+
"(presets: #{FORMATS.keys.join(', ')}, or pass a Regexp)"
|
|
1499
|
+
end
|
|
1500
|
+
field[:format_name] = preset.to_sym
|
|
1501
|
+
field[:format] = spec[:pattern]
|
|
1502
|
+
end
|
|
1503
|
+
|
|
819
1504
|
def resolve_normalizer!(field)
|
|
820
1505
|
normalizer = field[:normalize]
|
|
821
1506
|
return if normalizer.nil?
|
|
@@ -829,82 +1514,67 @@ module Permittable
|
|
|
829
1514
|
|
|
830
1515
|
# An authored value (`default:`, or a documentation `example:`) must
|
|
831
1516
|
# satisfy the field's own contract — catching a lie at class load beats
|
|
832
|
-
# shipping it to every request (or publishing it in generated docs)
|
|
833
|
-
#
|
|
834
|
-
#
|
|
835
|
-
#
|
|
1517
|
+
# shipping it to every request (or publishing it in generated docs) —
|
|
1518
|
+
# which is checked here by normalizing and casting it, same as a request's
|
|
1519
|
+
# value. `default: " free "` with `normalize: :squish` is checked as
|
|
1520
|
+
# "free"; `default: "18"` on an :integer is checked as 18.
|
|
1521
|
+
#
|
|
1522
|
+
# What is STORED from that differs by whether the field has `transform:`.
|
|
1523
|
+
# With none, the cast result is stored — the form a request sending the
|
|
1524
|
+
# same value gets, and what used to be thrown away: `default: "18"` used
|
|
1525
|
+
# to be handed to every request omitting it as the String "18", and
|
|
1526
|
+
# `:boolean, default: "false"` gave the app a truthy String.
|
|
1527
|
+
# With a `transform:`, the value is stored exactly AS AUTHORED instead —
|
|
1528
|
+
# `transform:` never runs on a default (see AuthoredValues), so casting it
|
|
1529
|
+
# here would silently change its type out from under an author who, per
|
|
1530
|
+
# the README, writes such a default in the shape the action should
|
|
1531
|
+
# receive: `default: 25` beside `transform: ->(v) { v.to_i }` on a
|
|
1532
|
+
# :string field means the app gets the Integer 25 either way, whether the
|
|
1533
|
+
# request sent "25" (cast then transformed) or omitted the field
|
|
1534
|
+
# (authored as the already-final Integer).
|
|
836
1535
|
def validate_authored_value!(field, opt)
|
|
837
1536
|
return unless field.key?(opt)
|
|
838
1537
|
return if authored_nil!(field, opt)
|
|
839
1538
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1539
|
+
# Copied first, like a request's String, so a mutating `normalize:`
|
|
1540
|
+
# proc cannot rewrite the host's own literal.
|
|
1541
|
+
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1542
|
+
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1543
|
+
status, result = Coercion.check_scalar(field, value)
|
|
1544
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1545
|
+
|
|
1546
|
+
field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
|
|
1547
|
+
end
|
|
1548
|
+
|
|
1549
|
+
# An array's authored value is validated by the REQUEST walker itself
|
|
1550
|
+
# (see AuthoredValues) exactly as validate_authored_value! validates a
|
|
1551
|
+
# scalar's: elements cast, nested hashes and arrays read at every depth, a
|
|
1552
|
+
# sub-field's own default: filled in, `""` on a nullable sub-field made
|
|
1553
|
+
# the explicit nil a request would get, keys the block does not declare
|
|
1554
|
+
# dropped (as `unknown: :ignore` drops them), and the array's own
|
|
1555
|
+
# `validate:` run over the result. A hand-rolled one-level check used to
|
|
1556
|
+
# cast only the top level of each element, and got every one of those
|
|
1557
|
+
# wrong.
|
|
1558
|
+
#
|
|
1559
|
+
# What is STORED follows the same split as a scalar's: without
|
|
1560
|
+
# `transform:`, the walker's read (exactly what a request sending it
|
|
1561
|
+
# gets); with one, the array exactly AS AUTHORED — the walker still runs,
|
|
1562
|
+
# so a declaration mistake (an element `validate:` refuses, a sub-field
|
|
1563
|
+
# default out of bounds) still fails at class load, but its cast result
|
|
1564
|
+
# is discarded rather than stored. `transform:` itself is deliberately
|
|
1565
|
+
# never run on a default either way — see AuthoredValues.
|
|
847
1566
|
def validate_array_authored_value!(field, opt)
|
|
848
1567
|
value = field[opt]
|
|
849
1568
|
return if authored_nil!(field, opt)
|
|
850
1569
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
851
1570
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
# The nested-block counterpart of the of: element check below. Without it
|
|
858
|
-
# `field[:of]` was nil for a block array, so its `default:` skipped
|
|
859
|
-
# validation entirely and whatever was authored went straight to every
|
|
860
|
-
# request that omitted the key. Shallow in the same way the of: check is:
|
|
861
|
-
# required sub-fields must be present and scalar ones must satisfy their
|
|
862
|
-
# own contract, which is what an authored value gets wrong.
|
|
863
|
-
def validate_array_element_hashes!(field, opt, value)
|
|
864
|
-
value.each do |element|
|
|
865
|
-
unless element.is_a?(Hash)
|
|
866
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains #{element.class} " \
|
|
867
|
-
"where the block declares a hash"
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
# Wrapped the way permittable_check_element wraps an element at
|
|
871
|
-
# request time, so class load reads keys exactly as a request does.
|
|
872
|
-
indifferent = ActiveSupport::HashWithIndifferentAccess.new(element)
|
|
873
|
-
field[:fields].each { |sub| validate_array_element_field!(field, opt, indifferent, sub) }
|
|
874
|
-
end
|
|
875
|
-
end
|
|
876
|
-
|
|
877
|
-
def validate_array_element_field!(field, opt, element, sub)
|
|
878
|
-
# Normalized before absence is read, and absence read with the runtime's
|
|
879
|
-
# own rule: a default: is applied WITHOUT revalidation, so anything this
|
|
880
|
-
# check waves through is handed to the app unexamined — and "" here used
|
|
881
|
-
# to mean a default could carry the very value a client is refused.
|
|
882
|
-
value = Coercion.apply_normalize(sub[:normalize], element[sub[:name]])
|
|
883
|
-
if Coercion.absent_value?(value)
|
|
884
|
-
# nullable: splits that rule exactly as permittable_explicit_null?
|
|
885
|
-
# does — a key present but empty is an explicit null, not an absence.
|
|
886
|
-
return if sub[:nullable] && element.key?(sub[:name])
|
|
887
|
-
return unless sub[:required]
|
|
888
|
-
|
|
889
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} is missing :#{sub[:name]}, " \
|
|
890
|
-
"which the block declares as required"
|
|
1571
|
+
read, violations = AuthoredValues.read_array(field, value)
|
|
1572
|
+
unless violations.empty?
|
|
1573
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
|
|
1574
|
+
"#{AuthoredValues.summary(violations)}"
|
|
891
1575
|
end
|
|
892
|
-
return unless sub[:kind] == :scalar
|
|
893
|
-
|
|
894
|
-
status, code = Coercion.check_scalar(sub, value)
|
|
895
|
-
return if status == :ok
|
|
896
1576
|
|
|
897
|
-
|
|
898
|
-
"violating its own contract (#{code})"
|
|
899
|
-
end
|
|
900
|
-
|
|
901
|
-
def validate_array_elements!(field, opt, value)
|
|
902
|
-
value.each do |element|
|
|
903
|
-
status, code = Coercion.cast(field[:of], element)
|
|
904
|
-
next if status == :ok
|
|
905
|
-
|
|
906
|
-
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
|
|
907
|
-
end
|
|
1577
|
+
field[opt] = freeze_authored(field[:transform] ? value : read)
|
|
908
1578
|
end
|
|
909
1579
|
|
|
910
1580
|
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
@@ -1084,13 +1754,35 @@ module Permittable
|
|
|
1084
1754
|
return if checked.empty?
|
|
1085
1755
|
|
|
1086
1756
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
1757
|
+
allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
|
|
1087
1758
|
begin
|
|
1088
|
-
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1759
|
+
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1760
|
+
types: types, check_types: Permittable.check_column_types,
|
|
1761
|
+
allowed: allowed)
|
|
1089
1762
|
rescue ArgumentError => e
|
|
1763
|
+
# The type error carries its own guidance; only the missing-column one
|
|
1764
|
+
# needs the virtual: hint appended.
|
|
1765
|
+
raise e unless e.message.include?("does not exist in the database")
|
|
1766
|
+
|
|
1090
1767
|
raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
|
|
1091
1768
|
end
|
|
1092
1769
|
end
|
|
1093
1770
|
|
|
1771
|
+
# `sensitive: true` on a nested or array field CASCADES to everything
|
|
1772
|
+
# inside it, because Rails' parameter filtering matches the leaf key it is
|
|
1773
|
+
# currently looking at — never the path that led there. Registering only
|
|
1774
|
+
# the container's own name therefore redacted nothing it promised: the
|
|
1775
|
+
# filter is handed ("payment", {...}), a Hash is not a String so nothing
|
|
1776
|
+
# is replaced, and it then recurses and asks about "card_number", which
|
|
1777
|
+
# was never registered.
|
|
1778
|
+
#
|
|
1779
|
+
# A sub-field opts out with an explicit `sensitive: false`. That escape
|
|
1780
|
+
# hatch exists because matching is a case-insensitive SUBSTRING match, so
|
|
1781
|
+
# cascading a generic name (:id, :name) would redact every parameter
|
|
1782
|
+
# app-wide that happens to contain it — occasionally a worse outcome than
|
|
1783
|
+
# the leak it prevents.
|
|
1784
|
+
# The cascade is already resolved on the field data (see
|
|
1785
|
+
# ContractBuilder#cascade_sensitive), so this only has to read it.
|
|
1094
1786
|
def register_sensitive_params(fields)
|
|
1095
1787
|
fields.each do |field|
|
|
1096
1788
|
Permittable.register_sensitive_parameter(field[:name]) if field[:sensitive]
|
|
@@ -1103,18 +1795,31 @@ module Permittable
|
|
|
1103
1795
|
# defaulted values for the given action (default: the current action).
|
|
1104
1796
|
# Absent optional fields are omitted. Raises InvalidParameters on
|
|
1105
1797
|
# violation; raises ArgumentError when no contract covers the action
|
|
1106
|
-
# (that is a programmer error, not a client error).
|
|
1798
|
+
# (that is a programmer error, not a client error).
|
|
1799
|
+
#
|
|
1800
|
+
# Memoized per action, and the memo remembers the OUTCOME rather than only
|
|
1801
|
+
# a success: a rejection is stored and re-raised. Validation is therefore
|
|
1802
|
+
# observable exactly once per action per request, which the
|
|
1803
|
+
# "invalid_parameters.permittable" event depends on — memoizing only
|
|
1804
|
+
# successes meant a rejected request that was read twice (an action calling
|
|
1805
|
+
# permittable_violations before permitted_params, say) instrumented twice
|
|
1806
|
+
# and double-counted itself in every dashboard.
|
|
1107
1807
|
def permitted_params(action = nil)
|
|
1108
1808
|
action = (action || permittable_action_name).to_s
|
|
1109
1809
|
raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty?
|
|
1110
1810
|
|
|
1111
1811
|
@permittable_validated ||= {}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1812
|
+
outcome = @permittable_validated.fetch(action) do
|
|
1813
|
+
@permittable_validated[action] = permittable_outcome_for(action)
|
|
1814
|
+
end
|
|
1815
|
+
# `cause: nil` because a memoized rejection is raised from wherever the
|
|
1816
|
+
# action happens to read the params next — possibly inside a `rescue` of
|
|
1817
|
+
# something unrelated, whose exception Ruby would otherwise adopt as this
|
|
1818
|
+
# error's cause for good. The object and its original backtrace (the
|
|
1819
|
+
# first raise site, where the violation was found) are preserved.
|
|
1820
|
+
raise outcome, cause: nil if outcome.is_a?(InvalidParameters)
|
|
1116
1821
|
|
|
1117
|
-
|
|
1822
|
+
outcome
|
|
1118
1823
|
end
|
|
1119
1824
|
|
|
1120
1825
|
# before_action entry point (public so hosts can `skip_before_action
|
|
@@ -1163,13 +1868,60 @@ module Permittable
|
|
|
1163
1868
|
|
|
1164
1869
|
private
|
|
1165
1870
|
|
|
1871
|
+
# ParamsWrapper copies a JSON body under the controller's wrapper key
|
|
1872
|
+
# (`user` for UsersController) — on by default in a Rails app — and a
|
|
1873
|
+
# rootless contract then saw that copy as an unknown top-level key on every
|
|
1874
|
+
# well-formed request. Whether the copy is Rails' own has to be read HERE,
|
|
1875
|
+
# before ParamsWrapper#process_action (next in the chain) runs: once it has
|
|
1876
|
+
# wrapped, `_wrapper_enabled?` answers false, because params now carry the
|
|
1877
|
+
# key. Asking afterwards could not tell Rails' copy from a client that sent
|
|
1878
|
+
# `user` itself, which is exactly the key the check must still flag.
|
|
1879
|
+
# Private, like the method it wraps — a public one would become an action.
|
|
1880
|
+
# A plain duck has no process_action and no ParamsWrapper, so this never
|
|
1881
|
+
# runs there, and the guards keep an actionpack-free host inert.
|
|
1882
|
+
# Assigned on every request, never only when true: a controller instance
|
|
1883
|
+
# dispatched twice would otherwise carry one request's exemption into the
|
|
1884
|
+
# next, where a `user` the client did send would pass as Rails' copy.
|
|
1885
|
+
def process_action(*)
|
|
1886
|
+
@permittable_wrapper_key = permittable_wrapper_copy_key
|
|
1887
|
+
super
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1890
|
+
# The wrapper key, if ParamsWrapper is about to copy the body under it;
|
|
1891
|
+
# nil otherwise. `_wrapper_enabled?` alone is not enough: it asks the
|
|
1892
|
+
# string-keyed params for the key AS CONFIGURED, so `wrap_parameters :user`
|
|
1893
|
+
# — the form the Rails docs use — answers "not sent" even when the client
|
|
1894
|
+
# sent `user` itself, and Rails wraps anyway. Asking the same params for
|
|
1895
|
+
# the String keeps that client's key the client's, whichever spelling the
|
|
1896
|
+
# host chose.
|
|
1897
|
+
def permittable_wrapper_copy_key
|
|
1898
|
+
return unless respond_to?(:_wrapper_enabled?, true) && respond_to?(:_wrapper_key, true) && _wrapper_enabled?
|
|
1899
|
+
|
|
1900
|
+
key = _wrapper_key.to_s
|
|
1901
|
+
key unless request.parameters.key?(key)
|
|
1902
|
+
end
|
|
1903
|
+
|
|
1904
|
+
# The value permitted_params memoizes: the validated params, or the
|
|
1905
|
+
# InvalidParameters that rejected them. ArgumentError is deliberately NOT
|
|
1906
|
+
# memoized — a contract that does not cover the action is a bug to fix, not
|
|
1907
|
+
# a verdict on this request, so it raises fresh on every call.
|
|
1908
|
+
def permittable_outcome_for(action)
|
|
1909
|
+
rule = self.class.permit_rule_for(action)
|
|
1910
|
+
raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
|
|
1911
|
+
|
|
1912
|
+
validate_params_contract!(rule, action)
|
|
1913
|
+
rescue InvalidParameters => e
|
|
1914
|
+
e
|
|
1915
|
+
end
|
|
1916
|
+
|
|
1166
1917
|
def validate_params_contract!(rule, action)
|
|
1167
1918
|
violations = []
|
|
1168
1919
|
source = permittable_root_hash(rule, violations)
|
|
1169
1920
|
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
1170
1921
|
if source
|
|
1171
|
-
|
|
1172
|
-
|
|
1922
|
+
checked = rule[:root] ? source : permittable_without_wrapper_copy(rule[:fields], source)
|
|
1923
|
+
result = permittable_check_hash(rule[:fields], checked, path: rule[:root] ? rule[:root].to_s : nil,
|
|
1924
|
+
unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
|
|
1173
1925
|
end
|
|
1174
1926
|
# finalize only sees a hash every field vouched for — never garbage.
|
|
1175
1927
|
result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
|
|
@@ -1201,6 +1953,10 @@ module Permittable
|
|
|
1201
1953
|
end
|
|
1202
1954
|
return ActiveSupport::HashWithIndifferentAccess.new unless source
|
|
1203
1955
|
|
|
1956
|
+
# Deliberately NOT deep-copied, unlike the enforce path's result: this is
|
|
1957
|
+
# the pre-contract app's own params, and `params.permit` hands its Strings
|
|
1958
|
+
# back by reference too — copying here would change behaviour in the one
|
|
1959
|
+
# mode whose promise is that nothing changes.
|
|
1204
1960
|
passed = ActiveSupport::HashWithIndifferentAccess.new(source)
|
|
1205
1961
|
rule[:root] ? passed : passed.except(*MONITOR_DROPPED_KEYS)
|
|
1206
1962
|
end
|
|
@@ -1219,7 +1975,121 @@ module Permittable
|
|
|
1219
1975
|
end
|
|
1220
1976
|
|
|
1221
1977
|
def permittable_violation_summary(violations)
|
|
1222
|
-
|
|
1978
|
+
# The param is the client-controlled part; the message (or code) is the
|
|
1979
|
+
# developer's, so it is handed over separately and never escaped — a
|
|
1980
|
+
# YAML `|` message ending in "\n" must not quote every name it follows.
|
|
1981
|
+
#
|
|
1982
|
+
# An unknown-key violation's `param:` is already reportable_text — valid
|
|
1983
|
+
# UTF-8, but scrubbed to U+FFFD wherever the key wasn't. That is right
|
|
1984
|
+
# for `details`/instrumentation (a machine reads it and only needs it not
|
|
1985
|
+
# to crash `to_json`), but prose can do better: permittable_prose_utf8
|
|
1986
|
+
# keeps a legacy byte transcodable and an invalid one visible as \xNN
|
|
1987
|
+
# rather than replacing it, so prose reads the RAW key when one was
|
|
1988
|
+
# saved (see permittable_unknown_key_violation), and falls back to the
|
|
1989
|
+
# param for any other violation.
|
|
1990
|
+
permittable_prose_list(violations) do |v|
|
|
1991
|
+
name = @permittable_unknown_key_raw&.[](v) || v[:param].to_s
|
|
1992
|
+
[name, v[:message] ? " #{v[:message]}" : " (#{v[:code]})"]
|
|
1993
|
+
end
|
|
1994
|
+
end
|
|
1995
|
+
|
|
1996
|
+
# See PROSE_LIST_LIMIT. `unknown: :error` on a request carrying 50,000
|
|
1997
|
+
# undeclared keys used to produce a 50,000-item sentence — a megabyte of
|
|
1998
|
+
# log line, or of exception message handed to every error tracker.
|
|
1999
|
+
# The block returns one item's name, or [name, suffix], and is called
|
|
2000
|
+
# only for the items actually shown — the rest are counted, never rendered.
|
|
2001
|
+
def permittable_prose_list(items)
|
|
2002
|
+
shown = items.first(PROSE_LIST_LIMIT).map { |item| permittable_prose_item(*yield(item)) }.join(", ")
|
|
2003
|
+
return shown if items.length <= PROSE_LIST_LIMIT
|
|
2004
|
+
|
|
2005
|
+
"#{shown}, and #{items.length - PROSE_LIST_LIMIT} more"
|
|
2006
|
+
end
|
|
2007
|
+
|
|
2008
|
+
# See PROSE_ITEM_LIMIT, PROSE_UNSAFE and PROSE_AMBIGUOUS. The item is the
|
|
2009
|
+
# name plus the suffix, truncated as one. Only a name that needs it is
|
|
2010
|
+
# quoted and escaped, judged by the part of it the truncated item would
|
|
2011
|
+
# SHOW — a control character past the cut is not printed, so it quotes
|
|
2012
|
+
# nothing. Every ordinary name therefore prints exactly as before, just
|
|
2013
|
+
# always as UTF-8: a Windows-1252 or binary key is converted rather than
|
|
2014
|
+
# written raw, and names of mixed encodings can be joined.
|
|
2015
|
+
def permittable_prose_item(name, suffix = "")
|
|
2016
|
+
text = permittable_prose_utf8(name[0, PROSE_SCAN_LIMIT])
|
|
2017
|
+
suffix = permittable_prose_utf8(suffix)
|
|
2018
|
+
more = !name[PROSE_SCAN_LIMIT].nil?
|
|
2019
|
+
fits = !more && text.length + suffix.length <= PROSE_ITEM_LIMIT
|
|
2020
|
+
shown = fits ? text : text[0, PROSE_ITEM_LIMIT - 3]
|
|
2021
|
+
if !shown.valid_encoding? || shown.match?(PROSE_UNSAFE) || shown.match?(PROSE_AMBIGUOUS)
|
|
2022
|
+
return permittable_prose_quoted(text, more, suffix)
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
fits ? "#{text}#{suffix}" : "#{"#{text}#{suffix}"[0, PROSE_ITEM_LIMIT - 3]}..."
|
|
2026
|
+
end
|
|
2027
|
+
|
|
2028
|
+
# The name is truncated by whole escapes, never through one: cutting the
|
|
2029
|
+
# escaped text at a fixed width could print a dangling backslash, or half
|
|
2030
|
+
# of an escape. When the name itself is cut, the "..." goes outside the
|
|
2031
|
+
# closing quote, so the quotes still delimit exactly what is shown. A
|
|
2032
|
+
# quoted name that fits the limit on its own is never cut: the suffix is
|
|
2033
|
+
# cut instead, to whatever room is left — possibly none — and the "..."
|
|
2034
|
+
# that marks it may then run up to three characters past the limit. A
|
|
2035
|
+
# dropped developer suffix is better flagged than hidden, and the name is
|
|
2036
|
+
# the part a reader is there for. Only as many characters are escaped as
|
|
2037
|
+
# can be shown.
|
|
2038
|
+
def permittable_prose_quoted(text, more, suffix)
|
|
2039
|
+
budget = PROSE_ITEM_LIMIT - 2 # the two quotes
|
|
2040
|
+
pieces = []
|
|
2041
|
+
length = 0
|
|
2042
|
+
text.each_char do |char|
|
|
2043
|
+
pieces << permittable_prose_escape(char)
|
|
2044
|
+
length += pieces.last.length
|
|
2045
|
+
break if length > budget
|
|
2046
|
+
end
|
|
2047
|
+
if !more && length <= budget
|
|
2048
|
+
quoted = "\"#{pieces.join}\""
|
|
2049
|
+
return "#{quoted}#{suffix}" if quoted.length + suffix.length <= PROSE_ITEM_LIMIT
|
|
2050
|
+
|
|
2051
|
+
return "#{quoted}#{suffix[0, [PROSE_ITEM_LIMIT - 3 - quoted.length, 0].max]}..."
|
|
2052
|
+
end
|
|
2053
|
+
|
|
2054
|
+
length -= pieces.pop.length while length > budget - 3
|
|
2055
|
+
"\"#{pieces.join}\"..."
|
|
2056
|
+
end
|
|
2057
|
+
|
|
2058
|
+
# A byte that is not valid UTF-8 is shown as \xNN rather than passed
|
|
2059
|
+
# through: it is not a character a person can read, and a lone 0x85 or
|
|
2060
|
+
# 0x9B is NEL or CSI to a Latin-1 terminal. A character beyond the BMP
|
|
2061
|
+
# (the Cf tag characters) is \u{XXXXX}, since \uXXXX holds only four digits.
|
|
2062
|
+
def permittable_prose_escape(char)
|
|
2063
|
+
return char.bytes.map { |byte| format('\x%02X', byte) }.join unless char.valid_encoding?
|
|
2064
|
+
|
|
2065
|
+
PROSE_ESCAPES.fetch(char) do
|
|
2066
|
+
next char unless char.match?(PROSE_UNSAFE)
|
|
2067
|
+
|
|
2068
|
+
char.ord > 0xFFFF ? format('\u{%X}', char.ord) : format('\u%04X', char.ord)
|
|
2069
|
+
end
|
|
2070
|
+
end
|
|
2071
|
+
|
|
2072
|
+
# PROSE_UNSAFE is a UTF-8 pattern, and matching it against a binary key
|
|
2073
|
+
# with high bytes raises Encoding::CompatibilityError — a log line must
|
|
2074
|
+
# never be what fails a request. A binary key has no charset to convert
|
|
2075
|
+
# from, and Rack hands UTF-8 bytes over as binary, so it is read as UTF-8.
|
|
2076
|
+
# A key in a real encoding is transcoded character by character: what
|
|
2077
|
+
# maps is converted, and only a byte that does not (Windows-1252 leaves
|
|
2078
|
+
# 0x81, 0x8D, 0x8F, 0x90 and 0x9D undefined) is kept as an invalid byte,
|
|
2079
|
+
# which the escaper then shows as \xNN — rather than reading the whole
|
|
2080
|
+
# key as UTF-8, which turned a mappable é into \xE9 and mojibake into
|
|
2081
|
+
# characters the client never sent.
|
|
2082
|
+
def permittable_prose_utf8(item)
|
|
2083
|
+
return item if item.encoding == Encoding::UTF_8
|
|
2084
|
+
return item.dup.force_encoding(Encoding::UTF_8) if item.encoding == Encoding::BINARY || item.ascii_only?
|
|
2085
|
+
|
|
2086
|
+
converter = Encoding::Converter.new(item.encoding, Encoding::UTF_8)
|
|
2087
|
+
source = item.dup
|
|
2088
|
+
out = String.new(encoding: Encoding::UTF_8)
|
|
2089
|
+
out << converter.primitive_errinfo[3].force_encoding(Encoding::UTF_8) until converter.primitive_convert(source, out) == :finished
|
|
2090
|
+
out
|
|
2091
|
+
rescue EncodingError # no converter, as for a dummy encoding such as UTF-7
|
|
2092
|
+
item.dup.force_encoding(Encoding::UTF_8)
|
|
1223
2093
|
end
|
|
1224
2094
|
|
|
1225
2095
|
# One violation detail entry. A field's `message:` (String, or Hash keyed
|
|
@@ -1261,12 +2131,21 @@ module Permittable
|
|
|
1261
2131
|
raw = permittable_plain_params
|
|
1262
2132
|
return raw unless rule[:root]
|
|
1263
2133
|
|
|
1264
|
-
|
|
2134
|
+
key = rule[:root].to_s
|
|
2135
|
+
value = raw[key]
|
|
1265
2136
|
return value if value.is_a?(Hash)
|
|
1266
2137
|
|
|
2138
|
+
# A root that is absent and a root sent with the wrong shape
|
|
2139
|
+
# ({"user": "bob"}) are different client mistakes, and telling a client
|
|
2140
|
+
# that the key it just sent is "missing" sends it looking in the wrong
|
|
2141
|
+
# place. Absence is the gem's own definition of it, so `{"user": ""}`
|
|
2142
|
+
# still reads as missing. Either way the envelope is malformed, so both
|
|
2143
|
+
# remain a 400.
|
|
2144
|
+
#
|
|
1267
2145
|
# No field declares the root, so message resolution can only come from
|
|
1268
2146
|
# I18n ({} has no :message).
|
|
1269
|
-
|
|
2147
|
+
code = permittable_absent?(value, raw, key) ? "missing" : "invalid_type"
|
|
2148
|
+
violations << permittable_violation({}, key, code)
|
|
1270
2149
|
nil
|
|
1271
2150
|
end
|
|
1272
2151
|
|
|
@@ -1310,7 +2189,7 @@ module Permittable
|
|
|
1310
2189
|
# Already normalized by permittable_normalized, before the absence rule.
|
|
1311
2190
|
permittable_check_whole(field, Coercion.check_scalar(field, value), full, result, violations: violations)
|
|
1312
2191
|
when :json
|
|
1313
|
-
permittable_check_whole(field,
|
|
2192
|
+
permittable_check_whole(field, permittable_check_json(field, value), full, result, violations: violations)
|
|
1314
2193
|
when :nested
|
|
1315
2194
|
if value.is_a?(Hash)
|
|
1316
2195
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
@@ -1327,33 +2206,82 @@ module Permittable
|
|
|
1327
2206
|
end
|
|
1328
2207
|
end
|
|
1329
2208
|
|
|
2209
|
+
# Coercion.check_json, with the copy the result needs made in the middle.
|
|
2210
|
+
# The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2211
|
+
# rebuilt its containers but not the Strings inside them, so it is
|
|
2212
|
+
# deep-copied for the reason permittable_normalized copies a String — but
|
|
2213
|
+
# only once it is within its bounds. Copying first meant a megabyte
|
|
2214
|
+
# payload refused on `length:` or `max_depth:` was copied in full just to
|
|
2215
|
+
# be refused, undoing the early exit those bounds exist for.
|
|
2216
|
+
def permittable_check_json(field, value)
|
|
2217
|
+
status, code = Coercion.check_json_bounds(field, value)
|
|
2218
|
+
return [status, code] unless status == :ok
|
|
2219
|
+
|
|
2220
|
+
Coercion.check_custom(field[:validate], value.deep_dup)
|
|
2221
|
+
end
|
|
2222
|
+
|
|
1330
2223
|
# The shared tail of the two kinds whose entire value is checked in one
|
|
1331
2224
|
# call — a scalar, or an opaque hash. A clean value is transformed into the
|
|
1332
2225
|
# result; anything else records its code.
|
|
1333
2226
|
def permittable_check_whole(field, outcome, full, result, violations:)
|
|
1334
2227
|
status, out = outcome
|
|
1335
2228
|
if status == :ok
|
|
1336
|
-
|
|
1337
|
-
result[field[:name].to_s] = out
|
|
2229
|
+
result[field[:name].to_s] = permittable_transform(field, out)
|
|
1338
2230
|
else
|
|
1339
2231
|
violations << permittable_violation(field, full, out)
|
|
1340
2232
|
end
|
|
1341
2233
|
end
|
|
1342
2234
|
|
|
1343
2235
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
2236
|
+
# `length:` is a BOUND, not a report. An array outside it is rejected
|
|
2237
|
+
# whatever its contents, so checking those contents can only add work and
|
|
2238
|
+
# noise: a 200k-element payload against `length: 0..10` used to cast every
|
|
2239
|
+
# element, collect 200k more violations, and answer with a multi-megabyte
|
|
2240
|
+
# 422 — for a request already refused by its first check. Stopping here
|
|
2241
|
+
# keeps the cost of an oversized array proportional to rejecting it.
|
|
2242
|
+
if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
2243
|
+
violations << permittable_violation(field, path, "length")
|
|
2244
|
+
return nil
|
|
2245
|
+
end
|
|
2246
|
+
|
|
1344
2247
|
before = violations.length
|
|
1345
|
-
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
1346
2248
|
out = value.each_with_index.map do |element, index|
|
|
1347
2249
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
1348
2250
|
end
|
|
1349
|
-
|
|
2251
|
+
# validate: and transform: see only a fully-valid array. A partially-nil
|
|
2252
|
+
# one (element violations) would hand user code garbage it never agreed
|
|
2253
|
+
# to see — and for validate: that was a crash, not just garbage:
|
|
2254
|
+
# `validate: ->(a) { a.sum < 100 }` sent `["x", 2]` raised TypeError on
|
|
2255
|
+
# the nil where "x" failed to cast, turning the element's 422 into a 500.
|
|
2256
|
+
#
|
|
2257
|
+
# The cost is real and accepted: the whole-array verdict is no longer
|
|
2258
|
+
# reported ALONGSIDE element violations. `[1, "x", 1]` against a
|
|
2259
|
+
# uniqueness validator reports only `ids[1]`; the client fixes it,
|
|
2260
|
+
# resends, and only then learns of the duplicate. Running app code over
|
|
2261
|
+
# nils it never agreed to handle is the worse failure.
|
|
2262
|
+
#
|
|
2263
|
+
# An undeclared key inside an element (`unknown: :error`) is not such a
|
|
2264
|
+
# violation: it removes nothing from the element validate: sees, so it
|
|
2265
|
+
# does not stop validate: from running.
|
|
2266
|
+
#
|
|
2267
|
+
# transform: is stricter, as on the scalar path: it runs only when
|
|
2268
|
+
# NOTHING violated, validate: included — a transform may rely on what
|
|
2269
|
+
# validate: checked (`Math.sqrt` after "all positive").
|
|
2270
|
+
elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
|
|
2271
|
+
if field[:validate] && elements_valid
|
|
1350
2272
|
status, code = Coercion.check_custom(field[:validate], out)
|
|
1351
2273
|
violations << permittable_violation(field, path, code) unless status == :ok
|
|
1352
2274
|
end
|
|
1353
2275
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
1354
2276
|
# violations) would hand user code garbage it never agreed to see.
|
|
1355
|
-
|
|
1356
|
-
|
|
2277
|
+
violations.length == before ? permittable_transform(field, out) : out
|
|
2278
|
+
end
|
|
2279
|
+
|
|
2280
|
+
# The one place a field's `transform:` is applied — a seam, so that
|
|
2281
|
+
# AuthoredValues can walk an authored default through this same walker
|
|
2282
|
+
# without running app code over it at class load.
|
|
2283
|
+
def permittable_transform(field, value)
|
|
2284
|
+
field[:transform] ? field[:transform].call(value) : value
|
|
1357
2285
|
end
|
|
1358
2286
|
|
|
1359
2287
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
@@ -1366,7 +2294,7 @@ module Permittable
|
|
|
1366
2294
|
path: path, unknown: unknown, top_level: false, violations: violations)
|
|
1367
2295
|
end
|
|
1368
2296
|
|
|
1369
|
-
status, out = Coercion.cast(field[:of], element)
|
|
2297
|
+
status, out = Coercion.cast(field[:of], permittable_own(element))
|
|
1370
2298
|
return out if status == :ok
|
|
1371
2299
|
|
|
1372
2300
|
violations << permittable_violation(field, path, out)
|
|
@@ -1380,18 +2308,34 @@ module Permittable
|
|
|
1380
2308
|
# corruption strict coercion exists to refuse, delivered by the gem's own
|
|
1381
2309
|
# preset. Only scalars take normalize:, and apply_normalize is itself a
|
|
1382
2310
|
# no-op without one, so it owns that decision for every caller.
|
|
2311
|
+
#
|
|
2312
|
+
# It is also where a request's String stops being the request's. Nothing
|
|
2313
|
+
# the walker hands to app code — `normalize:`, `validate:`, `transform:`,
|
|
2314
|
+
# the result — may alias the caller's params, or `permitted_params[:name]
|
|
2315
|
+
# << "x"` (or `normalize: ->(v) { v.strip! || v }`) rewrites the caller's
|
|
2316
|
+
# Hash or ActionController::Parameters behind the app's back. Copying at
|
|
2317
|
+
# the walker's input, ahead of normalize:, makes it one copy per String;
|
|
2318
|
+
# String#dup shares a long String's buffer copy-on-write, so the copy is
|
|
2319
|
+
# cheap until someone writes to it. `of:` elements get the same treatment
|
|
2320
|
+
# in permittable_check_element, and a :json hash in permittable_check_json.
|
|
1383
2321
|
def permittable_normalized(field, value)
|
|
1384
|
-
Coercion.apply_normalize(field[:normalize], value)
|
|
2322
|
+
Coercion.apply_normalize(field[:normalize], permittable_own(value))
|
|
2323
|
+
end
|
|
2324
|
+
|
|
2325
|
+
def permittable_own(value)
|
|
2326
|
+
value.is_a?(String) ? value.dup : value
|
|
1385
2327
|
end
|
|
1386
2328
|
|
|
1387
2329
|
# An authored default belongs to the contract, which is frozen data (see
|
|
1388
|
-
# ContractBuilder#freeze_authored)
|
|
1389
|
-
#
|
|
1390
|
-
#
|
|
2330
|
+
# ContractBuilder#freeze_authored), so every request gets a deep copy of it.
|
|
2331
|
+
# Copying only a top-level String was not enough: HashWithIndifferentAccess
|
|
2332
|
+
# copies a frozen Array or Hash as it assigns it, but not what is INSIDE
|
|
2333
|
+
# one, so `permitted_params[:tags].first << "x"` on an `of: :string`
|
|
2334
|
+
# default — or any edit to a String in a :json default — raised
|
|
2335
|
+
# FrozenError. A deep copy leaves every value in the result the app's own
|
|
1391
2336
|
# to mutate.
|
|
1392
2337
|
def permittable_default(field)
|
|
1393
|
-
|
|
1394
|
-
value.is_a?(String) ? value.dup : value
|
|
2338
|
+
field[:default].deep_dup
|
|
1395
2339
|
end
|
|
1396
2340
|
|
|
1397
2341
|
# nil and "" are both ABSENT — see the module comment.
|
|
@@ -1413,21 +2357,83 @@ module Permittable
|
|
|
1413
2357
|
|
|
1414
2358
|
declared = fields.map { |f| f[:name].to_s }
|
|
1415
2359
|
extra = hash.keys.map(&:to_s) - declared
|
|
1416
|
-
extra -= UNCHECKED_TOP_LEVEL_KEYS if top_level
|
|
2360
|
+
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys if top_level
|
|
1417
2361
|
return if extra.empty?
|
|
1418
2362
|
|
|
1419
2363
|
if unknown == :error
|
|
1420
|
-
extra.each { |key| violations <<
|
|
2364
|
+
extra.each { |key| violations << permittable_unknown_key_violation(path, key) }
|
|
1421
2365
|
elsif respond_to?(:logger) && logger
|
|
1422
|
-
|
|
1423
|
-
|
|
2366
|
+
listed = permittable_prose_list(extra) { |key| permittable_path(path, permittable_prose_utf8(key)) }
|
|
2367
|
+
logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: #{listed}")
|
|
1424
2368
|
end
|
|
1425
2369
|
end
|
|
1426
2370
|
|
|
2371
|
+
# The top-level keys THIS request's router put into params, beyond the
|
|
2372
|
+
# fixed UNCHECKED_TOP_LEVEL_KEYS: whatever it matched out of the URL
|
|
2373
|
+
# (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a
|
|
2374
|
+
# path parameter, not a body field). A controller only — a plain params
|
|
2375
|
+
# duck and a standalone Contract have no request, so they exempt nothing
|
|
2376
|
+
# extra. Subtracted from the undeclared keys, so a contract that DECLARES
|
|
2377
|
+
# `id` still has that field checked like any other: the value is real, the
|
|
2378
|
+
# URL carried it.
|
|
2379
|
+
def permittable_request_supplied_keys
|
|
2380
|
+
return [] unless respond_to?(:request) && request.respond_to?(:path_parameters)
|
|
2381
|
+
|
|
2382
|
+
request.path_parameters.keys.map(&:to_s)
|
|
2383
|
+
end
|
|
2384
|
+
|
|
2385
|
+
# A rootless contract's input without ParamsWrapper's copy of the body,
|
|
2386
|
+
# when Rails made one (see process_action). Removed rather than merely
|
|
2387
|
+
# exempted from the unknown-keys check, because the client never sent that
|
|
2388
|
+
# key: a contract that happens to declare a scalar or array field of the
|
|
2389
|
+
# wrapper's name (`optional :feedback, :string` on FeedbackController)
|
|
2390
|
+
# would otherwise validate Rails' copy of the whole body as that field — a
|
|
2391
|
+
# false 422 invalid_type for a well-formed request.
|
|
2392
|
+
#
|
|
2393
|
+
# Kept, though, when the contract declares that key as a hash container (a
|
|
2394
|
+
# nested block or :json): that rootless contract is reading the copy ON
|
|
2395
|
+
# PURPOSE, a root: spelled as a field, and it worked that way before the
|
|
2396
|
+
# copy was ever dropped — dropping it would turn every such request into
|
|
2397
|
+
# `user missing`. Top level only, where the copy lives; a rooted contract
|
|
2398
|
+
# reads the copy as its root, which is exactly what ParamsWrapper is for.
|
|
2399
|
+
# What is checked changes, not what monitor mode hands back: its raw
|
|
2400
|
+
# pass-through still carries the copy, as the pre-contract app's params did.
|
|
2401
|
+
def permittable_without_wrapper_copy(fields, source)
|
|
2402
|
+
key = @permittable_wrapper_key
|
|
2403
|
+
return source unless key
|
|
2404
|
+
return source if fields.any? { |f| f[:name].to_s == key && WRAPPER_CONTAINER_KINDS.include?(f[:kind]) }
|
|
2405
|
+
|
|
2406
|
+
source.except(key)
|
|
2407
|
+
end
|
|
2408
|
+
|
|
1427
2409
|
def permittable_path(path, key)
|
|
1428
2410
|
path ? "#{path}.#{key}" : key
|
|
1429
2411
|
end
|
|
1430
2412
|
|
|
2413
|
+
# The one place a CLIENT's key enters a path, so the only one converted to
|
|
2414
|
+
# reportable UTF-8 (see Coercion.reportable_text) — every declared key a
|
|
2415
|
+
# request walks through is the contract's own name and is left alone.
|
|
2416
|
+
#
|
|
2417
|
+
# The entry is also remembered by identity, which is how
|
|
2418
|
+
# permittable_check_array tells an undeclared key from a sub-field that
|
|
2419
|
+
# failed. The code alone cannot: a sub-field's validate: may itself
|
|
2420
|
+
# return :unknown.
|
|
2421
|
+
def permittable_unknown_key_violation(path, key)
|
|
2422
|
+
entry = permittable_violation({}, permittable_path(path, Coercion.reportable_text(key)), "unknown")
|
|
2423
|
+
(@permittable_unknown_key_violations ||= {}.compare_by_identity)[entry] = true
|
|
2424
|
+
# Prose (the exception message) gets the richer transcoding instead of
|
|
2425
|
+
# `param:`'s scrubbed-to-U+FFFD text — see permittable_violation_summary.
|
|
2426
|
+
# permittable_prose_utf8, not Coercion.reportable_text, is what keeps the
|
|
2427
|
+
# concatenation with `path` (the contract's own UTF-8 field names) from
|
|
2428
|
+
# raising Encoding::CompatibilityError, the same as the :log line below.
|
|
2429
|
+
(@permittable_unknown_key_raw ||= {}.compare_by_identity)[entry] = permittable_path(path, permittable_prose_utf8(key))
|
|
2430
|
+
entry
|
|
2431
|
+
end
|
|
2432
|
+
|
|
2433
|
+
def permittable_unknown_key_violation?(entry)
|
|
2434
|
+
@permittable_unknown_key_violations&.key?(entry) || false
|
|
2435
|
+
end
|
|
2436
|
+
|
|
1431
2437
|
def permittable_action_name
|
|
1432
2438
|
respond_to?(:action_name) && action_name ? action_name.to_s : nil
|
|
1433
2439
|
end
|
|
@@ -1439,6 +2445,10 @@ module Permittable
|
|
|
1439
2445
|
end
|
|
1440
2446
|
end
|
|
1441
2447
|
|
|
2448
|
+
# Class-load reading of an authored array default:/example: — the request
|
|
2449
|
+
# walker itself, so it needs the concern's body loaded.
|
|
2450
|
+
require "permittable/authored_values"
|
|
2451
|
+
|
|
1442
2452
|
# Contract exporters — the other readers of the frozen contract registry.
|
|
1443
2453
|
# Loaded after the module body so OpenAPI can see the concern's own methods.
|
|
1444
2454
|
require "permittable/json_schema"
|
|
@@ -1451,6 +2461,13 @@ require "permittable/generator"
|
|
|
1451
2461
|
# Standalone contracts — the same DSL callable on any Hash, no controller.
|
|
1452
2462
|
require "permittable/contract"
|
|
1453
2463
|
|
|
2464
|
+
# Contract COVERAGE — the registry crossed with the route set, so a
|
|
2465
|
+
# half-covered controller is as visible as an uncovered one.
|
|
2466
|
+
require "permittable/audit"
|
|
2467
|
+
|
|
2468
|
+
# Reusable field lists — `Permittable.fields` + the builder's `use` verb.
|
|
2469
|
+
require "permittable/field_group"
|
|
2470
|
+
|
|
1454
2471
|
# Boot-time integration (filter_parameters registration, the
|
|
1455
2472
|
# permittable:openapi rake task), Rails apps only
|
|
1456
2473
|
require "permittable/railtie" if defined?(Rails::Railtie)
|