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.
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
- # Either way it is deep-frozen on a copy, and each request gets its own deep
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. Without this
230
- # `unknown: :error` was unusable outside a JSON API: every browser form
231
- # failed on the framework's own keys rather than on anything the client got
232
- # wrong. Exempt from the CHECK only: unlike the routing keys these are NOT
233
- # stripped from monitor mode's raw pass-through, where handing back an
234
- # untouched params hash is the whole promise and a legacy action may well
235
- # read `_method` itself.
236
- FORM_KEYS = %w[authenticity_token _method utf8 commit].freeze
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
- assert_satisfiable!(name, :in, field[:in])
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:`, the value is stored exactly AS AUTHORED instead —
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: without
1650
- # `transform:`, the walker's read (exactly what a request sending it
1651
- # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1652
- # so a declaration mistake (an element `validate:` refuses, a sub-field
1653
- # default out of bounds) still fails at class load, but its cast result
1654
- # is discarded rather than stored. The array's OWN `transform:` is
1655
- # deliberately never run on a default either way; a SUB-FIELD's
1656
- # `transform:` still runs during that walk, so "the walker's read" above
1657
- # really is what an equivalent request produces — see AuthoredValues.
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
- read, violations = AuthoredValues.read_array(field, value)
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
- field[opt] = freeze_authored(field[:transform] ? value : read)
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
- permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
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
- if field[:validate] && elements_valid
2404
- status, code = Coercion.check_custom(field[:validate], out)
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.0
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport