permittable 0.14.0 → 0.15.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
@@ -1041,9 +1041,13 @@ module Permittable
1041
1041
  found = Date._parse(value)
1042
1042
  return [:error, "invalid_type"] unless complete_date?(found)
1043
1043
  return cast_date_instant(value, found) if names_an_instant?(found)
1044
- # No time found, but an offset may have been dropped ("5/9/2026
1045
- # -0500"), which :datetime refuses too.
1046
- return [:error, "invalid_type"] if !plain && offset_misread?(value, found)
1044
+
1045
+ # No time found, but a zone or offset may have been dropped
1046
+ # ("5/9/2026 -0500", "2026-09-05 EST") or an hour with it
1047
+ # ("2026-09-05T10"), which :datetime refuses or reads the same way.
1048
+ if !plain && (dropped = dropped_tail(value, found))
1049
+ return date_at_midnight(*dropped)
1050
+ end
1047
1051
 
1048
1052
  # Built from the components rather than re-running Date.parse, which
1049
1053
  # would parse the same string a second time — and Date.parse is the
@@ -1082,10 +1086,13 @@ module Permittable
1082
1086
  # ruled out the ISO forms cast_datetime reads without one; :datetime's
1083
1087
  # reading of it is then the same, with one parse fewer.
1084
1088
  def cast_date_instant(value, found = nil)
1085
- status, instant = found ? cast_parsed_datetime(value, found) : cast_datetime(value)
1086
- return [:error, "invalid_type"] unless status == :ok && instant == instant.beginning_of_day
1089
+ date_at_midnight(*(found ? cast_parsed_datetime(value, found) : cast_datetime(value)))
1090
+ end
1087
1091
 
1088
- [:ok, Date.new(instant.year, instant.month, instant.day)]
1092
+ # The Date of an instant read from a String when it is midnight UTC.
1093
+ def date_at_midnight(status, instant)
1094
+ date = date_of_instant(instant) if status == :ok
1095
+ date ? [:ok, date] : [:error, "invalid_type"]
1089
1096
  end
1090
1097
 
1091
1098
  # The date a Time or DateTime IS, or nil: its UTC date when it is
@@ -1167,7 +1174,9 @@ module Permittable
1167
1174
  # parts, shared with cast_date_instant so a :date need not parse twice.
1168
1175
  def cast_parsed_datetime(value, found)
1169
1176
  return [:error, "invalid_type"] unless complete_date?(found) && honoured_zone?(found)
1170
- return [:error, "invalid_type"] if offset_misread?(value, found)
1177
+
1178
+ dropped = dropped_tail(value, found)
1179
+ return dropped if dropped
1171
1180
 
1172
1181
  [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1173
1182
  rescue ArgumentError, RangeError
@@ -1236,23 +1245,126 @@ module Permittable
1236
1245
  (zone.start_with?("-") ? -1 : 1) * ((hours * 3600) + (minutes * 60))
1237
1246
  end
1238
1247
 
1239
- # Whether a date's trailing offset was taken for its time of day
1240
- # ("Sep 5 2026 -05:00" reports 05:00, "2026-09-05 UTC-05:00" too) or
1241
- # dropped without a trace ("5/9/2026 -0500", "2026-09-05 +5"), outside
1242
- # the ISO form midnight_at_offset reads. The tail is the culprit when the
1243
- # parser found no zone and the String without it names the same date and
1244
- # no time. Only a String that ends in a signed number and either got an
1245
- # hour or has a space before the number is parsed again, so a plain
1246
- # "2026-09-05" is not.
1247
- def offset_misread?(value, found)
1248
- return false if found.key?(:zone)
1248
+ # An ISO date, then a "T": what follows is an ISO time, so a parse that
1249
+ # found no hour ("2026-09-05T10", "T1030", "T10Z") dropped it.
1250
+ ISO_DATE_T_START = /\A\s*\d{4}(-?)\d{2}\1\d{2}[Tt]/
1251
+
1252
+ # Trailing words, set apart by a space: a zone name ("EST", "Eastern
1253
+ # Standard Time", "W. Europe Standard Time") or a day or era word. The
1254
+ # first word may be one letter, which is a military zone or the ISO "T"
1255
+ # and so no zone here, but still stands where an hour was lost.
1256
+ ZONE_NAME_TAIL = /(?<=\s)[[:alpha:]](?:\.|[[:alpha:]]+)?[[:alpha:].]*(?:\s+[[:alpha:].]+)*\z/
1257
+
1258
+ # An era word at the start of such a tail: the parser reads it with the
1259
+ # date ("Sep 5 2026 BC" is the year -2025), so it is not what follows.
1260
+ ERA_WORD = /\A(?:a\.?\s?d\.?|c\.?\s?e\.?|b\.?\s?c\.?(?:\s?e\.?)?)(?:\s+|\z)/i
1249
1261
 
1250
- tail = SIGNED_TAIL.match(value) or return false
1251
- return false unless tail[1] || found.key?(:hour)
1262
+ # What ends a date the way git, `date` and Time#to_s write it, with the
1263
+ # offset after the year: "Sat Sep 5 10:00:00 2026", "Sep 5 10:00 2026".
1264
+ TIME_THEN_YEAR = /\d:\d{2}(?::\d{2}(?:[.,]\d+)?)?\s+\d{4}\s*\z/
1265
+ private_constant :ZONE_NAME_TAIL, :ERA_WORD, :TIME_THEN_YEAR
1252
1266
 
1253
- rest = Date._parse(tail.pre_match)
1254
- complete_date?(rest) && !rest.key?(:hour) &&
1255
- rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
1267
+ # What Date._parse did with the END of a date or date-time String when it
1268
+ # dropped or misplaced it, as nil when it did neither, or the result
1269
+ # ([:ok, instant] or [:error, "invalid_type"]) the String must be given:
1270
+ #
1271
+ # - an hour after an ISO "T" it did not find ("2026-09-05T10");
1272
+ # - a trailing offset taken for a time of day ("Sep 5 2026 -05:00",
1273
+ # "2026-09-05 10 -05:00", "2026-09-05 UTC-05:00") or dropped without a
1274
+ # trace ("5/9/2026 -0500", "12:00 2026-09-05 -05:00"), outside the ISO
1275
+ # form midnight_at_offset reads. An offset after "10:00:00 2026" is the
1276
+ # exception: that is git's and ctime's format, which puts it after the
1277
+ # year, so it is read, like a zone name;
1278
+ # - a trailing zone NAME, which it reads only next to a time of day:
1279
+ # "2026-09-05 EST" is midnight at -05:00 and "Sat Sep 5 10:00:00 2026
1280
+ # EST" 10:00 there. A name it does not know is refused like any other
1281
+ # zone (honoured_zone?), and so is ANY other word after a complete date
1282
+ # ("2026-09-05 noon"), since all it can be is a zone. A day name and an
1283
+ # era word are what the parser reads as part of the date, not a zone.
1284
+ #
1285
+ # The tail is the culprit when the parser found no zone and the String
1286
+ # without it names the same date (and time). Only a String that ends in a
1287
+ # signed number or a word is parsed again, so a plain "2026-09-05" is not.
1288
+ def dropped_tail(value, found)
1289
+ return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
1290
+ return if found.key?(:zone)
1291
+
1292
+ if (tail = SIGNED_TAIL.match(value))
1293
+ # An offset glued to a zone name with no time ("2026-09-05 GMT+5") is
1294
+ # read below as midnight at that offset; one glued to a date is a
1295
+ # part of it ("2026-09-05" ends in "-05").
1296
+ named = !tail[1] && !found.key?(:hour) && /\s[[:alpha:]]+\z/.match(tail.pre_match)
1297
+ return unless tail[1] || found.key?(:hour) || named
1298
+
1299
+ rest = Date._parse(tail.pre_match)
1300
+ return unless same_date?(rest, found)
1301
+ return glued_zone(named, tail[0].strip, found) if named
1302
+
1303
+ git_style = tail[1] && found.key?(:hour) && TIME_THEN_YEAR.match?(tail.pre_match) && same_time?(rest, found)
1304
+ git_style ? instant_in_zone(tail.pre_match, tail[0].strip) : [:error, "invalid_type"]
1305
+ elsif (tail = ZONE_NAME_TAIL.match(value))
1306
+ pre = tail.pre_match
1307
+ text = tail[0]
1308
+ if (era = ERA_WORD.match(text))
1309
+ pre += era[0]
1310
+ text = era.post_match
1311
+ end
1312
+ # The era is part of the date, so the hour a lost one hid is looked
1313
+ # for before it ("2026-09-05 10 A.D.", "2026-09-05 10 AD EST").
1314
+ head = era ? Date._parse(tail.pre_match) : nil
1315
+ return same_time?(head, found) ? nil : [:error, "invalid_type"] if text.empty?
1316
+
1317
+ rest = Date._parse(pre)
1318
+ return unless same_date?(rest, found)
1319
+
1320
+ zone = Date._parse("00:00 #{text}")
1321
+ return unless zone.key?(:zone) # a day name
1322
+ # The hour the tail made the parser lose ("2026-09-05 10 EST").
1323
+ return [:error, "invalid_type"] unless same_time?(head || rest, found)
1324
+ # A single letter is a military zone, which is no zone here: alone it
1325
+ # is nothing ("2026-09-05 a", "A."), but the rest of an initial-led
1326
+ # name Ruby did not know ("W. Europe") is dropped, so it is refused
1327
+ # like any other word it does not know.
1328
+ return text.match?(/\A[[:alpha:]]\.?\z/) ? nil : [:error, "invalid_type"] if zone[:zone].length == 1
1329
+
1330
+ instant_in_zone(pre, text)
1331
+ end
1332
+ end
1333
+
1334
+ # A signed offset after a zone name ("GMT+5", "UTC+0530") is that zone to
1335
+ # Ruby when it reads it next to a time, so the String is midnight at it.
1336
+ # A name it does not read whole ("EST+5") or honour ("UTC+99") is refused.
1337
+ # What comes before the zone is held to the rules of any date String: no
1338
+ # hour a lost tail hid ("2026-09-05 10 AD GMT+5"), no second zone, and the
1339
+ # offset is hours ("+5", "+05") or hhmm with minutes a minute ("+0530").
1340
+ def glued_zone(word, offset, found)
1341
+ zone = "#{word[0].strip}#{offset}"
1342
+ pre = Date._parse(word.pre_match)
1343
+ return [:error, "invalid_type"] unless offset.match?(GLUED_OFFSET) && Date._parse("00:00 #{zone}")[:zone] == zone
1344
+ return [:error, "invalid_type"] unless same_time?(pre, found) && !pre.key?(:zone) && dropped_tail(word.pre_match, pre).nil?
1345
+
1346
+ instant_in_zone(word.pre_match, zone)
1347
+ end
1348
+
1349
+ # The wall clock `date` names, as it is read in the zone `text` names:
1350
+ # [:ok, instant], or an error when Ruby cannot honour the zone.
1351
+ def instant_in_zone(date, text)
1352
+ zone = Date._parse("00:00 #{text}")
1353
+ return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
1354
+
1355
+ [:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
1356
+ end
1357
+
1358
+ def same_time?(rest, found)
1359
+ TIME_PARTS.all? { |part| rest[part] == found[part] }
1360
+ end
1361
+
1362
+ TIME_PARTS = %i[hour min sec sec_fraction].freeze
1363
+ GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
1364
+ private_constant :TIME_PARTS, :GLUED_OFFSET
1365
+
1366
+ def same_date?(rest, found)
1367
+ complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
1256
1368
  end
1257
1369
 
1258
1370
  # Presets only make sense on String input; a non-String value (JSON
@@ -1446,13 +1558,17 @@ module Permittable
1446
1558
  end
1447
1559
 
1448
1560
  # What an exported enum lists for one member: the cast value, re-encoded
1449
- # as JSON — except a :date/:datetime member authored as a String, which
1450
- # is published AS WRITTEN. Re-encoding a cast Time prints whole seconds,
1451
- # so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value
1452
- # the server refuses. The authored String went through the very cast a
1453
- # request does, so the server accepts it by construction.
1561
+ # as JSON — except a :datetime member authored as a String, which is
1562
+ # published AS WRITTEN. Re-encoding a cast Time prints whole seconds, so
1563
+ # "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value the
1564
+ # server refuses. The authored String went through the very cast a request
1565
+ # does, so the server accepts it by construction. A :date member is
1566
+ # re-encoded: a Date has no digits to lose, and the String as written
1567
+ # ("Sep 6, 2026", "2026-09-05T00:00:00Z") is a date the server reads but
1568
+ # not one `format: date` accepts, so it is published as the YYYY-MM-DD
1569
+ # the server compares against.
1454
1570
  def published_in_member(type, member, value)
1455
- member.is_a?(String) && %i[date datetime].include?(type) ? member : value
1571
+ member.is_a?(String) && type == :datetime ? member : value
1456
1572
  end
1457
1573
 
1458
1574
  # Range#include? walks discrete ranges; cover? is the O(1) bounds check
@@ -1799,7 +1915,7 @@ module Permittable
1799
1915
  end
1800
1916
 
1801
1917
  # `published` is stored only where it differs from the cast members (a
1802
- # String-authored :date/:datetime member), so it is read as an override.
1918
+ # String-authored :datetime member), so it is read as an override.
1803
1919
  def cast_in_members!(field, members)
1804
1920
  status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
1805
1921
  unless status == :ok
@@ -1896,6 +2012,12 @@ module Permittable
1896
2012
  def validate_json_authored_value!(field, opt)
1897
2013
  return unless field.key?(opt)
1898
2014
  return if authored_nil!(field, opt)
2015
+
2016
+ # A request sending "" for a nullable :json field is the explicit null
2017
+ # the walker reads it as, so a blank authored value is that nil, as a
2018
+ # nullable scalar's is (see validate_authored_value!).
2019
+ return field[opt] = nil if field[:nullable] && Coercion.absent_value?(field[opt])
2020
+
1899
2021
  raise ArgumentError, "#{LABEL}: :#{opt} for :#{field[:name]} must be a Hash" unless field[opt].is_a?(Hash)
1900
2022
 
1901
2023
  status, read = AuthoredValues.read_json(field, field[opt])
@@ -2351,6 +2473,11 @@ module Permittable
2351
2473
  # rescue_from until the app includes them, and a rule that needs one of
2352
2474
  # them would otherwise load and then not run: raise at class load, naming
2353
2475
  # the module. Other hosts (a plain class, a duck) are left as they are.
2476
+ # Only the rule's OWN declared mode is read, never Permittable.mode: that
2477
+ # is usually set by an initializer, and an app must not stop booting over
2478
+ # it. The effective mode can only add a requirement, so an action it makes
2479
+ # monitored without a before_action is left to Audit.inert?, which reports
2480
+ # it as not covered.
2354
2481
  def permittable_ensure_runnable!(rule)
2355
2482
  return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
2356
2483
 
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.14.0
4
+ version: 0.15.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-11-09 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport