permittable 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/permittable.rb CHANGED
@@ -132,11 +132,16 @@ require "permittable/filter_parameter_registry"
132
132
  # (" " under :squish) cannot satisfy a required field by becoming "". An
133
133
  # authored `default:`/`example:` is stored as the contract reads it —
134
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
135
+ # array's read by the request walker itself. The exceptions: a field
136
+ # declaring `transform:` stores its default exactly AS AUTHORED, never
137
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
138
+ # so author such a default already in the shape the action should receive —
139
+ # and a `:json` field's is always stored as the walker reads a request's hash
140
+ # (indifferent access, String keys; `transform:` or not), except that a
141
+ # `:json` `example:` is stored as authored (in that same shape), whatever a
142
+ # mutating `validate:` did to the copy it was checked on.
143
+ # An `example:` is cast either way and never transformed: it documents what a
144
+ # client sends. Either way it is deep-frozen on a copy, and each request gets its own deep
140
145
  # copy, so no request can corrupt it for the next.
141
146
  #
142
147
  # `nullable: true` splits that rule in two for one field, which is how a PATCH
@@ -189,8 +194,10 @@ require "permittable/filter_parameter_registry"
189
194
  # into an Array. Runs only on request-supplied values: absent fields stay
190
195
  # absent, and a `default:` is handed out exactly AS AUTHORED — validated
191
196
  # 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.
197
+ # neither cast nor transformed (a `:json` field's is read as a request's
198
+ # hash is, indifferent with String keys, but no more than that) — so a
199
+ # request sending a field's default gets the transformed value, a
200
+ # request omitting it the untransformed one.
194
201
  # Author a default already in the shape the action should receive:
195
202
  # `transform: ->(v) { v.to_i }, default: 25` on a :string field hands both
196
203
  # paths the Integer 25. A field with no `transform:` still gets its
@@ -226,20 +233,39 @@ module Permittable
226
233
  # unknown-keys check must not flag them.
227
234
  ROUTING_KEYS = %w[controller action format].freeze
228
235
  # 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
236
+ # override, the encoding probe, and the submit button's name: `commit` from
237
+ # f.submit/submit_tag, `button` from f.button/button_tag (which render
238
+ # <button name="button">, so a browser sends `button=` even with no
239
+ # value). Without this `unknown: :error` was unusable outside a JSON API:
240
+ # every browser form failed on the framework's own keys rather than on
241
+ # anything the client got wrong. Exempt from the CHECK only: unlike the
242
+ # routing keys these are NOT stripped from monitor mode's raw
243
+ # pass-through, where handing back an untouched params hash is the whole
244
+ # promise and a legacy action may well read `_method` itself.
245
+ #
246
+ # The click point of an image submit (image_submit_tag, which has no name
247
+ # of its own to exempt) is a form key too, but not a fixed one: `x` and `y`,
248
+ # or `<name>.x` and `<name>.y`, as a PAIR of digits. See
249
+ # permittable_image_submit_keys.
250
+ FORM_KEYS = %w[authenticity_token _method utf8 commit button].freeze
237
251
  # ROUTING_KEYS/FORM_KEYS name where the keys come FROM; these two name what
238
252
  # is DECIDED with them, which is what the call sites care about — and the
239
253
  # asymmetry between them is the deliberate point, so spell it once here
240
254
  # rather than leaving a bare ROUTING_KEYS to read like an oversight.
241
255
  UNCHECKED_TOP_LEVEL_KEYS = (ROUTING_KEYS + FORM_KEYS).freeze
242
256
  MONITOR_DROPPED_KEYS = ROUTING_KEYS
257
+ # Rails' date_select, datetime_select and time_select submit ONE value as
258
+ # several keys, each named for its position: `starts_on(1i)` the year,
259
+ # `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and
260
+ # `(6i)` the second. Strong parameters passes them through for
261
+ # `permit(:starts_on)` and ActiveRecord assembles them. A contract read
262
+ # only `starts_on`, so such a form lost its date (silently, under the
263
+ # default `unknown: :ignore`) and a required one was always missing. A
264
+ # :date or :datetime field now reads its parts as its own value (see
265
+ # permittable_read_date_parts); this is how many parts each type has.
266
+ DATE_PARTS = { date: 3, datetime: 6 }.freeze
267
+ # One part, as a select sends it: an unsigned decimal integer.
268
+ DATE_PART_FORMAT = /\A\d+\z/
243
269
  # The field kinds that can hold ParamsWrapper's copy of a body — a hash. A
244
270
  # rootless contract declaring the wrapper key as one of these reads the
245
271
  # copy deliberately, so it is kept (see permittable_drops_wrapper_copy?).
@@ -616,6 +642,12 @@ module Permittable
616
642
  class_attribute :permittable_contracts, instance_accessor: false, default: []
617
643
 
618
644
  rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
645
+ # Registered here, at the include, and not at the first `permit_params`:
646
+ # the README documents `skip_before_action :enforce_params_contract`, and
647
+ # Rails raises when it is asked to skip a callback that is not defined
648
+ # yet. The cost is the order: this check runs before any callback
649
+ # declared after the include (see the README's "Per-request locales"),
650
+ # so a locale switch that must cover it is a `prepend_around_action`.
619
651
  before_action :enforce_params_contract if respond_to?(:before_action)
620
652
  end
621
653
 
@@ -676,17 +708,10 @@ module Permittable
676
708
  # Free-form hash. The shape is deliberately undeclared, so the only
677
709
  # checks are the bounds the field asked for: breadth (`length:`, the
678
710
  # top-level key count, same reading as an array's element count) and
679
- # nesting (`max_depth:`). Shared with macro-time `default:`/`example:`
680
- # checking, like check_scalar.
681
- def check_json(field, value)
682
- status, code = check_json_bounds(field, value)
683
- return [status, code] unless status == :ok
684
-
685
- check_custom(field[:validate], value)
686
- end
687
-
688
- # The structural half of check_json, which the request walker runs on
689
- # its own so that it can copy an accepted hash before app code sees it.
711
+ # nesting (`max_depth:`). The walker (permittable_check_json) runs them
712
+ # before `validate:`, so that it can copy an accepted hash before app
713
+ # code sees it, and an authored `default:`/`example:` goes through that
714
+ # walker too (see AuthoredValues).
690
715
  def check_json_bounds(field, value)
691
716
  return [:error, "invalid_type"] unless value.is_a?(Hash)
692
717
  return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
@@ -817,7 +842,8 @@ module Permittable
817
842
  # here (a plain :string field, not :decimal itself) must render the same
818
843
  # way regardless of whether that patch happens to be loaded.
819
844
  when BigDecimal then [:ok, value.to_s("F")]
820
- when Numeric, true, false then [:ok, value.to_s]
845
+ when Numeric then foreign_numeric { [:ok, value.to_s] }
846
+ when true, false then [:ok, value.to_s]
821
847
  else [:error, "invalid_type"]
822
848
  end
823
849
  end
@@ -849,7 +875,19 @@ module Permittable
849
875
 
850
876
  def cast_float(value)
851
877
  case value
852
- when Numeric then finite_float(value.to_f)
878
+ # A Complex is not real? (a real number), so never a Float — even Complex(1, 0),
879
+ # which :integer and :decimal refuse too ("1+0i" is no spelling of either).
880
+ # Taking the ones whose to_f happens to work would make the rule "the
881
+ # imaginary part is an exact zero": Complex(1, 0.0).to_f raises RangeError
882
+ # just as Complex(1, 1) does, and a contract must not raise on input.
883
+ #
884
+ # A zero Numeric is a genuine zero; one that only BECAME zero in to_f
885
+ # (BigDecimal("1e-400"), Rational(1, 10**400)) is underflow, exactly as
886
+ # for the String of it, so a nonzero one passes its text for the check.
887
+ when Numeric
888
+ foreign_numeric do
889
+ value.real? ? finite_float(value.to_f, source: (value.to_s unless value.zero?)) : [:error, "invalid_type"]
890
+ end
853
891
  when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
854
892
  else [:error, "invalid_type"]
855
893
  end
@@ -857,6 +895,18 @@ module Permittable
857
895
  [:error, "invalid_type"]
858
896
  end
859
897
 
898
+ # The Numeric branches call to_f, to_s and zero? on a value whose class
899
+ # the caller chose: Complex(1, 1).to_f raises RangeError, and a custom
900
+ # Numeric may raise anything. A value that cannot be converted is not a
901
+ # value of the type, so it is an invalid_type violation rather than an
902
+ # exception out of #call (a 500 where a 422 is due). Wrapped around the
903
+ # Numeric branches only; the other branches call nothing a caller wrote.
904
+ def foreign_numeric
905
+ yield
906
+ rescue StandardError
907
+ [:error, "invalid_type"]
908
+ end
909
+
860
910
  # A Float that is not finite does not represent what was sent. "1e400"
861
911
  # overflows to Infinity and "1e-400" underflows to zero — both silently,
862
912
  # and both leaving a value no column can faithfully store.
@@ -865,7 +915,8 @@ module Permittable
865
915
  # an ordinary 0.0: a zero result is rejected when the string it came from
866
916
  # named a nonzero SIGNIFICAND. Only the significand, because "0e10" is a
867
917
  # genuine zero whose exponent digits say nothing about the value — as are
868
- # "0", "0.0" and "0.0000".
918
+ # "0", "0.0" and "0.0000". A Numeric is checked the same way, by its to_s
919
+ # (see cast_float).
869
920
  def finite_float(result, source: nil)
870
921
  return [:error, "invalid_type"] unless result.finite?
871
922
  return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
@@ -879,23 +930,65 @@ module Permittable
879
930
  source.split(/[eE]/, 2).first.match?(/[1-9]/)
880
931
  end
881
932
 
933
+ # Rational#to_s is "1/2", which BigDecimal() cannot read, so the generic
934
+ # Numeric branch refused every Rational, even a whole one (3r).
882
935
  def cast_decimal(value)
883
936
  case value
884
- when Numeric then finite_decimal(BigDecimal(value.to_s))
885
- when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
937
+ when Rational then rational_to_decimal(value)
938
+ when Numeric then foreign_numeric { finite_decimal(BigDecimal(value.to_s)) }
939
+ when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value), source: value) : [:error, "invalid_type"]
886
940
  else [:error, "invalid_type"]
887
941
  end
888
942
  rescue ArgumentError
889
943
  [:error, "invalid_type"]
890
944
  end
891
945
 
892
- # BigDecimal has no exponent limit, so a :decimal cannot overflow — but
946
+ # A Rational is exactly a decimal only when its reduced denominator has
947
+ # no prime factor but 2 and 5 (1/2, 3/40), and then it divides 10**k for
948
+ # k = its bit length, which is at least the exponent of either factor.
949
+ # Anything else (1/3) has no finite expansion, so a BigDecimal could hold
950
+ # it only rounded at a precision this gem would have to pick. That is the
951
+ # guess every other coercion here refuses, so it is invalid_type.
952
+ def rational_to_decimal(value)
953
+ denominator = value.denominator
954
+ scale = denominator.bit_length
955
+ return [:error, "invalid_type"] unless ((10**scale) % denominator).zero?
956
+
957
+ finite_decimal(BigDecimal("#{value.numerator * (10**scale) / denominator}e-#{scale}"))
958
+ end
959
+
893
960
  # BigDecimal("NaN") and BigDecimal("Infinity") SUCCEED where Float()
894
961
  # raises, so a client could send the literal string "NaN" for a price and
895
962
  # have it stored. Nothing else in the gem disagreed with itself this
896
963
  # loudly: :float rejected those strings and :decimal did not.
897
- def finite_decimal(result)
898
- result.finite? ? [:ok, result] : [:error, "invalid_type"]
964
+ #
965
+ # BigDecimal's exponent is far wider than a Float's, but it is bounded:
966
+ # "1e99999999999999999999" overflows to Infinity, refused above, and
967
+ # "1e-99999999999999999999" underflows to zero — silently, like a Float,
968
+ # so the same nonzero-significand rule as finite_float applies.
969
+ def finite_decimal(result, source: nil)
970
+ return [:error, "invalid_type"] unless result.finite?
971
+ return [:error, "invalid_type"] if result.zero? && nonzero_significand?(source)
972
+ return [:error, "invalid_type"] if source && amplified?(result, source)
973
+
974
+ [:ok, result]
975
+ end
976
+
977
+ # How many digits a :decimal's plain form may run to beyond what the
978
+ # client actually sent. See amplified?.
979
+ DECIMAL_DIGIT_ALLOWANCE = 1_000
980
+
981
+ # BigDecimal keeps a huge exponent exactly: "1e4000000000000000000" is 22
982
+ # bytes and a finite value. But a database adapter quoting it, JSON
983
+ # rendering, BigDecimal#to_s and a log line all build its plain form,
984
+ # which is NoMemoryError there — and with a smaller exponent
985
+ # ("1e1000000000"), a gigabyte string from a dozen bytes of request.
986
+ # So a String whose plain form would run past both a thousand digits and
987
+ # its own length is refused: "1e400" and "1e-999" still cast, and so
988
+ # does any number spelled out digit by digit, however long, since the
989
+ # request already paid for every digit.
990
+ def amplified?(result, source)
991
+ result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
899
992
  end
900
993
 
901
994
  def cast_boolean(value)
@@ -914,6 +1007,15 @@ module Permittable
914
1007
  # ("2026-09-05", "2026/09/05", "Sep 5, 2026") still works.
915
1008
  def cast_date(value)
916
1009
  case value
1010
+ # Ahead of Date, which DateTime subclasses: `when Date` handed a
1011
+ # DateTime back whole, time of day included, so `default:
1012
+ # DateTime.new(2026, 1, 5, 12)` was stored as noon and exported as a
1013
+ # date-time under `format: date` — while a Time was refused outright,
1014
+ # and an in: member was read as an instant. Every instant is now read
1015
+ # the way that member is (see date_of_instant).
1016
+ when Time, DateTime
1017
+ date = date_of_instant(value)
1018
+ date ? [:ok, date] : [:error, "invalid_type"]
917
1019
  when Date then [:ok, value]
918
1020
  when String
919
1021
  found = Date._parse(value)
@@ -936,6 +1038,19 @@ module Permittable
936
1038
  found.key?(:year) && found.key?(:mon) && found.key?(:mday)
937
1039
  end
938
1040
 
1041
+ # The date a Time or DateTime IS, or nil: its UTC date when it is
1042
+ # exactly midnight UTC. That is the instant ActiveSupport compares a
1043
+ # Date as, and the one :datetime reads a Date as, so the two types map
1044
+ # onto each other and back. Any other instant has a time of day, which
1045
+ # a Date cannot hold, so it is not a date at all — the answer a Time
1046
+ # with a time of day always got. Shared by cast_date and
1047
+ # instant_as_date, so a value and an in: member cannot be read
1048
+ # differently; and always a plain Date, never the DateTime it came as.
1049
+ def date_of_instant(instant)
1050
+ utc = instant.to_time.getutc
1051
+ utc.to_date if utc == utc.beginning_of_day
1052
+ end
1053
+
939
1054
  # A zoneless String parses as UTC regardless of the host timezone
940
1055
  # (deterministic); explicit offsets are honoured and normalised to UTC.
941
1056
  def cast_datetime(value)
@@ -958,13 +1073,36 @@ module Permittable
958
1073
  # Time from components would have to reimplement DateTime.parse's
959
1074
  # handling of offsets, zone names and sub-second precision, and
960
1075
  # getting that subtly wrong costs more than the parse.
961
- complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]
1076
+ #
1077
+ # Parsed in the proleptic Gregorian calendar, which is what ISO 8601
1078
+ # and Time use. DateTime.parse defaults to the 1582 Italian reform
1079
+ # and read an earlier date as Julian; stdlib's DateTime#to_time then
1080
+ # converts by day number, so "1500-01-01" became 1500-01-10 — unless
1081
+ # the host had loaded ActiveSupport's DateTime#to_time, which
1082
+ # converts by component. Gregorian, both agree.
1083
+ found = Date._parse(value)
1084
+ return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1085
+
1086
+ [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
962
1087
  else [:error, "invalid_type"]
963
1088
  end
964
1089
  rescue ArgumentError, RangeError
965
1090
  [:error, "invalid_type"]
966
1091
  end
967
1092
 
1093
+ # A zone the parser could not turn into an offset — an offset of a day
1094
+ # or more ("+24:00", "-99:00", "+99") or an abbreviation it does not
1095
+ # know ("XYZ") — is reported as a zone with no offset, or with one
1096
+ # beyond a day, and DateTime.parse then reads the wall clock as UTC: an
1097
+ # instant hours away from the one the client named. The client stated a
1098
+ # zone, so it is refused rather than guessed at.
1099
+ def honoured_zone?(found)
1100
+ return true unless found.key?(:zone)
1101
+
1102
+ offset = found[:offset]
1103
+ !offset.nil? && offset.abs < 86_400
1104
+ end
1105
+
968
1106
  # Presets only make sense on String input; a non-String value (JSON
969
1107
  # numbers, booleans) skips normalization and goes straight to the cast.
970
1108
  #
@@ -1042,41 +1180,58 @@ module Permittable
1042
1180
  end
1043
1181
 
1044
1182
  # The members of an `in:` that is a LIST, or nil when it is not one.
1045
- # Only Array, Set, Hash and Enumerator count, and only when the object's
1046
- # OWN class provides the collection's ordinary include? — not a Hash,
1047
- # Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a
1048
- # fuzzy Set, a registry matching some other way entirely). `case allowed;
1049
- # when Hash ...` matches with ===, which for a Class is is_a?, so a
1050
- # subclass would otherwise match its ancestor's branch and have its
1051
- # override silently discarded — read for its raw keys/elements instead,
1052
- # which can invert which values it actually accepts. It is left opaque
1053
- # instead, exactly like any other object whose include? is the point
1054
- # (see resolve_in!) and enumerating it may be expensive (a DB-backed
1183
+ # Only Array, Set, Hash and Enumerator count, and only when the object
1184
+ # answers with the collection's ordinary include? — not one a Hash,
1185
+ # Array or Set SUBCLASS overrides (a case-insensitive allowlist, a fuzzy
1186
+ # Set, a registry matching some other way entirely). `case allowed; when
1187
+ # Hash ...` matches with ===, which for a Class is is_a?, so a subclass
1188
+ # would otherwise match its ancestor's branch and have its override
1189
+ # silently discarded — read for its raw keys/elements instead, which can
1190
+ # invert which values it actually accepts. It is left opaque instead,
1191
+ # exactly like any other object whose include? is the point (see
1192
+ # resolve_in!) and enumerating it may be expensive (a DB-backed
1055
1193
  # registry).
1056
1194
  #
1195
+ # So the test is which module OWNS include?, not the object's exact
1196
+ # class. A subclass that keeps its base's include? answers exactly what
1197
+ # its keys or elements say, and is a list like its base. Rails'
1198
+ # `config_for(:plans)` returns one, an ActiveSupport::OrderedOptions:
1199
+ # an exact-class test left it opaque, so `include?("pro")` asked about
1200
+ # its Symbol keys and every request was refused.
1201
+ #
1057
1202
  # A Hash lists its KEYS, which is what Hash#include? asks about — the
1058
1203
  # Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a
1059
1204
  # Set, so membership stays O(1) per request.
1060
- # ActiveSupport::HashWithIndifferentAccess is the one Hash subclass
1061
- # accepted anyway: its include? override only canonicalises the argument
1062
- # (String/Symbol) before the SAME key lookup, so its keys are still
1063
- # exactly its members — and it is what a Rails enum's own reader
1064
- # (`Post.statuses`) actually returns.
1205
+ # ActiveSupport::HashWithIndifferentAccess is the one include? override
1206
+ # accepted anyway: it only canonicalises the argument (String/Symbol)
1207
+ # before the SAME key lookup, so its keys are still exactly its members
1208
+ # — and it is what a Rails enum's own reader (`Post.statuses`) actually
1209
+ # returns.
1065
1210
  # Enumerator::Lazy is the same story on the Enumerator side: Lazy
1066
1211
  # overrides chain methods like map and select, but not include?, so it
1067
1212
  # is still read as a list — and forced to an Array here, once, since
1068
1213
  # left lazy it would be cast on every request instead of at class load.
1069
1214
  def in_list(allowed)
1070
1215
  case allowed
1071
- when Hash then allowed.keys.to_set if plain_hash?(allowed)
1072
- when Set then allowed if allowed.instance_of?(Set)
1073
- when Array then allowed.to_a if allowed.instance_of?(Array)
1074
- when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable
1216
+ when Hash then allowed.keys.to_set if include_from?(allowed, Hash, ActiveSupport::HashWithIndifferentAccess)
1217
+ when Set then allowed if include_from?(allowed, Set)
1218
+ when Array then allowed.to_a if include_from?(allowed, Array)
1219
+ when Enumerator then allowed.to_a if include_from?(allowed, Enumerator)
1075
1220
  end
1076
1221
  end
1077
1222
 
1078
- def plain_hash?(allowed)
1079
- allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess)
1223
+ # Whether `allowed` answers include? as one of `bases` itself does. What
1224
+ # is compared is the owner the BASE class reports, not a literal class:
1225
+ # a gem that prepends a module defining include? into Array, Hash or Set
1226
+ # (an instrumenting one) makes that module the owner for every instance,
1227
+ # and a test against the literal class then read every plain
1228
+ # `in: %i[draft published]` as an object with an include? of its own,
1229
+ # used it as given, and refused "draft". A prepended module is the
1230
+ # base's own include?; a subclass, a singleton method or an `extend`
1231
+ # still has a different owner.
1232
+ def include_from?(allowed, *bases)
1233
+ owner = allowed.method(:include?).owner
1234
+ bases.any? { |base| base.instance_method(:include?).owner == owner }
1080
1235
  end
1081
1236
 
1082
1237
  # A Symbol is read as its String: it is how Ruby spells a constant
@@ -1085,20 +1240,34 @@ module Permittable
1085
1240
  member = member.to_s if member.is_a?(Symbol)
1086
1241
  return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime))
1087
1242
 
1088
- cast(type, member)
1243
+ status, value = cast(type, member)
1244
+ return [status, value] unless status == :ok && type == :datetime && !member.is_a?(String) && !whole_nanoseconds?(value)
1245
+
1246
+ [:error, "finer than a nanosecond, so it never equals a request; round it, e.g. with .round(9)"]
1247
+ end
1248
+
1249
+ # A request's fraction of a second is a finite decimal, and the export
1250
+ # prints a member to at most nine digits, so a member that is not a whole
1251
+ # number of nanoseconds — Time.at(1.1) holds 1.10000000000000008881...,
1252
+ # Time.utc(..., Rational(1, 3)) a third — was published as an enum value
1253
+ # the server refuses and could never be matched. The :datetime analogue
1254
+ # of instant_as_date. Only for a Time-like member: a String member is
1255
+ # cast exactly as a request sending it is, and published as written, so
1256
+ # whatever precision it spells is reachable.
1257
+ def whole_nanoseconds?(time)
1258
+ (time.subsec * 1_000_000_000).denominator == 1
1089
1259
  end
1090
1260
 
1091
1261
  # A Time or DateTime member of a :date field. ActiveSupport compares one
1092
1262
  # with a Date as INSTANTS, the Date standing for its midnight UTC, so
1093
1263
  # that instant is the only one that ever equalled a request's date. It
1094
1264
  # is read as that UTC date; any other instant never matched anything,
1095
- # and is refused like any member no request could equal. (cast_date
1096
- # would keep a DateTime whole — it IS a Date — and refuse a Time.)
1265
+ # and is refused like any member no request could equal. cast_date
1266
+ # reads it the same way (date_of_instant); this only gives the
1267
+ # class-load error a reason where a request gets `invalid_type`.
1097
1268
  def instant_as_date(member)
1098
- utc = member.to_time.getutc
1099
- return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day
1100
-
1101
- [:ok, utc.to_date]
1269
+ date = date_of_instant(member)
1270
+ date ? [:ok, date] : [:error, "not midnight UTC, so it never equals a date"]
1102
1271
  end
1103
1272
 
1104
1273
  # What an exported enum lists for one member: the cast value, re-encoded
@@ -1199,6 +1368,7 @@ module Permittable
1199
1368
  validate_array_authored_value!(field, :default) if field.key?(:default)
1200
1369
  validate_array_authored_value!(field, :example) if field.key?(:example)
1201
1370
  validate_message!(field)
1371
+ freeze_desc!(field)
1202
1372
  @fields << field
1203
1373
  end
1204
1374
 
@@ -1214,10 +1384,13 @@ module Permittable
1214
1384
  # end
1215
1385
  #
1216
1386
  # `optional: true` relaxes every spliced field, which is how an update
1217
- # contract reuses a create contract: nothing is mandatory, but `default:`,
1218
- # types and bounds all still apply. It relaxes the TOP LEVEL only — if a
1219
- # client sends an address at all, the address's own required sub-fields
1220
- # still hold.
1387
+ # contract reuses a create contract: nothing is mandatory, and types and
1388
+ # bounds all still apply. It drops each relaxed field's `default:` too. A
1389
+ # default is filled in for every request that omits the field, so on a
1390
+ # PATCH it would overwrite the column the client left alone: `default:
1391
+ # "free"` reset every "pro" user who only renamed themself. It relaxes
1392
+ # the TOP LEVEL only — if a client sends an address at all, the
1393
+ # address's own required sub-fields and defaults still hold.
1221
1394
  #
1222
1395
  # `only:`/`except:` select a subset, in the group's own order. Naming a
1223
1396
  # field the group doesn't declare is a class-load error, so a typo cannot
@@ -1226,7 +1399,7 @@ module Permittable
1226
1399
  # and then declare `:city` yourself.
1227
1400
  def use(group, only: nil, except: nil, optional: false)
1228
1401
  fields = select_group_fields!(group_fields!(group), only: only, except: except)
1229
- fields = fields.map { |field| field.merge(required: false).freeze } if optional
1402
+ fields = fields.map { |field| field.except(:default).merge(required: false).freeze } if optional
1230
1403
  fields.each do |field|
1231
1404
  field_name!(field[:name])
1232
1405
  @fields << field
@@ -1236,7 +1409,22 @@ module Permittable
1236
1409
 
1237
1410
  private
1238
1411
 
1412
+ # A Contract is usable as a group because it answers #fields — but its
1413
+ # finalize is not a field, so splicing would drop the contract's
1414
+ # cross-field rule: a standalone Booking contract rejecting an ends_on
1415
+ # before its starts_on, used by a controller that then accepts it. That
1416
+ # is the drift `use` exists to prevent, so it is refused here, loudly,
1417
+ # the way a field group's own finalize is. Also under only:/except: — a
1418
+ # finalize is opaque, so there is no part of it to keep — which the
1419
+ # message says, since that is the first thing a user tries.
1239
1420
  def group_fields!(group)
1421
+ if group.is_a?(Contract) && group.rule[:finalize]
1422
+ raise ArgumentError, "#{LABEL}: use cannot carry a Permittable::Contract's finalize, so the contract's " \
1423
+ "cross-field rule would be silently dropped. Selecting fields with only:/except: " \
1424
+ "does not lift this: the finalize cannot be split, so it is refused for any part " \
1425
+ "of the contract. To share both, declare the fields in a Permittable.fields " \
1426
+ "group that the contract and this block each use, and declare finalize in each"
1427
+ end
1240
1428
  return group.fields if group.respond_to?(:fields)
1241
1429
 
1242
1430
  raise ArgumentError, "#{LABEL}: use expects a field group (Permittable.fields { ... }) or anything " \
@@ -1275,6 +1463,7 @@ module Permittable
1275
1463
  fields: nested_fields!(name, &block), **opts }
1276
1464
  field[:fields] = cascade_sensitive(field[:fields], field[:sensitive])
1277
1465
  validate_message!(field)
1466
+ freeze_desc!(field)
1278
1467
  elsif type&.to_sym == JSON_TYPE
1279
1468
  assert_opts!(name, opts, JSON_OPTS)
1280
1469
  # `type:` is carried alongside `kind:` so the same `as(:json)` matcher
@@ -1379,6 +1568,7 @@ module Permittable
1379
1568
  validate_authored_value!(field, :default)
1380
1569
  validate_authored_value!(field, :example)
1381
1570
  validate_message!(field)
1571
+ freeze_desc!(field)
1382
1572
  end
1383
1573
 
1384
1574
  # `in:` is a Range (bounds-checked with cover?), a list of values, or an
@@ -1394,19 +1584,34 @@ module Permittable
1394
1584
  # enum rule all read the members the runtime compares against. A member
1395
1585
  # no request value could ever equal is a contract mistake, and fails here
1396
1586
  # rather than as an `inclusion` on every request.
1587
+ #
1588
+ # An empty list or a reversed Range is refused for the same reason, but
1589
+ # an object used as given is not asked: it is read on every request, so
1590
+ # a live registry that is empty now (filled in an initializer or
1591
+ # `after_initialize`, after `eager_load!` has run this) is the very use
1592
+ # the README recommends it for.
1397
1593
  def resolve_in!(field)
1398
1594
  name = field[:name]
1399
1595
  allowed = field[:in]
1400
1596
  if allowed.is_a?(Range)
1401
1597
  assert_comparable_range!(field, allowed)
1598
+ assert_satisfiable!(name, :in, allowed)
1402
1599
  elsif (members = Coercion.in_list(allowed))
1403
1600
  cast_in_members!(field, members)
1601
+ assert_satisfiable!(name, :in, field[:in])
1404
1602
  elsif allowed.is_a?(String) || !allowed.respond_to?(:include?)
1405
1603
  raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \
1406
1604
  "or a Hash read as its keys), or an object answering include? " \
1407
1605
  "(got #{allowed.inspect})#{string_in_hint(allowed)}"
1408
1606
  end
1409
- assert_satisfiable!(name, :in, field[:in])
1607
+ end
1608
+
1609
+ # An `in:` that is neither a Range nor a list the gem read and cast
1610
+ # itself, so one resolve_in! kept exactly as given: its contents are the
1611
+ # host's, and unknown at class load.
1612
+ def live_in?(field)
1613
+ allowed = field[:in]
1614
+ !allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
1410
1615
  end
1411
1616
 
1412
1617
  def string_in_hint(allowed)
@@ -1484,6 +1689,7 @@ module Permittable
1484
1689
  validate_json_authored_value!(field, :default)
1485
1690
  validate_json_authored_value!(field, :example)
1486
1691
  validate_message!(field)
1692
+ freeze_desc!(field)
1487
1693
  end
1488
1694
 
1489
1695
  def validate_max_depth!(name, depth)
@@ -1492,17 +1698,32 @@ module Permittable
1492
1698
  raise ArgumentError, "#{LABEL}: :max_depth for :#{name} must be a positive Integer"
1493
1699
  end
1494
1700
 
1495
- # Same rule as a scalar's authored value, over check_json: a `default:` or
1496
- # `example:` that its own bounds would reject fails at class load.
1701
+ # Same rule as a scalar's authored value: a `default:` or `example:` that
1702
+ # its own bounds would reject fails at class load. Like an array's, it is
1703
+ # read by the request walker itself (see AuthoredValues), so it is checked
1704
+ # as a request sending the same hash is. The raw Hash used to be checked
1705
+ # instead: `validate: ->(p) { p[:theme] == "dark" }` passed a request's
1706
+ # {"theme":"dark"}, which the walker reads indifferently, and refused the
1707
+ # same Hash authored as `default:`. And a `validate:` that edits its
1708
+ # argument edited the host's own Hash, which a request's deep copy spares.
1709
+ #
1710
+ # For a `default:` the walker's copy is what is stored, so the action, the
1711
+ # export and the RSpec matcher all read what a request sending it would
1712
+ # have produced. An `example:` is not: it documents what a client SENDS,
1713
+ # so it is validated on that copy but stored as a fresh copy of the value
1714
+ # AS AUTHORED (request-shaped, indifferent with String keys), which a
1715
+ # `validate:` editing its argument cannot have added a key to or dropped
1716
+ # one from. That matches every other example on a contract, which is never
1717
+ # processed the way the action would see it.
1497
1718
  def validate_json_authored_value!(field, opt)
1498
1719
  return unless field.key?(opt)
1499
1720
  return if authored_nil!(field, opt)
1500
1721
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
1501
1722
 
1502
- status, code = Coercion.check_json(field, field[opt])
1503
- raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{code})" unless status == :ok
1723
+ status, read = AuthoredValues.read_json(field, field[opt])
1724
+ raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{read})" unless status == :ok
1504
1725
 
1505
- field[opt] = freeze_authored(field[opt])
1726
+ field[opt] = freeze_authored(opt == :example ? ActiveSupport::HashWithIndifferentAccess.new(field[opt]) : read)
1506
1727
  end
1507
1728
 
1508
1729
  # format / length / normalize reason about characters; on any other
@@ -1522,10 +1743,33 @@ module Permittable
1522
1743
  raise ArgumentError, "#{LABEL}: :length for :#{name} must be a non-negative Integer or a Range " \
1523
1744
  "(got #{length.inspect})"
1524
1745
  end
1746
+ validate_length_ends!(name, length) if length.is_a?(Range)
1525
1747
 
1526
1748
  assert_satisfiable!(name, :length, length)
1527
1749
  end
1528
1750
 
1751
+ # A length is a count, so each end of a Range is a non-negative whole
1752
+ # number — an Integer, or a Float/Rational/BigDecimal with no fraction
1753
+ # (`1..10.0` means ten) — or open: nil, or an infinite Float on its own
1754
+ # side (`1..Float::INFINITY` predates endless Ranges). A negative end matched nothing (`..-1`
1755
+ # rejected every value, forever, as the client's fault) or meant nothing
1756
+ # (`-2..3` is `..3`), and a fractional one exported a minLength/maxLength
1757
+ # the JSON Schema metaschema refuses — Ajv rejects the whole document.
1758
+ def validate_length_ends!(name, length)
1759
+ ok = length_end?(length.begin, -Float::INFINITY) && length_end?(length.end, Float::INFINITY)
1760
+ return if ok
1761
+
1762
+ raise ArgumentError, "#{LABEL}: :length for :#{name} must have non-negative whole-number ends " \
1763
+ "(got #{length.inspect}); leave an end off for no bound, e.g. 1.. or ..80"
1764
+ end
1765
+
1766
+ def length_end?(value, open)
1767
+ return true if value.nil? || value == open
1768
+ return false unless value.is_a?(Numeric) && value.real? && value.finite?
1769
+
1770
+ !value.negative? && value == value.to_i
1771
+ end
1772
+
1529
1773
  # A reversed Range (5..2), an exclusive Range with equal endpoints
1530
1774
  # (3...3), or an empty set (in: []) excludes every value there is, so the
1531
1775
  # field it bounds can never validate. That used to surface as every
@@ -1614,7 +1858,7 @@ module Permittable
1614
1858
  # same value gets, and what used to be thrown away: `default: "18"` used
1615
1859
  # to be handed to every request omitting it as the String "18", and
1616
1860
  # `:boolean, default: "false"` gave the app a truthy String.
1617
- # With a `transform:`, the value is stored exactly AS AUTHORED instead —
1861
+ # With a `transform:`, a default is stored exactly AS AUTHORED instead —
1618
1862
  # `transform:` never runs on a default (see AuthoredValues), so casting it
1619
1863
  # here would silently change its type out from under an author who, per
1620
1864
  # the README, writes such a default in the shape the action should
@@ -1622,6 +1866,17 @@ module Permittable
1622
1866
  # :string field means the app gets the Integer 25 either way, whether the
1623
1867
  # request sent "25" (cast then transformed) or omitted the field
1624
1868
  # (authored as the already-final Integer).
1869
+ #
1870
+ # An `example:` is cast either way. It documents what a client SENDS and
1871
+ # never reaches the action, so that reason does not apply to it: under
1872
+ # `transform:` it used to be published uncast, `"type": "integer",
1873
+ # "examples": ["18"]`, the very lie the cast exists to stop.
1874
+ #
1875
+ # The one rule not checked is `in:` when it is an object used as given
1876
+ # (see resolve_in!): a live registry is empty at class load, so
1877
+ # `in: LIVE, default: "order.paid"` would be refused for a member it will
1878
+ # hold by the first request. What it will contain is unknown then, so its
1879
+ # `include?` is not asked; every other rule is still checked.
1625
1880
  def validate_authored_value!(field, opt)
1626
1881
  return unless field.key?(opt)
1627
1882
  return if authored_nil!(field, opt)
@@ -1630,10 +1885,10 @@ module Permittable
1630
1885
  # proc cannot rewrite the host's own literal.
1631
1886
  authored = field[opt].is_a?(String) ? field[opt].dup : field[opt]
1632
1887
  value = Coercion.apply_normalize(field[:normalize], authored)
1633
- status, result = Coercion.check_scalar(field, value)
1888
+ status, result = Coercion.check_scalar(live_in?(field) ? field.except(:in) : field, value)
1634
1889
  raise ArgumentError, "#{LABEL}: :#{opt} for field :#{field[:name]} violates its own contract (#{result})" unless status == :ok
1635
1890
 
1636
- field[opt] = freeze_authored(field[:transform] ? field[opt] : result)
1891
+ field[opt] = freeze_authored(opt == :default && field[:transform] ? field[opt] : result)
1637
1892
  end
1638
1893
 
1639
1894
  # An array's authored value is validated by the REQUEST walker itself
@@ -1646,27 +1901,61 @@ module Permittable
1646
1901
  # cast only the top level of each element, and got every one of those
