permittable 0.11.0 → 0.11.1
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 +49 -0
- data/README.md +368 -24
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +294 -61
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +209 -29
- metadata +2 -2
data/lib/permittable.rb
CHANGED
|
@@ -136,7 +136,8 @@ require "permittable/filter_parameter_registry"
|
|
|
136
136
|
# declaring `transform:`: its default is stored exactly AS AUTHORED, never
|
|
137
137
|
# cast — `transform:` never runs on a default either (see OUTPUT RESHAPING),
|
|
138
138
|
# so author such a default already in the shape the action should receive.
|
|
139
|
-
#
|
|
139
|
+
# An `example:` is cast either way and never transformed: it documents what a
|
|
140
|
+
# client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
|
|
140
141
|
# copy, so no request can corrupt it for the next.
|
|
141
142
|
#
|
|
142
143
|
# `nullable: true` splits that rule in two for one field, which is how a PATCH
|
|
@@ -226,14 +227,21 @@ module Permittable
|
|
|
226
227
|
# unknown-keys check must not flag them.
|
|
227
228
|
ROUTING_KEYS = %w[controller action format].freeze
|
|
228
229
|
# Nor the keys an ordinary form POST carries — the CSRF token, the verb
|
|
229
|
-
# override, the encoding probe, and the submit button's name
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
|
|
230
|
+
# override, the encoding probe, and the submit button's name: `commit` from
|
|
231
|
+
# f.submit/submit_tag, `button` from f.button/button_tag (which render
|
|
232
|
+
# <button name="button">, so a browser sends `button=` even with no
|
|
233
|
+
# value). Without this `unknown: :error` was unusable outside a JSON API:
|
|
234
|
+
# every browser form failed on the framework's own keys rather than on
|
|
235
|
+
# anything the client got wrong. Exempt from the CHECK only: unlike the
|
|
236
|
+
# routing keys these are NOT stripped from monitor mode's raw
|
|
237
|
+
# pass-through, where handing back an untouched params hash is the whole
|
|
238
|
+
# promise and a legacy action may well read `_method` itself.
|
|
239
|
+
#
|
|
240
|
+
# The click point of an image submit (image_submit_tag, which has no name
|
|
241
|
+
# of its own to exempt) is a form key too, but not a fixed one: `x` and `y`,
|
|
242
|
+
# or `<name>.x` and `<name>.y`, as a PAIR of digits. See
|
|
243
|
+
# permittable_image_submit_keys.
|
|
244
|
+
FORM_KEYS = %w[authenticity_token _method utf8 commit button].freeze
|
|
237
245
|
# ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
|
|
238
246
|
# is DECIDED with them, which is what the call sites care about — and the
|
|
239
247
|
# asymmetry between them is the deliberate point, so spell it once here
|
|
@@ -616,6 +624,12 @@ module Permittable
|
|
|
616
624
|
class_attribute :permittable_contracts, instance_accessor: false, default: []
|
|
617
625
|
|
|
618
626
|
rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
|
|
627
|
+
# Registered here, at the include, and not at the first `permit_params`:
|
|
628
|
+
# the README documents `skip_before_action :enforce_params_contract`, and
|
|
629
|
+
# Rails raises when it is asked to skip a callback that is not defined
|
|
630
|
+
# yet. The cost is the order: this check runs before any callback
|
|
631
|
+
# declared after the include (see the README's "Per-request locales"),
|
|
632
|
+
# so a locale switch that must cover it is a `prepend_around_action`.
|
|
619
633
|
before_action :enforce_params_contract if respond_to?(:before_action)
|
|
620
634
|
end
|
|
621
635
|
|
|
@@ -879,8 +893,11 @@ module Permittable
|
|
|
879
893
|
source.split(/[eE]/, 2).first.match?(/[1-9]/)
|
|
880
894
|
end
|
|
881
895
|
|
|
896
|
+
# Rational#to_s is "1/2", which BigDecimal() cannot read, so the generic
|
|
897
|
+
# Numeric branch refused every Rational, even a whole one (3r).
|
|
882
898
|
def cast_decimal(value)
|
|
883
899
|
case value
|
|
900
|
+
when Rational then rational_to_decimal(value)
|
|
884
901
|
when Numeric then finite_decimal(BigDecimal(value.to_s))
|
|
885
902
|
when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
|
|
886
903
|
else [:error, "invalid_type"]
|
|
@@ -889,6 +906,20 @@ module Permittable
|
|
|
889
906
|
[:error, "invalid_type"]
|
|
890
907
|
end
|
|
891
908
|
|
|
909
|
+
# A Rational is exactly a decimal only when its reduced denominator has
|
|
910
|
+
# no prime factor but 2 and 5 (1/2, 3/40), and then it divides 10**k for
|
|
911
|
+
# k = its bit length, which is at least the exponent of either factor.
|
|
912
|
+
# Anything else (1/3) has no finite expansion, so a BigDecimal could hold
|
|
913
|
+
# it only rounded at a precision this gem would have to pick. That is the
|
|
914
|
+
# guess every other coercion here refuses, so it is invalid_type.
|
|
915
|
+
def rational_to_decimal(value)
|
|
916
|
+
denominator = value.denominator
|
|
917
|
+
scale = denominator.bit_length
|
|
918
|
+
return [:error, "invalid_type"] unless ((10**scale) % denominator).zero?
|
|
919
|
+
|
|
920
|
+
finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
|
|
921
|
+
end
|
|
922
|
+
|
|
892
923
|
# BigDecimal has no exponent limit, so a :decimal cannot overflow — but
|
|
893
924
|
# BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
|
|
894
925
|
# raises, so a client could send the literal string "NaN" for a price and
|
|
@@ -1199,6 +1230,7 @@ module Permittable
|
|
|
1199
1230
|
validate_array_authored_value!(field, :default) if field.key?(:default)
|
|
1200
1231
|
validate_array_authored_value!(field, :example) if field.key?(:example)
|
|
1201
1232
|
validate_message!(field)
|
|
1233
|
+
freeze_desc!(field)
|
|
1202
1234
|
@fields << field
|
|
1203
1235
|
end
|
|
1204
1236
|
|
|
@@ -1275,6 +1307,7 @@ module Permittable
|
|
|
1275
1307
|
fields: nested_fields!(name, &block), **opts }
|
|
1276
1308
|
field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
|
|
1277
1309
|
validate_message!(field)
|
|
1310
|
+
freeze_desc!(field)
|
|
1278
1311
|
elsif type&.to_sym == JSON_TYPE
|
|
1279
1312
|
assert_opts!(name, opts, JSON_OPTS)
|
|
1280
1313
|
# `type:` is carried alongside `kind:` so the same `as(:json)` matcher
|
|
@@ -1379,6 +1412,7 @@ module Permittable
|
|
|
1379
1412
|
validate_authored_value!(field, :default)
|
|
1380
1413
|
validate_authored_value!(field, :example)
|
|
1381
1414
|
validate_message!(field)
|
|
1415
|
+
freeze_desc!(field)
|
|
1382
1416
|
end
|
|
1383
1417
|
|
|
1384
1418
|
# `in:` is a Range (bounds-checked with cover?), a list of values, or an
|
|
@@ -1394,19 +1428,34 @@ module Permittable
|
|
|
1394
1428
|
# enum rule all read the members the runtime compares against. A member
|
|
1395
1429
|
# no request value could ever equal is a contract mistake, and fails here
|
|
1396
1430
|
# rather than as an `inclusion` on every request.
|
|
1431
|
+
#
|
|
1432
|
+
# An empty list or a reversed Range is refused for the same reason, but
|
|
1433
|
+
# an object used as given is not asked: it is read on every request, so
|
|
1434
|
+
# a live registry that is empty now (filled in an initializer or
|
|
1435
|
+
# `after_initialize`, after `eager_load!` has run this) is the very use
|
|
1436
|
+
# the README recommends it for.
|
|
1397
1437
|
def resolve_in!(field)
|
|
1398
1438
|
name = field[:name]
|
|
1399
1439
|
allowed = field[:in]
|
|
1400
1440
|
if allowed.is_a?(Range)
|
|
1401
1441
|
assert_comparable_range!(field, allowed)
|
|
1442
|
+
assert_satisfiable!(name, :in, allowed)
|
|
1402
1443
|
elsif (members = Coercion.in_list(allowed))
|
|
1403
1444
|
cast_in_members!(field, members)
|
|
1445
|
+
assert_satisfiable!(name, :in, field[:in])
|
|
1404
1446
|
elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
|
|
1405
1447
|
raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
|
|
1406
1448
|
"or a Hash read as its keys), or an object answering include? " \
|
|
1407
1449
|
"(got #{allowed.inspect})#{string_in_hint(allowed)}"
|
|
1408
1450
|
end
|
|
1409
|
-
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
# An `in:` that is neither a Range nor a list the gem read and cast
|
|
1454
|
+
# itself, so one resolve_in! kept exactly as given: its contents are the
|
|
1455
|
+
# host's, and unknown at class load.
|
|
1456
|
+
def live_in?(field)
|
|
1457
|
+
allowed = field[:in]
|
|
1458
|
+
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
1410
1459
|
end
|
|
1411
1460
|
|
|
1412
1461
|
def string_in_hint(allowed)
|
|
@@ -1484,6 +1533,7 @@ module Permittable
|
|
|
1484
1533
|
validate_json_authored_value!(field, :default)
|
|
1485
1534
|
validate_json_authored_value!(field, :example)
|
|
1486
1535
|
validate_message!(field)
|
|
1536
|
+
freeze_desc!(field)
|
|
1487
1537
|
end
|
|
1488
1538
|
|
|
1489
1539
|
def validate_max_depth!(name, depth)
|
|
@@ -1614,7 +1664,7 @@ module Permittable
|
|
|
1614
1664
|
# same value gets, and what used to be thrown away: `default: "18"` used
|
|
1615
1665
|
# to be handed to every request omitting it as the String "18", and
|
|
1616
1666
|
# `:boolean, default: "false"` gave the app a truthy String.
|
|
1617
|
-
# With a `transform:`,
|
|
1667
|
+
# With a `transform:`, a default is stored exactly AS AUTHORED instead —
|
|
1618
1668
|
# `transform:` never runs on a default (see AuthoredValues), so casting it
|
|
1619
1669
|
# here would silently change its type out from under an author who, per
|
|
1620
1670
|
# the README, writes such a default in the shape the action should
|
|
@@ -1622,6 +1672,17 @@ module Permittable
|
|
|
1622
1672
|
# :string field means the app gets the Integer 25 either way, whether the
|
|
1623
1673
|
# request sent "25" (cast then transformed) or omitted the field
|
|
1624
1674
|
# (authored as the already-final Integer).
|
|
1675
|
+
#
|
|
1676
|
+
# An `example:` is cast either way. It documents what a client SENDS and
|
|
1677
|
+
# never reaches the action, so that reason does not apply to it: under
|
|
1678
|
+
# `transform:` it used to be published uncast, `"type": "integer",
|
|
1679
|
+
# "examples": ["18"]`, the very lie the cast exists to stop.
|
|
1680
|
+
#
|
|
1681
|
+
# The one rule not checked is `in:` when it is an object used as given
|
|
1682
|
+
# (see resolve_in!): a live registry is empty at class load, so
|
|
1683
|
+
# `in: LIVE, default: "order.paid"` would be refused for a member it will
|
|
1684
|
+
# hold by the first request. What it will contain is unknown then, so its
|
|
1685
|
+
# `include?` is not asked; every other rule is still checked.
|
|
1625
1686
|
def validate_authored_value!(field, opt)
|
|
1626
1687
|
return unless field.key?(opt)
|
|
1627
1688
|
return if authored_nil!(field, opt)
|
|
@@ -1630,10 +1691,10 @@ module Permittable
|
|
|
1630
1691
|
# proc cannot rewrite the host's own literal.
|
|
1631
1692
|
authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
|
|
1632
1693
|
value = Coercion.apply_normalize(field[:normalize], authored)
|
|
1633
|
-
status, result = Coercion.check_scalar(field, value)
|
|
1694
|
+
status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
|
|
1634
1695
|
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
|
|
1635
1696
|
|
|
1636
|
-
field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
|
|
1697
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
|
|
1637
1698
|
end
|
|
1638
1699
|
|
|
1639
1700
|
# An array's authored value is validated by the REQUEST walker itself
|
|
@@ -1646,27 +1707,61 @@ module Permittable
|
|
|
1646
1707
|
# cast only the top level of each element, and got every one of those
|
|
1647
1708
|
# wrong.
|
|
1648
1709
|
#
|
|
1649
|
-
# What is STORED follows the same split as a scalar's:
|
|
1650
|
-
# `transform:`, the walker's read (exactly what a request sending
|
|
1651
|
-
# gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1652
|
-
# so a declaration mistake (an element `validate:` refuses, a
|
|
1653
|
-
# default out of bounds) still fails at class load, but its
|
|
1654
|
-
# is discarded rather than stored. The array's OWN
|
|
1655
|
-
# deliberately never run on a default either way
|
|
1656
|
-
# `transform:` still runs during that walk, so "the walker's
|
|
1657
|
-
# really is what an equivalent request produces
|
|
1710
|
+
# What is STORED for a default follows the same split as a scalar's:
|
|
1711
|
+
# without `transform:`, the walker's read (exactly what a request sending
|
|
1712
|
+
# it gets); with one, the array exactly AS AUTHORED — the walker still
|
|
1713
|
+
# runs, so a declaration mistake (an element `validate:` refuses, a
|
|
1714
|
+
# sub-field default out of bounds) still fails at class load, but its
|
|
1715
|
+
# cast result is discarded rather than stored. The array's OWN
|
|
1716
|
+
# `transform:` is deliberately never run on a default either way. A
|
|
1717
|
+
# SUB-FIELD's `transform:` still runs during that walk, so "the walker's
|
|
1718
|
+
# read" above really is what an equivalent request produces: all of them
|
|
1719
|
+
# when the array has no `transform:` of its own, and, when it has one,
|
|
1720
|
+
# those below an array declaring `validate:`, the only readers of their
|
|
1721
|
+
# output (a request runs sub-field transforms, then that `validate:`,
|
|
1722
|
+
# then the array's own transform) — see AuthoredValues.
|
|
1723
|
+
#
|
|
1724
|
+
# An `example:` is validated the same way, as a request would be, but what
|
|
1725
|
+
# is STORED is a second read of it exactly as a client SENDS it: cast and
|
|
1726
|
+
# normalized, with no `transform:` run at any depth and no sub-field
|
|
1727
|
+
# `default:` filled in. Read through a sub-field's transform,
|
|
1728
|
+
# `[{ price: "9.99" }]` under `transform: ->(d) { (d * 100).to_i }` was
|
|
1729
|
+
# published as `[{ "price": 999 }]`: 100 times the price, and a `tags`
|
|
1730
|
+
# split into an Array the server refuses. The two reads cannot be one: an
|
|
1731
|
+
# array-level `validate:` must see the transformed sub-fields a request
|
|
1732
|
+
# hands it (`i[:tags].is_a?(Array)`), so the validating read keeps them,
|
|
1733
|
+
# and the stored read, which runs no `validate:`, discards them.
|
|
1734
|
+
#
|
|
1735
|
+
# The transforming read exists only for that `validate:`, so it runs only
|
|
1736
|
+
# when one could consume it. Anywhere else its output is thrown away, yet
|
|
1737
|
+
# it would still call app code at class load: `optional :id, :string,
|
|
1738
|
+
# transform: ->(s) { Account.find(s) }` under an `example:` hit the
|
|
1739
|
+
# database, or raised, at boot. Only an array carries a `validate:` that
|
|
1740
|
+
# sees its transformed children: a scalar's or a :json field's runs on
|
|
1741
|
+
# its own value before its own transform, and a nested hash takes none.
|
|
1742
|
+
# Without one, the as-sent read both validates and is stored.
|
|
1658
1743
|
def validate_array_authored_value!(field, opt)
|
|
1659
1744
|
value = field[opt]
|
|
1660
1745
|
return if authored_nil!(field, opt)
|
|
1661
1746
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
|
|
1662
1747
|
|
|
1663
|
-
|
|
1748
|
+
as_sent = opt == :example && !array_validate_in?(field)
|
|
1749
|
+
read, violations = AuthoredValues.read_array(field, value, as_sent: as_sent)
|
|
1664
1750
|
unless violations.empty?
|
|
1665
1751
|
raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
|
|
1666
1752
|
"#{AuthoredValues.summary(violations)}"
|
|
1667
1753
|
end
|
|
1668
1754
|
|
|
1669
|
-
|
|
1755
|
+
read, = AuthoredValues.read_array(field, value, as_sent: true) if opt == :example && !as_sent
|
|
1756
|
+
field[opt] = freeze_authored(opt == :default && field[:transform] ? value : read)
|
|
1757
|
+
end
|
|
1758
|
+
|
|
1759
|
+
# Whether `field`, or any array or nested hash below it, declares an
|
|
1760
|
+
# array-level `validate:`.
|
|
1761
|
+
def array_validate_in?(field)
|
|
1762
|
+
return true if field[:kind] == :array && field[:validate]
|
|
1763
|
+
|
|
1764
|
+
Array(field[:fields]).any? { |sub| array_validate_in?(sub) }
|
|
1670
1765
|
end
|
|
1671
1766
|
|
|
1672
1767
|
# A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
|
|
@@ -1705,6 +1800,18 @@ module Permittable
|
|
|
1705
1800
|
"declare nullable: true to make an explicit null part of the contract"
|
|
1706
1801
|
end
|
|
1707
1802
|
|
|
1803
|
+
# `desc:` is exported as the schema's `description`, and was handed out
|
|
1804
|
+
# by reference: a caller appending to a generated document's
|
|
1805
|
+
# description (" (deprecated)", a gsub! for localisation) rewrote the
|
|
1806
|
+
# contract inside the frozen rule, and every later export in the process
|
|
1807
|
+
# carried the edit. Frozen as a copy, like message:, default: and
|
|
1808
|
+
# example:, so the host's own String is left as it passed it. The
|
|
1809
|
+
# exporters hand out an unfrozen `dup` of it, so editing a generated
|
|
1810
|
+
# document in place works and still cannot reach the contract.
|
|
1811
|
+
def freeze_desc!(field)
|
|
1812
|
+
field[:desc] = freeze_authored(field[:desc]) unless field[:desc].nil?
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1708
1815
|
# `message:` customizes what the client reads for a violation on this
|
|
1709
1816
|
# field: one String covering every code, or a Hash of code => String
|
|
1710
1817
|
# (codes without an entry keep the default rendering). Keys are
|
|
@@ -1804,7 +1911,7 @@ module Permittable
|
|
|
1804
1911
|
|
|
1805
1912
|
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
1806
1913
|
model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
|
|
1807
|
-
finalize: builder.finalizer, desc: desc }.freeze
|
|
1914
|
+
finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
|
|
1808
1915
|
self.permittable_contracts = permittable_contracts + [rule]
|
|
1809
1916
|
end
|
|
1810
1917
|
|
|
@@ -1851,10 +1958,11 @@ module Permittable
|
|
|
1851
1958
|
|
|
1852
1959
|
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
1853
1960
|
allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
|
|
1961
|
+
transformed = checked.select { |f| f[:transform] }.map { |f| f[:name].to_sym }
|
|
1854
1962
|
begin
|
|
1855
1963
|
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
|
|
1856
1964
|
types: types, check_types: Permittable.check_column_types,
|
|
1857
|
-
allowed: allowed)
|
|
1965
|
+
allowed: allowed, transformed: transformed)
|
|
1858
1966
|
rescue ArgumentError => e
|
|
1859
1967
|
# The type error carries its own guidance; only the missing-column one
|
|
1860
1968
|
# needs the virtual: hint appended.
|
|
@@ -1929,10 +2037,33 @@ module Permittable
|
|
|
1929
2037
|
return nil unless action
|
|
1930
2038
|
|
|
1931
2039
|
rule = self.class.permit_rule_for(action)
|
|
1932
|
-
|
|
2040
|
+
return nil unless rule
|
|
2041
|
+
|
|
2042
|
+
if permittable_mode(rule) == :monitor
|
|
2043
|
+
permittable_monitor_eagerly(action)
|
|
2044
|
+
elsif rule[:enforce]
|
|
2045
|
+
permitted_params(action)
|
|
2046
|
+
end
|
|
1933
2047
|
nil
|
|
1934
2048
|
end
|
|
1935
2049
|
|
|
2050
|
+
# Monitor mode's promise is that the action runs, whatever the request
|
|
2051
|
+
# holds — that is what makes it safe to turn on. Checking eagerly reads
|
|
2052
|
+
# `params`, which raises ActionDispatch's ParseError on a body Rails
|
|
2053
|
+
# cannot parse: a webhook action reading request.raw_post itself got a
|
|
2054
|
+
# 400 instead of running. An exception from the contract's own code
|
|
2055
|
+
# (validate:, transform:, finalize) became a 500 the same way. Either is
|
|
2056
|
+
# now logged, by class only (a parse error's message can quote the body),
|
|
2057
|
+
# and the action runs; one that reads permitted_params meets the same
|
|
2058
|
+
# exception itself, as it would with no contract at all.
|
|
2059
|
+
def permittable_monitor_eagerly(action)
|
|
2060
|
+
permitted_params(action)
|
|
2061
|
+
rescue StandardError => e
|
|
2062
|
+
return unless respond_to?(:logger) && logger
|
|
2063
|
+
|
|
2064
|
+
logger.warn("#{LABEL}: [monitor] ##{action} could not be checked: #{e.class} — the action runs unchecked")
|
|
2065
|
+
end
|
|
2066
|
+
|
|
1936
2067
|
# The violation details recorded by validating `action` (default: the
|
|
1937
2068
|
# current action) — [] when the request satisfied the contract. Triggers
|
|
1938
2069
|
# the same memoized validation as permitted_params, so under monitor mode
|
|
@@ -2330,6 +2461,7 @@ module Permittable
|
|
|
2330
2461
|
violations << permittable_violation(field, full, "invalid_type")
|
|
2331
2462
|
end
|
|
2332
2463
|
when :array
|
|
2464
|
+
value = value.values if permittable_fields_for_records?(field, value)
|
|
2333
2465
|
if value.is_a?(Array)
|
|
2334
2466
|
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
2335
2467
|
else
|
|
@@ -2338,6 +2470,20 @@ module Permittable
|
|
|
2338
2470
|
end
|
|
2339
2471
|
end
|
|
2340
2472
|
|
|
2473
|
+
# Whether `value` is an array of hashes sent the way Rails' fields_for
|
|
2474
|
+
# sends one: `user[addresses_attributes][0][city]` reaches the params as a
|
|
2475
|
+
# Hash keyed "0", "1", ... (or a timestamp, for a record added in the
|
|
2476
|
+
# browser). Strong params permits exactly this shape (its
|
|
2477
|
+
# fields_for_style?: every key an integer, every value a hash) and
|
|
2478
|
+
# accepts_nested_attributes_for reads its values in order, so an
|
|
2479
|
+
# `array ... do` field reads them as its elements. Paths then count the
|
|
2480
|
+
# records in the order sent. An empty Hash, or one of scalars, is not this
|
|
2481
|
+
# shape and stays invalid_type.
|
|
2482
|
+
def permittable_fields_for_records?(field, value)
|
|
2483
|
+
field[:fields] && value.is_a?(Hash) && !value.empty? &&
|
|
2484
|
+
value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
|
|
2485
|
+
end
|
|
2486
|
+
|
|
2341
2487
|
# Coercion.check_json, with the copy the result needs made in the middle.
|
|
2342
2488
|
# The opaque hash is handed over whole, and HashWithIndifferentAccess
|
|
2343
2489
|
# rebuilt its containers but not the Strings inside them, so it is
|
|
@@ -2400,8 +2546,9 @@ module Permittable
|
|
|
2400
2546
|
# NOTHING violated, validate: included — a transform may rely on what
|
|
2401
2547
|
# validate: checked (`Math.sqrt` after "all positive").
|
|
2402
2548
|
elements_valid = violations.drop(before).all? { |v| permittable_unknown_key_violation?(v) }
|
|
2403
|
-
|
|
2404
|
-
|
|
2549
|
+
validator = permittable_array_validator(field)
|
|
2550
|
+
if validator && elements_valid
|
|
2551
|
+
status, code = Coercion.check_custom(validator, out)
|
|
2405
2552
|
violations << permittable_violation(field, path, code) unless status == :ok
|
|
2406
2553
|
end
|
|
2407
2554
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
@@ -2416,6 +2563,11 @@ module Permittable
|
|
|
2416
2563
|
field[:transform] ? field[:transform].call(value) : value
|
|
2417
2564
|
end
|
|
2418
2565
|
|
|
2566
|
+
# A second seam, for the same reader: the array-level `validate:` to run.
|
|
2567
|
+
def permittable_array_validator(field)
|
|
2568
|
+
field[:validate]
|
|
2569
|
+
end
|
|
2570
|
+
|
|
2419
2571
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
2420
2572
|
if field[:fields]
|
|
2421
2573
|
unless element.is_a?(Hash)
|
|
@@ -2492,6 +2644,7 @@ module Permittable
|
|
|
2492
2644
|
if top_level
|
|
2493
2645
|
extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
|
|
2494
2646
|
extra -= [permittable_configured_csrf_key].compact
|
|
2647
|
+
extra -= permittable_image_submit_keys(hash, extra)
|
|
2495
2648
|
end
|
|
2496
2649
|
return if extra.empty?
|
|
2497
2650
|
|
|
@@ -2517,6 +2670,33 @@ module Permittable
|
|
|
2517
2670
|
request.path_parameters.keys.map(&:to_s)
|
|
2518
2671
|
end
|
|
2519
2672
|
|
|
2673
|
+
# The click point of an image submit among `keys` (the undeclared top-level
|
|
2674
|
+
# keys). image_submit_tag renders <input type="image">, and a browser
|
|
2675
|
+
# submitting through it posts where it was clicked: `x=12&y=7`, or
|
|
2676
|
+
# `save.x=12&save.y=7` when the tag is named "save" (Rack keeps a dotted
|
|
2677
|
+
# key as it is). Like the other FORM_KEYS these are the framework's own
|
|
2678
|
+
# keys, so `unknown: :error` must not fail the form for them. Unlike them
|
|
2679
|
+
# `x` and `y` are ordinary names a contract may really be sent, so a
|
|
2680
|
+
# lone `x` is still flagged: a point is exempt only as a PAIR, both of
|
|
2681
|
+
# them in `keys` (a declared `x` or `y` is not, and is checked as a field)
|
|
2682
|
+
# and both digits, which is all a browser sends (coordinates are never
|
|
2683
|
+
# negative or fractional). Read by bytes, since a client's key or value can
|
|
2684
|
+
# be invalid UTF-8, which a regexp refuses to match, or another encoding
|
|
2685
|
+
# altogether (a UTF-16 key makes `end_with?` raise).
|
|
2686
|
+
def permittable_image_submit_keys(hash, keys)
|
|
2687
|
+
by_bytes = keys.to_h { |key| [key.b, key] }
|
|
2688
|
+
by_bytes.each_with_object([]) do |(bytes, key), point|
|
|
2689
|
+
next unless bytes == "x" || bytes.end_with?(".x")
|
|
2690
|
+
|
|
2691
|
+
partner = by_bytes["#{bytes.delete_suffix('x')}y"]
|
|
2692
|
+
point.push(key, partner) if partner && [key, partner].all? { |name| permittable_digits?(hash[name]) }
|
|
2693
|
+
end
|
|
2694
|
+
end
|
|
2695
|
+
|
|
2696
|
+
def permittable_digits?(value)
|
|
2697
|
+
value.is_a?(String) && !value.empty? && value.each_byte.all? { |byte| byte.between?(48, 57) }
|
|
2698
|
+
end
|
|
2699
|
+
|
|
2520
2700
|
# FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
|
|
2521
2701
|
# name — but `config.action_controller.request_forgery_protection_token`
|
|
2522
2702
|
# lets an app rename it, and an app that does trips `unknown: :error` on
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: permittable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.11.
|
|
4
|
+
version: 0.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|