jscalendar 0.1.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.
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscalendar
4
+ # Collects violations while treating configured resource limits as hard failures.
5
+ class Validator
6
+ KNOWN_NAMES = SCHEMA.values.flat_map { |definition| definition.fetch("properties").keys }.uniq.freeze
7
+
8
+ def initialize(validate_patches: true, **limits)
9
+ unknown = limits.keys - DEFAULT_LIMITS.keys
10
+ raise ArgumentError, "unknown limits: #{unknown.join(", ")}" unless unknown.empty?
11
+
12
+ @limits = DEFAULT_LIMITS.merge(limits)
13
+ @limits.each do |name, value|
14
+ raise ArgumentError, "#{name} must be a nonnegative Integer" unless value.is_a?(Integer) && value >= 0
15
+ end
16
+ raise ArgumentError, "max_depth must be positive" if @limits[:max_depth].zero?
17
+
18
+ @validate_patches = validate_patches
19
+ @errors = []
20
+ end
21
+
22
+ def validate(value, type: value.is_a?(Hash) && value["@type"], time_zones: {})
23
+ @errors = []
24
+ inspect_json(value, "", 0, [])
25
+ return @errors unless @errors.empty?
26
+
27
+ @zones = time_zones
28
+ object(value, type, "")
29
+ @errors
30
+ end
31
+
32
+ def validate_json(value, pointer: "")
33
+ @errors = []
34
+ inspect_json(value, pointer, 0, [])
35
+ @errors
36
+ end
37
+
38
+ private
39
+
40
+ def add(pointer, message)
41
+ @errors << Violation.new(pointer: pointer, message: message)
42
+ end
43
+
44
+ def inspect_json(value, path, depth, ancestors)
45
+ case value
46
+ when Hash, Array
47
+ raise LimitError.new("JSON nesting exceeds max_depth", pointer: path) if depth >= @limits[:max_depth]
48
+
49
+ if ancestors.include?(value.object_id)
50
+ add(path, "cyclic JSON data")
51
+ return
52
+ end
53
+ raise LimitError.new("collection exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
54
+
55
+ next_ancestors = ancestors + [value.object_id]
56
+ if value.is_a?(Hash)
57
+ value.each do |key, item|
58
+ unless key.is_a?(String) && unicode?(key)
59
+ add(path, "JSON member names must be valid Unicode strings")
60
+ next
61
+ end
62
+ inspect_json(item, Jscalendar.pointer(path, key), depth + 1, next_ancestors)
63
+ end
64
+ else
65
+ value.each_with_index { |item, index| inspect_json(item, Jscalendar.pointer(path, index), depth + 1, next_ancestors) }
66
+ end
67
+ when String
68
+ add(path, "string is not valid I-JSON Unicode") unless unicode?(value)
69
+ check_data_uri_limit(value, path)
70
+ when Integer
71
+ add(path, "integer is outside the exact I-JSON range") unless Types.integer?(value)
72
+ when Float
73
+ add(path, "number must be finite") unless value.finite?
74
+ when TrueClass, FalseClass, NilClass
75
+ nil
76
+ else
77
+ add(path, "value is not JSON-compatible")
78
+ end
79
+ end
80
+
81
+ def unicode?(value)
82
+ return false unless value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
83
+
84
+ value.each_codepoint.none? { |point| point.between?(0xD800, 0xDFFF) || point.between?(0xFDD0, 0xFDEF) || (point & 0xFFFF) >= 0xFFFE }
85
+ end
86
+
87
+ def check_data_uri_limit(value, path)
88
+ return unless value.b.byteslice(0, 5)&.downcase == "data:"
89
+
90
+ header, payload = value.b.split(",", 2)
91
+ return unless payload
92
+
93
+ payload = URI::DEFAULT_PARSER.unescape(payload)
94
+ size = if header.downcase.end_with?(";base64")
95
+ (payload.bytesize * 3 / 4) - [payload[/=+\z/].to_s.length, 2].min
96
+ else
97
+ payload.bytesize
98
+ end
99
+ raise LimitError.new("data URI exceeds max_data_uri_size", pointer: path) if size > @limits[:max_data_uri_size]
100
+ end
101
+
102
+ def object(value, type, path)
103
+ unless value.is_a?(Hash) && SCHEMA.key?(type)
104
+ add(path, "expected a known JSCalendar object")
105
+ return
106
+ end
107
+ previous_zones = @zones
108
+ @zones = @zones.merge(value["timeZones"].is_a?(Hash) ? value["timeZones"] : {})
109
+ fields = SCHEMA.fetch(type).fetch("properties")
110
+ add(Jscalendar.pointer(path, "@type"), "expected @type #{type}") unless value["@type"] == type
111
+ fields.each do |name, rule|
112
+ add(Jscalendar.pointer(path, name), "required property is missing") if rule["required"] && !value.key?(name)
113
+ end
114
+ value.each do |name, item|
115
+ next unless name.is_a?(String) && unicode?(name)
116
+
117
+ location = Jscalendar.pointer(path, name)
118
+ if fields.key?(name)
119
+ check(item, fields[name].fetch("type"), location) unless name == "@type"
120
+ elsif KNOWN_NAMES.any? { |known| known.casecmp?(name) }
121
+ add(location, "property is not allowed in #{type} or has incorrect case")
122
+ elsif !Types.vendor?(name) && !name.match?(/\A[a-z][A-Za-z0-9]*\z/)
123
+ add(location, "invalid property name")
124
+ end
125
+ end
126
+ relationships(value, type, path)
127
+ ensure
128
+ @zones = previous_zones if defined?(previous_zones) && previous_zones
129
+ end
130
+
131
+ def check(value, signature, path)
132
+ if signature.end_with?("|null")
133
+ check(value, signature.delete_suffix("|null"), path) unless value.nil?
134
+ elsif SCHEMA.key?(signature)
135
+ object(value, signature, path)
136
+ elsif signature.end_with?("[]")
137
+ if value.is_a?(Array)
138
+ value.each_with_index { |item, i| check(item, signature.delete_suffix("[]"), Jscalendar.pointer(path, i)) }
139
+ else
140
+ add(path, "expected an array")
141
+ end
142
+ elsif (match = /\A(\w+)\[(.+)\]\z/.match(signature))
143
+ check_map(value, match[1], match[2], path)
144
+ elsif signature.start_with?("Enum:", "Closed:")
145
+ enum(value, signature.split(":", 2).last, path, extensible: signature.start_with?("Enum:"))
146
+ elsif signature.start_with?("EnumMap:", "ClosedEnumMap:")
147
+ check_map(value, "String", "True", path)
148
+ if value.is_a?(Hash)
149
+ value.each_key { |key| enum(key, signature.split(":", 2).last, Jscalendar.pointer(path, key), extensible: signature.start_with?("EnumMap:")) }
150
+ end
151
+ elsif signature == "PatchObject"
152
+ if value.is_a?(Hash)
153
+ raise LimitError.new("patch count exceeds max_patches", pointer: path) if value.size > @limits[:max_patches]
154
+ else
155
+ add(path, "expected PatchObject")
156
+ end
157
+ elsif %w[Entry Trigger].include?(signature)
158
+ if !value.is_a?(Hash) || !value["@type"].is_a?(String)
159
+ add(path, "expected typed #{signature}")
160
+ elsif (signature == "Entry" ? %w[Event Task] : %w[OffsetTrigger AbsoluteTrigger]).include?(value["@type"])
161
+ object(value, value["@type"], path)
162
+ end
163
+ else
164
+ add(path, "invalid #{signature} value") unless scalar?(value, signature)
165
+ end
166
+ end
167
+
168
+ def check_map(value, key_type, item_type, path)
169
+ unless value.is_a?(Hash)
170
+ add(path, "expected a map")
171
+ return
172
+ end
173
+ value.each do |key, item|
174
+ location = Jscalendar.pointer(path, key)
175
+ add(location, "invalid #{key_type} map key") unless scalar?(key, key_type)
176
+ check(item, item_type, location)
177
+ end
178
+ end
179
+
180
+ def enum(value, name, path, extensible: true)
181
+ valid = value.is_a?(String) && (ENUMS.fetch(name).include?(value) || (extensible && Types.vendor?(value)))
182
+ add(path, "invalid #{name} value") unless valid
183
+ end
184
+
185
+ def scalar?(value, signature)
186
+ case signature
187
+ when "String" then value.is_a?(String)
188
+ when "Boolean" then [true, false].include?(value)
189
+ when "True" then value == true
190
+ when "Int" then Types.integer?(value)
191
+ when "UnsignedInt" then Types.integer?(value, minimum: 0)
192
+ when "Id" then Types.id?(value)
193
+ when "UTCDateTime" then Types.utc_date_time?(value)
194
+ when "LocalDateTime" then value.is_a?(String) && Types.utc_date_time?("#{value}Z")
195
+ when "Duration", "SignedDuration"
196
+ value.is_a?(String) && Jscalendar.const_get(signature).new(value)
197
+ when "TimeZoneId"
198
+ value.is_a?(String) && (@zones.key?(value) || TZInfo::Timezone.get(value))
199
+ when "LanguageTag" then Types.language_tag?(value)
200
+ when "URI" then Types.uri?(value)
201
+ when "Email" then Types.email?(value)
202
+ when "UTCOffset"
203
+ value.is_a?(String) && value.match?(/\A[+-](?:[01]\d|2[0-3])[0-5]\d(?:[0-5]\d)?\z/) && !%w[-0000 -000000].include?(value)
204
+ else false
205
+ end
206
+ rescue ValidationError, TZInfo::InvalidTimezoneIdentifier
207
+ false
208
+ end
209
+
210
+ def relationships(value, type, path)
211
+ if value["color"].is_a?(String)
212
+ valid = ENUMS.fetch("color").include?(value["color"].downcase) || value["color"].match?(/\A#(?:[0-9a-f]{3}|[0-9a-f]{6})\z/i)
213
+ add("#{path}/color", "expected CSS Level 3 color name or hexadecimal RGB") unless valid
214
+ end
215
+ if value["method"].is_a?(String) && !%w[publish request reply add cancel refresh counter declinecounter].include?(value["method"])
216
+ add("#{path}/method", "expected lowercase iTIP method")
217
+ end
218
+ %w[links delegatedTo delegatedFrom memberOf replyTo sendTo roles locationTypes].each do |name|
219
+ add(Jscalendar.pointer(path, name), "must not be empty") if value[name].is_a?(Hash) && value[name].empty?
220
+ end
221
+ %w[percentComplete priority].each do |name|
222
+ next unless value[name].is_a?(Integer)
223
+
224
+ max = name == "priority" ? 9 : 100
225
+ add(Jscalendar.pointer(path, name), "must be between 0 and #{max}") unless value[name].between?(0, max)
226
+ end
227
+ if value["descriptionContentType"].is_a?(String)
228
+ media = value["descriptionContentType"]
229
+ valid = media.match?(%r{\Atext/[A-Za-z0-9!\#$&^_.+-]+(?:\s*;[^\r\n]*)?\z}) &&
230
+ (!media.match?(/charset\s*=/i) || media.match?(/charset\s*=\s*"?utf-8"?(?:\s*;|\z)/i))
231
+ add(Jscalendar.pointer(path, "descriptionContentType"), "expected text media type with UTF-8 charset") unless valid
232
+ end
233
+ %w[replyTo sendTo].each do |name|
234
+ next unless value[name].is_a?(Hash)
235
+
236
+ value[name].each do |method, uri|
237
+ location = Jscalendar.pointer(Jscalendar.pointer(path, name), method)
238
+ add(location, "method must be ASCII alphanumeric") unless method.match?(/\A[A-Za-z0-9]+\z/)
239
+ scheme = if method == "imip"
240
+ "mailto:"
241
+ else
242
+ (method == "web" && name == "replyTo" ? "https:" : nil)
243
+ end
244
+ add(location, "expected #{scheme} URI") if scheme && uri.is_a?(String) && !uri.downcase.start_with?(scheme)
245
+ end
246
+ end
247
+ case type
248
+ when "Event", "Task", "Group" then calendar(value, type, path)
249
+ when "RecurrenceRule" then recurrence(value, path)
250
+ when "NDay"
251
+ add(Jscalendar.pointer(path, "nthOfPeriod"), "must be nonzero") if value["nthOfPeriod"].is_a?(Integer) && value["nthOfPeriod"].zero?
252
+ when "Link"
253
+ add(Jscalendar.pointer(path, "rel"), "must be icon when display is set") if value.key?("display") && value["rel"] != "icon"
254
+ if value["cid"].is_a?(String)
255
+ cid = value["cid"]
256
+ valid = !cid.match?(/%(?![0-9a-fA-F]{2})/) && Types.email?(URI::DEFAULT_PARSER.unescape(cid))
257
+ add("#{path}/cid", "expected URL-encoded content-id") unless valid
258
+ end
259
+ when "Location"
260
+ add(path, "location needs a property other than relativeTo") if (value.keys - %w[@type relativeTo]).empty?
261
+ add(Jscalendar.pointer(path, "coordinates"), "expected geo URI") if value["coordinates"].is_a?(String) && !value["coordinates"].start_with?("geo:")
262
+ when "Participant"
263
+ if value.key?("progress") && value.fetch("participationStatus", "needs-action") != "accepted"
264
+ add(Jscalendar.pointer(path, "progress"), "requires accepted participationStatus")
265
+ end
266
+ if value["scheduleStatus"].is_a?(Array)
267
+ value["scheduleStatus"].each_with_index do |status, i|
268
+ add("#{path}/scheduleStatus/#{i}", "invalid scheduling status") unless status.is_a?(String) && status.match?(/\A[1-5]\.\d+(?:\.\d+)?\z/)
269
+ end
270
+ end
271
+ when "TimeZone" then zone(value, path)
272
+ when "TimeZoneRule"
273
+ add("#{path}/recurrenceRules", "at most one rule is allowed") if value["recurrenceRules"].is_a?(Array) && value["recurrenceRules"].size > 1
274
+ if value["recurrenceOverrides"].is_a?(Hash)
275
+ value.fetch("recurrenceOverrides", {}).each do |key, patch|
276
+ add(Jscalendar.pointer("#{path}/recurrenceOverrides", key), "time zone override must be empty") unless patch == {}
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ def calendar(value, type, path)
283
+ if value.key?("recurrenceId")
284
+ %w[recurrenceRules recurrenceOverrides].each { |name| add("#{path}/#{name}", "not allowed on recurrence instance") if value.key?(name) }
285
+ elsif value.key?("recurrenceIdTimeZone")
286
+ add("#{path}/recurrenceIdTimeZone", "requires recurrenceId")
287
+ end
288
+ if type == "Task" && value.key?("recurrenceRules") && !value.key?("start") && !value.key?("due")
289
+ add("#{path}/recurrenceRules", "task recurrence needs start or due")
290
+ end
291
+ if value["participants"].is_a?(Hash)
292
+ value["participants"].each do |id, person|
293
+ next unless person.is_a?(Hash)
294
+
295
+ if person.key?("sendTo") && (!value["replyTo"].is_a?(Hash) || value["replyTo"].empty?)
296
+ add("#{path}/replyTo", "participants with sendTo require a reply method")
297
+ end
298
+ %w[progress progressUpdated percentComplete].each do |name|
299
+ add(Jscalendar.pointer("#{path}/participants", id) + "/#{name}", "only allowed in a Task") if type != "Task" && person.key?(name)
300
+ end
301
+ %w[delegatedTo delegatedFrom memberOf].each do |name|
302
+ next unless person[name].is_a?(Hash)
303
+
304
+ person[name].each_key do |reference|
305
+ add(Jscalendar.pointer("#{path}/participants/#{id}/#{name}", reference), "unknown participant") unless value["participants"].key?(reference)
306
+ end
307
+ end
308
+ end
309
+ end
310
+ if value["timeZones"].is_a?(Hash)
311
+ references = zone_references(value.except("timeZones"))
312
+ value["timeZones"].each_key do |id|
313
+ location = Jscalendar.pointer("#{path}/timeZones", id)
314
+ add(location, "custom identifier must start with / and be paramtext") unless id.start_with?("/") && paramtext?(id)
315
+ add(location, "unreferenced custom time zone") unless references.include?(id)
316
+ end
317
+ end
318
+ validate_patches(value, path) if @validate_patches
319
+ end
320
+
321
+ def validate_patches(value, path)
322
+ %w[recurrenceOverrides localizations].each do |name|
323
+ next unless value[name].is_a?(Hash)
324
+
325
+ value[name].each do |key, patch|
326
+ next unless patch.is_a?(Hash)
327
+
328
+ if name == "recurrenceOverrides" && patch["excluded"] == true
329
+ add(Jscalendar.pointer("#{path}/#{name}", key), "excluded override cannot patch other properties") if patch.size != 1
330
+ next
331
+ end
332
+ filtered = name == "recurrenceOverrides" ? PatchObject.recurrence(patch) : PatchObject.localization(patch)
333
+ base = value.except("recurrenceOverrides", "localizations")
334
+ begin
335
+ result = PatchObject.new(filtered, **@limits).apply(base, validate: false)
336
+ violations = Validator.new(validate_patches: false, **@limits).validate(result, time_zones: @zones)
337
+ violations.each { |error| add(Jscalendar.pointer("#{path}/#{name}", key) + error.pointer, error.message) }
338
+ rescue ValidationError => e
339
+ add(Jscalendar.pointer("#{path}/#{name}", key), e.message)
340
+ end
341
+ end
342
+ end
343
+ end
344
+
345
+ def recurrence(value, path)
346
+ %w[byDay byMonthDay byMonth byYearDay byWeekNo byHour byMinute bySecond bySetPosition].each do |name|
347
+ add("#{path}/#{name}", "must not be empty") if value[name].is_a?(Array) && value[name].empty?
348
+ end
349
+ add("#{path}/interval", "must be positive") if value["interval"].is_a?(Integer) && value["interval"].zero?
350
+ add("#{path}/until", "count and until are mutually exclusive") if value.key?("count") && value.key?("until")
351
+ ranges = { "byHour" => 0..23, "byMinute" => 0..59, "bySecond" => 0..60 }
352
+ ranges.merge!("byMonthDay" => -31..31, "byYearDay" => -366..366, "byWeekNo" => -53..53) if value.fetch("rscale", "gregorian") == "gregorian"
353
+ ranges.each do |name, range|
354
+ next unless value[name].is_a?(Array)
355
+
356
+ value[name].each_with_index do |number, i|
357
+ valid = number.is_a?(Integer) && range.cover?(number) && !(range.begin.negative? && number.zero?)
358
+ add("#{path}/#{name}/#{i}", "out of range") unless valid
359
+ end
360
+ end
361
+ if value["bySetPosition"].is_a?(Array)
362
+ value["bySetPosition"].each_with_index do |number, i|
363
+ add("#{path}/bySetPosition/#{i}", "must be nonzero") if number.is_a?(Integer) && number.zero?
364
+ end
365
+ end
366
+ return unless value["byMonth"].is_a?(Array)
367
+
368
+ value["byMonth"].each_with_index do |month, i|
369
+ valid = month.is_a?(String) && month.match?(/\A[1-9]\d*L?\z/)
370
+ valid &&= month.match?(/\A(?:[1-9]|1[0-2])\z/) if value.fetch("rscale", "gregorian") == "gregorian"
371
+ add("#{path}/byMonth/#{i}", "invalid calendar month") unless valid
372
+ end
373
+ end
374
+
375
+ def zone(value, path)
376
+ rules = %w[standard daylight].flat_map { |name| value[name].is_a?(Array) ? value[name] : [] }
377
+ add(path, "time zone needs a transition rule") if rules.empty?
378
+ add("#{path}/tzId", "invalid paramtext") if value["tzId"].is_a?(String) && !paramtext?(value["tzId"])
379
+ end
380
+
381
+ def paramtext?(value)
382
+ !value.empty? && !value.match?(/[\x00-\x08\x0A-\x1F\x7F";:,]/)
383
+ end
384
+
385
+ def zone_references(value)
386
+ case value
387
+ when Hash
388
+ value.flat_map do |key, item|
389
+ if key == "timeZones"
390
+ []
391
+ else
392
+ (%w[timeZone recurrenceIdTimeZone].include?(key) ? [item] : zone_references(item))
393
+ end
394
+ end
395
+ when Array then value.flat_map { |item| zone_references(item) }
396
+ else []
397
+ end
398
+ end
399
+ end
400
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscalendar
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jscalendar.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "date"
6
+ require "time"
7
+ require "uri"
8
+ require "bigdecimal"
9
+ require "securerandom"
10
+ require "timeout"
11
+ require "tzinfo"
12
+ require "rrule"
13
+ require_relative "jscalendar/version"
14
+ require_relative "jscalendar/errors"
15
+ require_relative "jscalendar/types"
16
+ require_relative "jscalendar/temporal"
17
+ require_relative "jscalendar/validator"
18
+ require_relative "jscalendar/model"
19
+ require_relative "jscalendar/patch_object"
20
+ require_relative "jscalendar/time_zone"
21
+ require_relative "jscalendar/recurrence"
22
+ require_relative "jscalendar/calendar"
23
+
24
+ JSCalendar = Jscalendar unless defined?(JSCalendar)
25
+
26
+ module Jscalendar
27
+ class << self
28
+ def parse(source, strict: true, **limits)
29
+ raise ParseError, "JSON input must be a String" unless source.is_a?(String)
30
+
31
+ Validator.new(**limits)
32
+ options = DEFAULT_LIMITS.merge(limits)
33
+ raise LimitError, "input exceeds max_input_size" if source.bytesize > options[:max_input_size]
34
+
35
+ data = JSON.parse(source, allow_duplicate_key: false, max_nesting: options[:max_depth])
36
+ raise ParseError, "expected Event, Task, or Group" unless data.is_a?(Hash) && %w[Event Task Group].include?(data["@type"])
37
+
38
+ const_get(data["@type"]).new(data, strict: strict, **limits)
39
+ rescue JSON::NestingError => e
40
+ raise LimitError, e.message
41
+ rescue JSON::ParserError, EncodingError => e
42
+ raise ParseError, e.message
43
+ end
44
+
45
+ def validate(source, **limits)
46
+ validator = Validator.new(**limits)
47
+ data = if source.is_a?(String)
48
+ parse(source, strict: false, **limits).to_h
49
+ else
50
+ deep_copy(source, max_depth: limits.fetch(:max_depth, DEFAULT_LIMITS[:max_depth]))
51
+ end
52
+ validator.validate(data)
53
+ end
54
+ end
55
+ end