1647
1902
  # wrong.
1648
1903
  #
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.
1904
+ # What is STORED for a default follows the same split as a scalar's:
1905
+ # without `transform:`, the walker's read (exactly what a request sending
1906
+ # it gets); with one, the array exactly AS AUTHORED — the walker still
1907
+ # runs, so a declaration mistake (an element `validate:` refuses, a
1908
+ # sub-field default out of bounds) still fails at class load, but its
1909
+ # cast result is discarded rather than stored. The array's OWN
1910
+ # `transform:` is deliberately never run on a default either way. A
1911
+ # SUB-FIELD's `transform:` still runs during that walk, so "the walker's
1912
+ # read" above really is what an equivalent request produces: all of them
1913
+ # when the array has no `transform:` of its own, and, when it has one,
1914
+ # those below an array declaring `validate:`, the only readers of their
1915
+ # output (a request runs sub-field transforms, then that `validate:`,
1916
+ # then the array's own transform) — see AuthoredValues.
1917
+ #
1918
+ # An `example:` is validated the same way, as a request would be, but what
1919
+ # is STORED is a second read of it exactly as a client SENDS it: cast and
1920
+ # normalized, with no `transform:` run at any depth and no sub-field
1921
+ # `default:` filled in. Read through a sub-field's transform,
1922
+ # `[{ price: "9.99" }]` under `transform: ->(d) { (d * 100).to_i }` was
1923
+ # published as `[{ "price": 999 }]`: 100 times the price, and a `tags`
1924
+ # split into an Array the server refuses. The two reads cannot be one: an
1925
+ # array-level `validate:` must see the transformed sub-fields a request
1926
+ # hands it (`i[:tags].is_a?(Array)`), so the validating read keeps them,
1927
+ # and the stored read, which runs no `validate:`, discards them.
1928
+ #
1929
+ # The transforming read exists only for that `validate:`, so it runs only
1930
+ # when one could consume it. Anywhere else its output is thrown away, yet
1931
+ # it would still call app code at class load: `optional :id, :string,
1932
+ # transform: ->(s) { Account.find(s) }` under an `example:` hit the
1933
+ # database, or raised, at boot. Only an array carries a `validate:` that
1934
+ # sees its transformed children: a scalar's or a :json field's runs on
1935
+ # its own value before its own transform, and a nested hash takes none.
1936
+ # Without one, the as-sent read both validates and is stored.
1658
1937
  def validate_array_authored_value!(field, opt)
1659
1938
  value = field[opt]
1660
1939
  return if authored_nil!(field, opt)
1661
1940
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} must be an Array" unless value.is_a?(Array)
1662
1941
 
1663
- read, violations = AuthoredValues.read_array(field, value)
1942
+ as_sent = opt == :example && !array_validate_in?(field)
1943
+ read, violations = AuthoredValues.read_array(field, value, as_sent: as_sent)
1664
1944
  unless violations.empty?
1665
1945
  raise ArgumentError, "#{LABEL}: :#{opt} for array :#{field[:name]} violates its own contract: " \
1666
1946
  "#{AuthoredValues.summary(violations)}"
1667
1947
  end
1668
1948
 
1669
- field[opt] = freeze_authored(field[:transform] ? value : read)
1949
+ read, = AuthoredValues.read_array(field, value, as_sent: true) if opt == :example && !as_sent
1950
+ field[opt] = freeze_authored(opt == :default && field[:transform] ? value : read)
1951
+ end
1952
+
1953
+ # Whether `field`, or any array or nested hash below it, declares an
1954
+ # array-level `validate:`.
1955
+ def array_validate_in?(field)
1956
+ return true if field[:kind] == :array && field[:validate]
1957
+
1958
+ Array(field[:fields]).any? { |sub| array_validate_in?(sub) }
1670
1959
  end
1671
1960
 
1672
1961
  # A contract is frozen data, but `@fields.map(&:freeze)` freezes only the
@@ -1705,6 +1994,18 @@ module Permittable
1705
1994
  "declare nullable: true to make an explicit null part of the contract"
1706
1995
  end
1707
1996
 
1997
+ # `desc:` is exported as the schema's `description`, and was handed out
1998
+ # by reference: a caller appending to a generated document's
1999
+ # description (" (deprecated)", a gsub! for localisation) rewrote the
2000
+ # contract inside the frozen rule, and every later export in the process
2001
+ # carried the edit. Frozen as a copy, like message:, default: and
2002
+ # example:, so the host's own String is left as it passed it. The
2003
+ # exporters hand out an unfrozen `dup` of it, so editing a generated
2004
+ # document in place works and still cannot reach the contract.
2005
+ def freeze_desc!(field)
2006
+ field[:desc] = freeze_authored(field[:desc]) unless field[:desc].nil?
2007
+ end
2008
+
1708
2009
  # `message:` customizes what the client reads for a violation on this
1709
2010
  # field: one String covering every code, or a Hash of code => String
1710
2011
  # (codes without an entry keep the default rendering). Keys are
@@ -1804,7 +2105,7 @@ module Permittable
1804
2105
 
1805
2106
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
1806
2107
  model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
1807
- finalize: builder.finalizer, desc: desc }.freeze
2108
+ finalize: builder.finalizer, desc: desc&.deep_dup&.freeze }.freeze
1808
2109
  self.permittable_contracts = permittable_contracts + [rule]
1809
2110
  end
1810
2111
 
@@ -1851,10 +2152,11 @@ module Permittable
1851
2152
 
1852
2153
  types = checked.to_h { |f| [f[:name], f[:type]] }
1853
2154
  allowed = checked.select { |f| f.key?(:in) }.to_h { |f| [f[:name], f[:in]] }
2155
+ transformed = checked.select { |f| f[:transform] }.map { |f| f[:name].to_sym }
1854
2156
  begin
1855
2157
  ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] },
1856
2158
  types: types, check_types: Permittable.check_column_types,
1857
- allowed: allowed)
2159
+ allowed: allowed, transformed: transformed)
1858
2160
  rescue ArgumentError => e
1859
2161
  # The type error carries its own guidance; only the missing-column one
1860
2162
  # needs the virtual: hint appended.
@@ -1929,10 +2231,33 @@ module Permittable
1929
2231
  return nil unless action
1930
2232
 
1931
2233
  rule = self.class.permit_rule_for(action)
1932
- permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
2234
+ return nil unless rule
2235
+
2236
+ if permittable_mode(rule) == :monitor
2237
+ permittable_monitor_eagerly(action)
2238
+ elsif rule[:enforce]
2239
+ permitted_params(action)
2240
+ end
1933
2241
  nil
1934
2242
  end
1935
2243
 
2244
+ # Monitor mode's promise is that the action runs, whatever the request
2245
+ # holds — that is what makes it safe to turn on. Checking eagerly reads
2246
+ # `params`, which raises ActionDispatch's ParseError on a body Rails
2247
+ # cannot parse: a webhook action reading request.raw_post itself got a
2248
+ # 400 instead of running. An exception from the contract's own code
2249
+ # (validate:, transform:, finalize) became a 500 the same way. Either is
2250
+ # now logged, by class only (a parse error's message can quote the body),
2251
+ # and the action runs; one that reads permitted_params meets the same
2252
+ # exception itself, as it would with no contract at all.
2253
+ def permittable_monitor_eagerly(action)
2254
+ permitted_params(action)
2255
+ rescue StandardError => e
2256
+ return unless respond_to?(:logger) && logger
2257
+
2258
+ logger.warn("#{LABEL}: [monitor] ##{action} could not be checked: #{e.class} — the action runs unchecked")
2259
+ end
2260
+
1936
2261
  # The violation details recorded by validating `action` (default: the
