jscontact 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,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
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_localizations: 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" unless @limits[:max_depth].positive?
17
+
18
+ @validate_localizations = validate_localizations
19
+ @errors = []
20
+ end
21
+
22
+ def validate(value, type: "Card", root: true)
23
+ @errors = []
24
+ inspect_json(value, "", 0, [])
25
+ object(value, type, "", root: root)
26
+ @errors
27
+ end
28
+
29
+ def validate_json(value, pointer: "")
30
+ @errors = []
31
+ inspect_json(value, pointer, 0, [])
32
+ @errors
33
+ end
34
+
35
+ private
36
+
37
+ def add(pointer, message)
38
+ @errors << Violation.new(pointer: pointer, message: message)
39
+ end
40
+
41
+ def inspect_json(value, path, depth, ancestors)
42
+ case value
43
+ when Hash, Array
44
+ raise LimitError.new("JSON nesting exceeds max_depth", pointer: path) if depth >= @limits[:max_depth]
45
+
46
+ if ancestors.include?(value.object_id)
47
+ add(path, "cyclic JSON data")
48
+ return
49
+ end
50
+ next_ancestors = ancestors + [value.object_id]
51
+ if value.is_a?(Hash)
52
+ value.each do |key, item|
53
+ unless key.is_a?(String) && unicode?(key)
54
+ add(path, "JSON member names must be valid Unicode strings")
55
+ next
56
+ end
57
+ inspect_json(item, Jscontact.pointer(path, key), depth + 1, next_ancestors)
58
+ end
59
+ else
60
+ value.each_with_index { |item, index| inspect_json(item, Jscontact.pointer(path, index), depth + 1, next_ancestors) }
61
+ end
62
+ when String
63
+ add(path, "string is not valid I-JSON Unicode") unless unicode?(value)
64
+ check_data_uri_limit(value, path)
65
+ when Integer
66
+ add(path, "integer is outside the exact I-JSON range") unless Types.integer?(value)
67
+ when Float
68
+ add(path, "number must be finite") unless value.finite?
69
+ when TrueClass, FalseClass, NilClass
70
+ nil
71
+ else
72
+ add(path, "value is not JSON-compatible")
73
+ end
74
+ end
75
+
76
+ def unicode?(value)
77
+ return false unless value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
78
+
79
+ value.each_codepoint.none? { |point| point.between?(0xD800, 0xDFFF) || point.between?(0xFDD0, 0xFDEF) || (point & 0xFFFF) >= 0xFFFE }
80
+ end
81
+
82
+ def check_data_uri_limit(value, path)
83
+ return unless value.b.byteslice(0, 5)&.downcase == "data:"
84
+
85
+ header, payload = value.b.split(",", 2)
86
+ return unless payload
87
+
88
+ payload = URI::DEFAULT_PARSER.unescape(payload)
89
+ size = if header.downcase.end_with?(";base64")
90
+ (payload.bytesize * 3 / 4) - [payload[/=+\z/].to_s.length, 2].min
91
+ else
92
+ payload.bytesize
93
+ end
94
+ raise LimitError.new("data URI exceeds max_data_uri_size", pointer: path) if size > @limits[:max_data_uri_size]
95
+ end
96
+
97
+ def object(value, type, path, root: false)
98
+ unless value.is_a?(Hash)
99
+ add(path, "expected #{type} object")
100
+ return
101
+ end
102
+ fields = SCHEMA.fetch(type).fetch("properties")
103
+ reserved = SCHEMA.fetch(type).fetch("reserved", [])
104
+ add(Jscontact.pointer(path, "@type"), "expected @type #{type}") if (root || value.key?("@type")) && value["@type"] != type
105
+ fields.each do |name, rule|
106
+ required = rule["required"] || (type == "Card" && rule.fetch("required_versions", []).include?(value["version"]))
107
+ add(Jscontact.pointer(path, name), "required property is missing") if required && !value.key?(name)
108
+ end
109
+ value.each do |name, item|
110
+ next unless name.is_a?(String) && unicode?(name)
111
+
112
+ location = Jscontact.pointer(path, name)
113
+ if name.downcase == "extra" || reserved.any? { |entry| entry.casecmp?(name) }
114
+ add(location, "#{name} is reserved")
115
+ elsif fields.key?(name)
116
+ check(item, fields[name].fetch("type"), location) unless name == "@type"
117
+ elsif KNOWN_NAMES.any? { |known| known.casecmp?(name) }
118
+ add(location, "property name has incorrect case or is not allowed in #{type}")
119
+ elsif !Types.vendor?(name) && !name.match?(/\A(?:[a-z][A-Za-z0-9]*|@[A-Za-z0-9]+)\z/)
120
+ add(location, "invalid property name")
121
+ end
122
+ end
123
+ relationships(value, type, path)
124
+ end
125
+
126
+ def check(value, signature, path)
127
+ return if value.is_a?(String) && !unicode?(value)
128
+
129
+ if SCHEMA.key?(signature)
130
+ object(value, signature, path)
131
+ elsif signature.end_with?("[]")
132
+ check_array(value, signature.delete_suffix("[]"), path)
133
+ elsif (match = /\A(\w+)\[(.+)\]\z/.match(signature))
134
+ check_map(value, match[1], match[2], path)
135
+ elsif signature.start_with?("Enum:")
136
+ check_enum(value, signature.delete_prefix("Enum:"), path)
137
+ elsif signature.start_with?("EnumMap:")
138
+ check_enum_map(value, signature.delete_prefix("EnumMap:"), path, true_only: true)
139
+ elsif signature == "Contexts"
140
+ check_enum_map(value, "context", path, true_only: false)
141
+ elsif signature == "PartialDate|Timestamp"
142
+ type = value.is_a?(Hash) ? value.fetch("@type", "PartialDate") : "PartialDate"
143
+ if %w[PartialDate Timestamp].include?(type)
144
+ object(value, type, path)
145
+ else
146
+ add(Jscontact.pointer(path, "@type"), "expected PartialDate or Timestamp")
147
+ end
148
+ elsif signature == "VCardParams"
149
+ check_parameters(value, path)
150
+ elsif signature == "JCardProp"
151
+ check_jcard_property(value, path)
152
+ elsif signature == "PatchObject"
153
+ add(path, "expected PatchObject") unless value.is_a?(Hash)
154
+ raise LimitError.new("patch count exceeds max_patches", pointer: path) if value.is_a?(Hash) && value.size > @limits[:max_patches]
155
+ else
156
+ add(path, "invalid #{signature} value") unless scalar?(value, signature)
157
+ end
158
+ end
159
+
160
+ def check_array(value, signature, path)
161
+ unless value.is_a?(Array)
162
+ add(path, "expected an array")
163
+ return
164
+ end
165
+ raise LimitError.new("array exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
166
+
167
+ value.each_with_index { |item, index| check(item, signature, Jscontact.pointer(path, index)) }
168
+ end
169
+
170
+ def check_map(value, key_type, item_type, path)
171
+ unless value.is_a?(Hash)
172
+ add(path, "expected a map")
173
+ return
174
+ end
175
+ raise LimitError.new("map exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
176
+
177
+ value.each do |key, item|
178
+ next unless key.is_a?(String) && unicode?(key)
179
+
180
+ location = Jscontact.pointer(path, key)
181
+ add(location, "invalid #{key_type} map key") unless scalar?(key, key_type)
182
+ check(item, item_type, location)
183
+ end
184
+ end
185
+
186
+ def check_enum(value, enum, path)
187
+ return if value.is_a?(String) && (ENUMS.fetch(enum).include?(value) || Types.vendor?(value))
188
+
189
+ add(path, "expected a registered or vendor-specific #{enum} value")
190
+ end
191
+
192
+ def check_enum_map(value, enum, path, true_only:)
193
+ check_map(value, "String", true_only ? "True" : "Boolean", path)
194
+ return unless value.is_a?(Hash)
195
+
196
+ value.each_key { |key| check_enum(key, enum, Jscontact.pointer(path, key)) }
197
+ end
198
+
199
+ def check_parameters(value, path)
200
+ unless value.is_a?(Hash)
201
+ add(path, "expected vCard parameter map")
202
+ return
203
+ end
204
+ value.each do |key, item|
205
+ location = Jscontact.pointer(path, key)
206
+ add(location, "invalid lowercase parameter name") unless key.is_a?(String) && key.match?(/\A[a-z0-9-]+\z/)
207
+ unless item.is_a?(String) || (item.is_a?(Array) && !item.empty? && item.all?(String))
208
+ add(location, "parameter must be a string or nonempty string array")
209
+ end
210
+ end
211
+ end
212
+
213
+ def check_jcard_property(value, path)
214
+ unless value.is_a?(Array) && value.size >= 4
215
+ add(path, "expected jCard property array")
216
+ return
217
+ end
218
+ unless value[0].is_a?(String) && value[0].ascii_only? && value[0].match?(/\A[a-z0-9-]+\z/)
219
+ add(Jscontact.pointer(path, 0), "invalid lowercase vCard property name")
220
+ end
221
+ check_parameters(value[1], Jscontact.pointer(path, 1))
222
+ unless value[2].is_a?(String) && value[2].ascii_only? && value[2].match?(/\A[a-z0-9-]+\z/)
223
+ add(Jscontact.pointer(path, 2), "invalid lowercase vCard value type")
224
+ return
225
+ end
226
+
227
+ value.drop(3).each_with_index { |item, index| check_jcard_value(item, value[2], Jscontact.pointer(path, index + 3)) }
228
+ end
229
+
230
+ def check_jcard_value(value, type, path)
231
+ return if value.is_a?(String) && !unicode?(value)
232
+
233
+ if value.is_a?(Array) && %w[text unknown].include?(type)
234
+ add(path, "structured jCard values must not be empty") if value.empty?
235
+ value.each_with_index { |item, index| check_jcard_value(item, type, Jscontact.pointer(path, index)) }
236
+ return
237
+ end
238
+ valid = case type
239
+ when "boolean" then [true, false].include?(value)
240
+ when "integer" then Types.integer?(value)
241
+ when "float" then (value.is_a?(Integer) || value.is_a?(Float)) && value.finite?
242
+ when "uri" then Types.uri?(value)
243
+ when "language-tag" then Types.language_tag?(value)
244
+ when "text", "unknown" then value.is_a?(String) && !value.match?(/[\x00-\x08\x0B-\x1F\x7F]/)
245
+ when "date", "time", "date-time", "date-and-or-time", "timestamp", "utc-offset"
246
+ Types.jcard_temporal?(value, type)
247
+ else true # Unknown registered value types must survive round trips.
248
+ end
249
+ add(path, "invalid jCard #{type} value") unless valid
250
+ end
251
+
252
+ def scalar?(value, signature)
253
+ case signature
254
+ when "String" then value.is_a?(String)
255
+ when "NonEmptyString" then value.is_a?(String) && !value.empty?
256
+ when "Boolean" then [true, false].include?(value)
257
+ when "True" then value == true
258
+ when "Int" then Types.integer?(value)
259
+ when "UnsignedInt" then Types.integer?(value, minimum: 0)
260
+ when "PositiveInt" then Types.integer?(value, minimum: 1)
261
+ when "Preference" then Types.integer?(value, minimum: 1, maximum: 100)
262
+ when "Month" then Types.integer?(value, minimum: 1, maximum: 12)
263
+ when "Day" then Types.integer?(value, minimum: 1, maximum: 31)
264
+ when "Id" then Types.id?(value)
265
+ when "UTCDateTime" then Types.utc_date_time?(value)
266
+ when "URI" then Types.uri?(value)
267
+ when "GeoURI" then geo_uri?(value)
268
+ when "Email" then Types.email?(value)
269
+ when "LanguageTag" then Types.language_tag?(value)
270
+ when "Script" then Types.script?(value)
271
+ when "Version" then %w[1.0 2.0].include?(value)
272
+ when "VCardName" then value.is_a?(String) && value.match?(/\A[A-Za-z0-9-]+\z/)
273
+ when "CalendarScale" then value.is_a?(String) && (ENUMS["calendarScale"].include?(value) || Types.vendor?(value))
274
+ when "CountryCode" then value.is_a?(String) && !TZInfo::Country.get(value.upcase).nil?
275
+ when "TimeZone" then value.is_a?(String) && !TZInfo::Timezone.get(value).nil?
276
+ when "MediaType" then value.is_a?(String) && value.match?(%r{\A[A-Za-z0-9!\#$&^_.+-]+/[A-Za-z0-9!\#$&^_.+-]+(?:\s*;[^\r\n]+)?\z})
277
+ else false
278
+ end
279
+ rescue TZInfo::InvalidCountryCode, TZInfo::InvalidTimezoneIdentifier
280
+ false
281
+ end
282
+
283
+ def geo_uri?(value)
284
+ return false unless value.is_a?(String) && value.ascii_only?
285
+
286
+ coordinates, *parameters = value.split(";", -1)
287
+ match = /\Ageo:(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,-?\d+(?:\.\d+)?)?\z/i.match(coordinates)
288
+ return false unless match
289
+
290
+ crs = "wgs84"
291
+ parameters.each_with_index do |parameter, index|
292
+ name, content = parameter.split("=", 2)
293
+ return false unless name&.match?(/\A[A-Za-z0-9-]+\z/)
294
+
295
+ case name.downcase
296
+ when "crs"
297
+ return false unless index.zero? && content&.match?(/\A[A-Za-z0-9-]+\z/)
298
+
299
+ crs = content.downcase
300
+ when "u"
301
+ return false unless index == (parameters.first.downcase.start_with?("crs=") ? 1 : 0)
302
+ return false unless content&.match?(/\A\d+(?:\.\d+)?\z/)
303
+ else
304
+ return false if content && !content.match?(/\A(?:[A-Za-z0-9_.!~*'()\[\]:&+$-]|%[0-9A-Fa-f]{2})+\z/)
305
+ end
306
+ end
307
+ return true unless crs == "wgs84"
308
+
309
+ match[1].match?(/\A-?\d{1,2}(?:\.\d+)?\z/) && match[2].match?(/\A-?\d{1,3}(?:\.\d+)?\z/) &&
310
+ match[1].to_f.between?(-90, 90) && match[2].to_f.between?(-180, 180)
311
+ end
312
+
313
+ def relationships(value, type, path)
314
+ case type
315
+ when "Card"
316
+ add(Jscontact.pointer(path, "members"), "members requires kind group") if value.key?("members") && value["kind"] != "group"
317
+ @errors.concat(Localization.validate(value, **@limits)) if @validate_localizations && value["localizations"].is_a?(Hash) && defined?(Localization)
318
+ when "Name", "Address" then components(value, type, path)
319
+ when "Organization"
320
+ alternatives(value, %w[name units], path)
321
+ add(Jscontact.pointer(path, "units"), "units must not be empty") if value["units"].is_a?(Array) && value["units"].empty?
322
+ when "SpeakToAs" then alternatives(value, %w[grammaticalGender pronouns], path)
323
+ when "OnlineService" then alternatives(value, %w[uri user], path)
324
+ when "Author"
325
+ add(path, "author needs a property other than @type") if (value.keys - ["@type"]).empty?
326
+ when "PartialDate" then partial_date(value, path)
327
+ end
328
+ end
329
+
330
+ def alternatives(value, names, path)
331
+ add(path, "at least one of #{names.join(", ")} is required") unless names.any? { |name| value.key?(name) }
332
+ end
333
+
334
+ def components(value, type, path)
335
+ alternatives(value, type == "Name" ? %w[components full] : %w[components coordinates countryCode full timeZone], path)
336
+ list = value["components"]
337
+ if value.key?("defaultSeparator") && (value["isOrdered"] != true || !value.key?("components"))
338
+ add(Jscontact.pointer(path, "defaultSeparator"), "requires ordered components")
339
+ end
340
+ if value["sortAs"].is_a?(Hash)
341
+ value["sortAs"].each_key do |kind|
342
+ next if list.is_a?(Array) && list.any? { |component| component.is_a?(Hash) && component["kind"] == kind }
343
+
344
+ add(Jscontact.pointer(Jscontact.pointer(path, "sortAs"), kind), "sort key must name an existing component kind")
345
+ end
346
+ add(Jscontact.pointer(path, "sortAs"), "requires components") unless value.key?("components")
347
+ end
348
+ return unless list.is_a?(Array)
349
+
350
+ component_path = Jscontact.pointer(path, "components")
351
+ unless list.any? { |component| component.is_a?(Hash) && component["kind"] != "separator" }
352
+ add(component_path, "at least one non-separator component is required")
353
+ end
354
+ list.each_with_index do |component, index|
355
+ next unless component.is_a?(Hash)
356
+
357
+ location = Jscontact.pointer(component_path, index)
358
+ if component["kind"] == "separator"
359
+ add(location, "separator requires ordered components") unless value["isOrdered"] == true
360
+ add(location, "consecutive separators are forbidden") if index.positive? && list[index - 1].is_a?(Hash) && list[index - 1]["kind"] == "separator"
361
+ end
362
+ if component.key?("phonetic") && !value.key?("phoneticScript") && !value.key?("phoneticSystem")
363
+ add(Jscontact.pointer(location, "phonetic"), "requires phoneticScript or phoneticSystem on its parent")
364
+ end
365
+ end
366
+ end
367
+
368
+ def partial_date(value, path)
369
+ alternatives(value, %w[year month day], path)
370
+ add(Jscontact.pointer(path, "month"), "month requires year or day") if value.key?("month") && !value.key?("year") && !value.key?("day")
371
+ add(Jscontact.pointer(path, "day"), "day requires month") if value.key?("day") && !value.key?("month")
372
+ year, month, day = value.values_at("year", "month", "day")
373
+ return unless Types.integer?(month, minimum: 1, maximum: 12) && Types.integer?(day, minimum: 1, maximum: 31)
374
+ return if year && !Types.integer?(year, minimum: 0)
375
+
376
+ add(path, "invalid Gregorian calendar date") unless Date.valid_date?(year || 2000, month, day, Date::GREGORIAN)
377
+ end
378
+ end
379
+ end