1937
2262
  # current action) — [] when the request satisfied the contract. Triggers
1938
2263
  # the same memoized validation as permitted_params, so under monitor mode
@@ -2292,6 +2617,7 @@ module Permittable
2292
2617
 
2293
2618
  def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
2294
2619
  result = ActiveSupport::HashWithIndifferentAccess.new
2620
+ hash = permittable_read_date_parts(fields, hash)
2295
2621
  fields.each do |field|
2296
2622
  key = field[:name].to_s
2297
2623
  full = permittable_path(path, key)
@@ -2314,6 +2640,119 @@ module Permittable
2314
2640
  result
2315
2641
  end
2316
2642
 
2643
+ # See DATE_PARTS. A :date or :datetime field reads the parts sent for it
2644
+ # as its own value. They are replaced, in a COPY of `hash`, by the one
2645
+ # value they make, under the field's own key, so the walk sees exactly
2646
+ # what it would have had the client sent that value: the cast, `in:`,
2647
+ # `validate:`, `transform:`, the absence rule and `nullable:` all apply
2648
+ # unchanged, the action gets the field's own key, and the unknown-keys
2649
+ # check never sees the parts it read. `hash` itself is left as it is: it
2650
+ # may be the request's own params, which monitor mode hands back as they
2651
+ # arrived.
2652
+ #
2653
+ # Only when the field's own key is ABSENT. When both are sent, the key the
2654
+ # contract declares wins the VALUE, and the parts are left in the copy,
2655
+ # untouched, for the unknown-keys check to judge as it always has: flagged
2656
+ # under `unknown: :error`, logged under `:log`, dropped under `:ignore`.
2657
+ # They must not be consumed unread: strong parameters passes them through
2658
+ # `permit(:starts_on)` (the legacy action of an `enforce: true` rollout) and
2659
+ # ActiveRecord assigns multiparameter attributes AFTER the plain ones, so
2660
+ # parts hidden from `unknown: :error` would overwrite the value the
2661
+ # contract validated. And not when the contract declares one of the parts
2662
+ # as a field of its own, which is how a contract could carry them through
2663
+ # before this: that field keeps it.
2664
+ def permittable_read_date_parts(fields, hash)
2665
+ read = nil
2666
+ fields.each do |field|
2667
+ count = field[:kind] == :scalar && DATE_PARTS[field[:type]]
2668
+ next unless count
2669
+
2670
+ key = field[:name].to_s
2671
+ names = Array.new(count) { |index| "#{key}(#{index + 1}i)" }
2672
+ next if hash.key?(key)
2673
+ next unless names.any? { |name| hash.key?(name) }
2674
+ next if fields.any? { |other| names.include?(other[:name].to_s) }
2675
+
2676
+ read ||= hash.dup
2677
+ parts = {}
2678
+ names.each.with_index(1) { |name, position| parts[position] = read.delete(name) if read.key?(name) }
2679
+
2680
+ # Parts that make no value are handed to the field as the several
2681
+ # values they are, which a scalar refuses as invalid_type: on the
2682
+ # field's own path, with its own message:, as for `?starts_on[]=x`.
2683
+ read[key] = permittable_date_from_parts(parts, field[:type]) || parts.values
2684
+ end
2685
+ read || hash
2686
+ end
2687
+
2688
+ # The ISO 8601 String that `parts` (keyed by Rails' positions, 1-3 the
2689
+ # date and 4-6 the time) spell for a field of `type`, "" when they spell
2690
+ # the field sent empty, or nil when they spell nothing at all. A String is
2691
+ # what a :date or :datetime field already reads, so its cast and that
2692
+ # cast's rules (a zoneless datetime is UTC) stay in one place.
2693
+ #
2694
+ # This follows ActiveRecord's own reading of the same parts
2695
+ # (ActiveModel::Type::Date and DateTime cast them as a Hash): a value
2696
+ # needs all of (1i)-(3i), and when any one of them is blank the attribute
2697
+ # is nil, so the field reads as sent empty. That has to be ANY and not all
2698
+ # of them: Rails fills a part it discards (discard_day, or the date of a
2699
+ # time_select) through a hidden input, and the year and month selects of
2700
+ # an untouched picker still sit on their prompts beside it, so the form
2701
+ # arrives as {"(1i)" => "", "(2i)" => "", "(3i)" => "1"}. The time parts
2702
+ # of a :datetime are the other way round: ActiveModel defaults a blank
2703
+ # (4i), (5i) or (6i) to 00 once the date is there, and so does this. The
2704
+ # blank part need only be among those SENT: ActiveModel reads a Hash with
2705
+ # (2i) and (3i) but no (1i), or with (1i) and (2i) but no (3i), as nil when
2706
+ # one of the ones it has is blank, and so does this. A part that is missing
2707
+ # with no blank sibling is nil here, for a scalar to refuse as
2708
+ # invalid_type, when it is one of (1i)-(3i): a time_select with
2709
+ # `ignore_date:` sends only (4i) and (5i), and a :datetime needs a date.
2710
+ # A MISSING (4i), (5i) or (6i) is 00 like a blank one, which is what
2711
+ # ActiveModel does with {1 => 2026, 2 => 10, 3 => 17}: a date_select on a
2712
+ # :datetime column, or a datetime_select with `discard_hour:`, sends the
2713
+ # date alone, and (6i) comes with `include_seconds:` only.
2714
+ # Whatever else is not an unsigned decimal integer is nil likewise.
2715
+ def permittable_date_from_parts(parts, type)
2716
+ return "" if (1..3).any? { |position| parts.key?(position) && Coercion.absent_value?(parts[position]) }
2717
+
2718
+ time = type == :datetime
2719
+ digits = (1..3).map { |position| permittable_date_part(parts[position], year: position == 1) }
2720
+ if time
2721
+ # A time part that is blank or missing is 00, as ActiveModel reads it.
2722
+ digits += (4..6).map do |position|
2723
+ next "00" if !parts.key?(position) || Coercion.absent_value?(parts[position])
2724
+
2725
+ permittable_date_part(parts[position])
2726
+ end
2727
+ end
2728
+ return nil if digits.include?(nil)
2729
+
2730
+ year, month, day, hour, minute, second = digits
2731
+ time ? "#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}" : "#{year}-#{month}-#{day}"
2732
+ end
2733
+
2734
+ # One part's digits, padded once its leading zeros are dropped: "7" and
2735
+ # "007" are both "07". Joined as sent, Date._parse reads "2026-007-5" as
2736
+ # May 7th. The year is padded to four digits, so "99" is the year 99 (as
2737
+ # ActiveRecord reads it) rather than a two-digit year Date._parse places
2738
+ # in 1999, and is otherwise left to the cast, as a year in a String is.
2739
+ # Any other part has at most two digits: a month of "100" is no value a
2740
+ # select offers, and Date._parse would read the string around it some
2741
+ # other way. A non-negative Integer is the digits it prints: a JSON body or
2742
+ # an `as: :json` request cannot send the String a form does, and
2743
+ # ActiveRecord reads either. nil for anything that is not an unsigned
2744
+ # decimal integer.
2745
+ def permittable_date_part(part, year: false)
2746
+ part = part.to_s if part.is_a?(Integer) && !part.negative?
2747
+ text = Coercion.utf8_text(part) if part.is_a?(String)
2748
+ return nil unless text&.match?(DATE_PART_FORMAT)
2749
+
2750
+ digits = text.sub(/\A0+(?=\d)/, "")
2751
+ return digits.rjust(4, "0") if year
2752
+
2753
+ digits.rjust(2, "0") if digits.length <= 2
2754
+ end
2755
+
2317
2756
  def permittable_check_field(field, value, full, result, unknown:, violations:)
2318
2757
  key = field[:name].to_s
2319
2758
  case field[:kind]
@@ -2330,6 +2769,7 @@ module Permittable
2330
2769
  violations << permittable_violation(field, full, "invalid_type")
2331
2770
  end
2332
2771
  when :array
2772
+ value = value.values if permittable_fields_for_records?(field, value)
2333
2773
  if value.is_a?(Array)
2334
2774
  result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
2335
2775
  else
@@ -2338,8 +2778,22 @@ module Permittable
2338
2778
  end
2339
2779
  end
2340
2780
 
2341
- # Coercion.check_json, with the copy the result needs made in the middle.
2342
- # The opaque hash is handed over whole, and HashWithIndifferentAccess
2781
+ # Whether `value` is an array of hashes sent the way Rails' fields_for
2782
+ # sends one: `user[addresses_attributes][0][city]` reaches the params as a
2783
+ # Hash keyed "0", "1", ... (or a timestamp, for a record added in the
2784
+ # browser). Strong params permits exactly this shape (its
2785
+ # fields_for_style?: every key an integer, every value a hash) and
2786
+ # accepts_nested_attributes_for reads its values in order, so an
2787
+ # `array ... do` field reads them as its elements. Paths then count the
2788
+ # records in the order sent. An empty Hash, or one of scalars, is not this
2789
+ # shape and stays invalid_type.
2790
+ def permittable_fields_for_records?(field, value)
2791
+ field[:fields] && value.is_a?(Hash) && !value.empty? &&
2792
+ value.all? { |key, record| key.to_s.match?(/\A-?\d+\z/) && record.is_a?(Hash) }
2793
+ end
2794
+
2795
+ # The bounds, then `validate:`, with the copy the result needs made in the
2796
+ # middle. The opaque hash is handed over whole, and HashWithIndifferentAccess
2343
2797
  # rebuilt its containers but not the Strings inside them, so it is
2344
2798
  # deep-copied for the reason permittable_normalized copies a String — but
2345
2799
  # only once it is within its bounds. Copying first meant a megabyte
@@ -2365,19 +2819,31 @@ module Permittable
2365
2819
  end
2366
2820
 
2367
2821
  def permittable_check_array(field, value, path:, unknown:, violations:)
2822
+ # A scalar array's "" elements are absent, as "" is for a field: the
2823
+ # hidden `name="post[tag_ids][]" value=""` that collection_check_boxes
2824
+ # and `select multiple: true` render, so that unchecking every box still
2825
+ # sends the key, made every submit a 422 for `of: :integer`. Each kept
2826
+ # element keeps the index the client sent it at, so violation paths
2827
+ # still point into the request. A null element, which no form sends,
2828
+ # is still refused.
2829
+ blank = ->(element) { !field[:fields] && element.is_a?(String) && element.empty? }
2830
+ count = field[:fields] ? value.length : value.length - value.count(&blank)
2831
+
2368
2832
  # `length:` is a BOUND, not a report. An array outside it is rejected
2369
2833
  # whatever its contents, so checking those contents can only add work and
2370
2834
  # noise: a 200k-element payload against `length: 0..10` used to cast every
2371
2835
  # element, collect 200k more violations, and answer with a multi-megabyte
2372
2836
  # 422 — for a request already refused by its first check. Stopping here
2373
- # keeps the cost of an oversized array proportional to rejecting it.
2374
- if field[:length] && !Coercion.length_ok?(field[:length], value.length)
2837
+ # keeps the cost of an oversized array proportional to rejecting it (the
2838
+ # blank count above allocates nothing).
2839
+ if field[:length] && !Coercion.length_ok?(field[:length], count)
2375
2840
  violations << permittable_violation(field, path, "length")
2376
2841
  return nil
2377
2842
  end
2378
2843
 
2379
2844
  before = violations.length
2380
- out = value.each_with_index.map do |element, index|
2845
+ elements = value.each_with_index.reject { |element, _| blank.call(element) }
2846
+ out = elements.map do |element, index|
2381
2847
  permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
2382
2848
  end
2383
2849
  # validate: and transform: see only a fully-valid array. A partially-nil
@@ -2400,8 +2866,9 @@ module Permittable
2400
2866
  # NOTHING violated, validate: included — a transform may rely on what
2401
2867
  # validate: checked (`Math.sqrt` after "all positive").
2402
2868
  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)
2869
+ validator = permittable_array_validator(field)
2870
+ if validator && elements_valid
2871
+ status, code = Coercion.check_custom(validator, out)
2405
2872
  violations << permittable_violation(field, path, code) unless status == :ok
2406
2873
  end
2407
2874
  # Transform only a fully-valid array — a partially-nil one (element
@@ -2416,6 +2883,11 @@ module Permittable
2416
2883
  field[:transform] ? field[:transform].call(value) : value
2417
2884
  end
2418
2885
 
2886
+ # A second seam, for the same reader: the array-level `validate:` to run.
2887
+ def permittable_array_validator(field)
2888
+ field[:validate]
2889
+ end
2890
+
2419
2891
  def permittable_check_element(field, element, path, unknown:, violations:)
2420
2892
  if field[:fields]
2421
2893
  unless element.is_a?(Hash)
@@ -2492,6 +2964,7 @@ module Permittable
2492
2964
  if top_level
2493
2965
  extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
2494
2966
  extra -= [permittable_configured_csrf_key].compact
2967
+ extra -= permittable_image_submit_keys(hash, extra)
2495
2968
  end
2496
2969
  return if extra.empty?
2497
2970
 
@@ -2517,6 +2990,33 @@ module Permittable
2517
2990
  request.path_parameters.keys.map(&:to_s)
2518
2991
  end
2519
2992
 
2993
+ # The click point of an image submit among `keys` (the undeclared top-level
2994
+ # keys). image_submit_tag renders <input type="image">, and a browser
2995
+ # submitting through it posts where it was clicked: `x=12&y=7`, or
2996
+ # `save.x=12&save.y=7` when the tag is named "save" (Rack keeps a dotted
2997
+ # key as it is). Like the other FORM_KEYS these are the framework's own
2998
+ # keys, so `unknown: :error` must not fail the form for them. Unlike them
2999
+ # `x` and `y` are ordinary names a contract may really be sent, so a
3000
+ # lone `x` is still flagged: a point is exempt only as a PAIR, both of
3001
+ # them in `keys` (a declared `x` or `y` is not, and is checked as a field)
3002
+ # and both digits, which is all a browser sends (coordinates are never
3003
+ # negative or fractional). Read by bytes, since a client's key or value can
3004
+ # be invalid UTF-8, which a regexp refuses to match, or another encoding
3005
+ # altogether (a UTF-16 key makes `end_with?` raise).
3006
+ def permittable_image_submit_keys(hash, keys)
3007
+ by_bytes = keys.to_h { |key| [key.b, key] }
3008
+ by_bytes.each_with_object([]) do |(bytes, key), point|
3009
+ next unless bytes == "x" || bytes.end_with?(".x")
3010
+
3011
+ partner = by_bytes["#{bytes.delete_suffix('x')}y"]
3012
+ point.push(key, partner) if partner && [key, partner].all? { |name| permittable_digits?(hash[name]) }
3013
+ end
3014
+ end
3015
+
3016
+ def permittable_digits?(value)
3017
+ value.is_a?(String) && !value.empty? && value.each_byte.all? { |byte| byte.between?(48, 57) }
3018
+ end
3019
+
2520
3020
  # FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
2521
3021
  # name — but `config.action_controller.request_forgery_protection_token`
2522
3022
  # lets an app rename it, and an app that does trips `unknown: :error` on