schemacop 3.0.39 → 3.1.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +1 -1
  3. data/.rubocop.yml +4 -1
  4. data/CHANGELOG.md +87 -0
  5. data/Gemfile +1 -1
  6. data/README.md +8 -7
  7. data/README_V3.md +38 -5
  8. data/VERSION +1 -1
  9. data/lib/schemacop/schema3.rb +2 -2
  10. data/lib/schemacop/v2/node.rb +1 -1
  11. data/lib/schemacop/v2/validator/string_validator.rb +1 -1
  12. data/lib/schemacop/v3/any_of_node.rb +13 -0
  13. data/lib/schemacop/v3/array_node.rb +2 -3
  14. data/lib/schemacop/v3/global_context.rb +1 -1
  15. data/lib/schemacop/v3/hash_node.rb +23 -21
  16. data/lib/schemacop/v3/node.rb +5 -0
  17. data/lib/schemacop/v3/numeric_node.rb +2 -2
  18. data/lib/schemacop/v3/object_node.rb +1 -1
  19. data/lib/schemacop/v3/one_of_node.rb +16 -3
  20. data/lib/schemacop/v3/result.rb +14 -1
  21. data/lib/schemacop/v3/string_node.rb +32 -4
  22. data/lib/schemacop/v3.rb +32 -0
  23. data/lib/schemacop.rb +12 -9
  24. data/schemacop.gemspec +3 -3
  25. data/test/lib/test_helper.rb +7 -0
  26. data/test/unit/schemacop/v2/casting_test.rb +1 -1
  27. data/test/unit/schemacop/v2/custom_check_test.rb +2 -2
  28. data/test/unit/schemacop/v2/custom_if_test.rb +2 -2
  29. data/test/unit/schemacop/v3/any_of_node_test.rb +24 -0
  30. data/test/unit/schemacop/v3/array_node_test.rb +60 -1
  31. data/test/unit/schemacop/v3/binary_node_test.rb +8 -0
  32. data/test/unit/schemacop/v3/hash_node_test.rb +143 -1
  33. data/test/unit/schemacop/v3/integer_node_test.rb +15 -1
  34. data/test/unit/schemacop/v3/is_not_node_test.rb +12 -0
  35. data/test/unit/schemacop/v3/number_node_test.rb +1 -1
  36. data/test/unit/schemacop/v3/object_node_test.rb +8 -0
  37. data/test/unit/schemacop/v3/one_of_node_test.rb +61 -0
  38. data/test/unit/schemacop/v3/string_node_test.rb +250 -2
  39. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5265f8c948ca9f0d23aa7e13ff78a5a980ce12ccd16c6fd1dae4e8ec6a30b833
4
- data.tar.gz: 77177403eccb133730a9359172c6d37362d2d76184a2758131bcd35fe5869f0a
3
+ metadata.gz: f790c910d2c32accea93c9f4e67b85c80ababfbaca2b64930d7e380a65125d9e
4
+ data.tar.gz: ae3f22c58697112408efde125190a0cd8518199ceb8b47b65094e289d9e8769d
5
5
  SHA512:
6
- metadata.gz: 306a17bfebac65db3f0c7fbd37f2b5d0fa3c44b26c6217732241fa8cd43455a552a07f21327c805d421c0a2649f8b1b936b8063d9cf5b97673e416aea3dff8b6
7
- data.tar.gz: 64f2e2a148374b811fb4ac251606b25c48be802a1255e3dd72389aa2f4250f59695dba9b20b8cc62f48a9e646ea45d6cc9c9aeacc216b4704300b3864fa1701c
6
+ metadata.gz: b3f20de721d5e94f4bbb1504292c8ecd7136ae8468e8fde9d0885e9503aee4c6f950e7bd888ea50067d0dbf40c3dd0e36ab12d2ee674143a28405bd3c70bfe2f
7
+ data.tar.gz: efd82ce7677ee0c3d5031af98997840cd6cf38a3aa2337e68ead04e383add88e32d29f15d08ad0739e47691c48299f329c547677abd6aca1118a29fe6308a4e4
@@ -12,7 +12,7 @@ jobs:
12
12
  strategy:
13
13
  fail-fast: false
14
14
  matrix:
15
- ruby-version: ['2.6.2', '2.7.1', '3.0.1', '3.1.0', '3.2.0', '3.3.0', '3.4.0']
15
+ ruby-version: ['3.2.0', '3.3.0', '3.4.0', '4.0.0']
16
16
 
17
17
  steps:
18
18
  - uses: actions/checkout@v2
data/.rubocop.yml CHANGED
@@ -10,7 +10,7 @@ AllCops:
10
10
  - 'locale/translations.rb'
11
11
  - 'lib/scratch.rb'
12
12
  - 'schemacop.gemspec'
13
-
13
+ SuggestExtensions: false
14
14
  DisplayCopNames: true
15
15
 
16
16
  Style/SignalException:
@@ -108,4 +108,7 @@ Style/CaseLikeIf:
108
108
  Enabled: false
109
109
 
110
110
  Lint/EmptyClass:
111
+ Enabled: false
112
+
113
+ Lint/RedundantRequireStatement:
111
114
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Change log
2
2
 
3
+ ## 3.1.1 (2026-09-08)
4
+
5
+ * Fix crashes when validating strings whose encoding does not fit their
6
+ contents or the schema:
7
+
8
+ * A string with an invalid byte sequence is reported as such and no longer
9
+ handed to `allow_blank`, `pattern` and `format`, which decode it and
10
+ therefore raised an `ArgumentError`. The checks that do not decode the
11
+ string (`min_length`, `max_length`, `encoding` and `enum`) are still
12
+ reported.
13
+
14
+ * A string that cannot be compared with the `pattern` or with the pattern of
15
+ the given `format` because their encodings are incompatible (e.g. a UTF-16
16
+ string) is reported as not matching it, rather than raising an
17
+ `Encoding::CompatibilityError` or a `RegexpError`. The same holds for
18
+ `allow_blank`, `dep` dependencies and `treat_blank_as_nil`, which treat
19
+ such a string as blank only if it is empty.
20
+
21
+ * Hash keys and values with an invalid byte sequence no longer raise an
22
+ `ArgumentError` or an `EncodingError` when used together with
23
+ `property_names`, pattern properties, `ignore_obsolete_properties` or `dep`
24
+ dependencies. Such a key matches none of the patterns.
25
+
26
+ * A key with an invalid byte sequence in the path of a validation error no
27
+ longer makes `Result#messages`, `Result#exception_message` and `validate!`
28
+ raise an `ArgumentError`. The invalid bytes are replaced in the path of the
29
+ message.
30
+
31
+ * The `encoding` error of a string is now reported before its `pattern` and
32
+ `format` errors, rather than after them.
33
+
34
+ Internal reference: `#153682`.
35
+
36
+ * Anchor the patterns of the built-in string formats with `\A` and `\z` instead
37
+ of `^` and `$`, which match at the start and the end of every line in Ruby. A
38
+ multi-line string passed the validation as soon as one of its lines matched
39
+ and was then handed to the cast handler: `"32\ntest"` passed the `integer`
40
+ format and raised an `ArgumentError`, `"true\n"` passed the `boolean` format
41
+ and was cast to `false`. A value with a trailing newline is rejected as well
42
+ now, where it used to pass and be cast. Affects the formats `date`,
43
+ `date-time`, `time`, `mailbox`, `boolean`, `integer`, `number` and
44
+ `integer-list`.
45
+
46
+ Internal reference: `#153681`.
47
+
48
+ * Require `tempfile`, which the `bin` node needs. Requiring the gem outside of
49
+ Rails failed with a `NameError` for `Tempfile`.
50
+
51
+ * Fix `ignore_obsolete_properties` ignoring an allow list given as strings
52
+ (e.g. `ignore_obsolete_properties: %w[foo]`), which reported the listed
53
+ properties as obsolete. Strings and symbols are now treated alike.
54
+
55
+ * Fix a `NoMethodError` when casting a hash that declares pattern properties
56
+ and contains a key matching none of them.
57
+
58
+ * Fix casting of hash properties that are specified in the schema: in a hash
59
+ that also declares pattern properties or additional properties, they were
60
+ casted a second time as such, which could cast them with the wrong node and
61
+ added the original key to the result next to the one given via `as`.
62
+
63
+ ## 3.1.0 (2026-05-09)
64
+
65
+ * Add option `encoding` to `str` node for Schemacop V3 schemas to validate that
66
+ a string has one of the specified encodings. Accepts a single encoding name or
67
+ an array of encoding names (e.g. `encoding: 'UTF-8'` or
68
+ `encoding: %w[UTF-8 US-ASCII]`).
69
+
70
+ Internal reference: `#143271`.
71
+
72
+ * All strings are now validated for valid encoding (via `valid_encoding?`),
73
+ regardless of whether the `encoding` option is set. Strings with invalid byte
74
+ sequences for their declared encoding will now produce a validation error.
75
+
76
+ Internal reference: `#143271`.
77
+
78
+ * Update RuboCop from 1.24.1 to 1.69.2.
79
+
80
+ * Drop support for all EOL Rubies (2.6.2, 2.7.1, 3.0.1, 3.1.0)
81
+
82
+ * Fix `cast_str` default option causing incorrect casting and validation errors
83
+ when used with combination nodes and array tuples. Numeric-looking strings
84
+ (e.g. `"1"`) were incorrectly cast to integers in schemas where a string type
85
+ was also valid. With this fix, `cast_str` only activates when the value cannot
86
+ be matched natively by another sibling schema.
87
+
88
+ Internal reference: `#149255`.
89
+
3
90
  ## 3.0.39 (2026-03-26)
4
91
 
5
92
  * Add `BinaryNode` for validating binary data fields such as file uploads.
data/Gemfile CHANGED
@@ -9,5 +9,5 @@ gem 'minitest'
9
9
  gem 'minitest-reporters'
10
10
  gem 'pry'
11
11
  gem 'rake'
12
- gem 'rubocop', '1.24.1'
12
+ gem 'rubocop', '1.69.2'
13
13
  gem 'simplecov', '0.21.2'
data/README.md CHANGED
@@ -12,13 +12,14 @@ use in conjunction with [OpenAPI](https://swagger.io/specification/).
12
12
 
13
13
  Schemacop is tested with the following ruby versions:
14
14
 
15
- * 2.6.2
16
- * 2.7.1
17
- * 3.0.1
18
- * 3.1.0
19
- * 3.2.0
20
- * 3.3.0
21
- * 3.4.0
15
+ | Schemacop | Ruby |
16
+ |:----------|:------------------------------------------------|
17
+ | >= 3.1.0 | 3.2.0, 3.3.0, 3.4.0, 4.0.0 |
18
+ | >= 3.0.31 | 2.6.2, 2.7.1, 3.0.1, 3.1.0, 3.2.0, 3.3.0, 3.4.0 |
19
+ | >= 3.0.29 | 2.6.2, 2.7.1, 3.0.1, 3.1.0, 3.2.0, 3.3.0 |
20
+ | >= 3.0.23 | 2.6.2, 2.7.1, 3.0.1, 3.1.0, 3.2.0 |
21
+ | >= 3.0.17 | 2.6.2, 2.7.1, 3.0.1, 3.1.0 |
22
+ | <= 3.0.16 | 2.6.2, 2.7.1, 3.0.1 |
22
23
 
23
24
  Other ruby versions might work but are not covered by our automated tests.
24
25
 
data/README_V3.md CHANGED
@@ -213,8 +213,17 @@ transformed into various types.
213
213
  * `pattern`
214
214
  Defines a (ruby) regex pattern the value will be matched against. Must be either
215
215
  a string which should not be enclosed in `/` characters, or a Ruby Regexp.
216
- The pattern should generally start with `^` and end with `$` so as to evaluate
217
- the entire string.
216
+ The pattern should generally be anchored so as to evaluate the entire string.
217
+ In Ruby, `^` and `$` match at the start and the end of every *line*, so
218
+ `/^[0-9]+$/` accepts `"32\nfoo"`; `\A` and `\z` match only at the start and
219
+ the end of the string.
220
+
221
+ Patterns are exported to the JSON schema verbatim, and JSON Schema uses the
222
+ ECMA-262 dialect, which knows no `\A` and `\z` (it reads them as the literal
223
+ characters `A` and `z`) and where `^` and `$` already match the whole string
224
+ only. A pattern can therefore be correct on one side or the other, but not on
225
+ both: use `\A` and `\z` for schemas that are validated in Ruby, and `^` and
226
+ `$` for schemas that are exported and validated elsewhere.
218
227
  * `format`
219
228
  The `format` option allows for basic semantic validation on certain kinds of
220
229
  string values that are commonly used. See section *formats* for more
@@ -224,6 +233,23 @@ transformed into various types.
224
233
  By default, blank strings are allowed and left as they are when casted (e.g.
225
234
  the string `''` is valid). If you want to disallow blank strings, set this
226
235
  option to `false`.
236
+ * `encoding`
237
+ Validates the encoding of the string. Accepts a single encoding name or an
238
+ array of encoding names (e.g. `encoding: 'UTF-8'` or
239
+ `encoding: %w[UTF-8 US-ASCII]`). See `Encoding.name_list` for all available
240
+ encoding names.
241
+
242
+ Note that regardless of the `encoding` option, all strings are validated for
243
+ valid encoding using `valid_encoding?`. Strings with invalid byte sequences for
244
+ their declared encoding will always produce a validation error. The `pattern`
245
+ and `format` of such a string are not checked, as matching a pattern requires
246
+ decoding it; the checks that do not decode the string (`min_length`,
247
+ `max_length`, `encoding` and `enum`) are still reported.
248
+
249
+ A string whose encoding is valid but cannot be compared with the `pattern` or
250
+ with the pattern of the given `format` (e.g. a UTF-16 string) is reported as not
251
+ matching it, rather than raising an error. Note that `min_length` and
252
+ `max_length` count bytes for such an encoding, as it has no characters.
227
253
 
228
254
  #### Formats
229
255
 
@@ -286,12 +312,16 @@ You can also implement your custom formats or override the behavior of the
286
312
  standard formats. For Rails applications, this can be done in
287
313
  `config/schemacop.rb`:
288
314
 
315
+ Anchor the pattern with `\A` and `\z`. In Ruby, `^` and `$` match at the start
316
+ and the end of every line, so a multi-line value would pass the validation and
317
+ reach your handler.
318
+
289
319
  ```ruby
290
320
  # config/schemacop.rb
291
321
  Schemacop.register_string_formatter(
292
- :character_array, # Formatter name
293
- pattern: /^[a-zA-Z](,[a-zA-Z])*/, # Regex pattern for validation
294
- handler: ->(value) { value.split(',') } # Casting callback
322
+ :character_array, # Formatter name
323
+ pattern: /\A[a-zA-Z](,[a-zA-Z])*\z/, # Regex pattern for validation
324
+ handler: ->(value) { value.split(',') } # Casting callback
295
325
  )
296
326
 
297
327
  # In your schema
@@ -927,6 +957,9 @@ It consists of key-value-pairs that can be validated using arbitrary nodes.
927
957
  This option allows to specify a regexp pattern (as string) which validates the
928
958
  keys of any properties that are not specified in the hash. This option only
929
959
  makes sense if `additional_properties` is enabled. See below for more information.
960
+ The pattern is anchored the same way as the `pattern` option of the string
961
+ node, i.e. a pattern using `^` and `$` accepts a key that matches on any of
962
+ its lines.
930
963
 
931
964
  * `min_properties`
932
965
  Specifies the (inclusive) minimum number of properties a hash must contain.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.0.39
1
+ 3.1.1
@@ -1,8 +1,8 @@
1
1
  module Schemacop
2
2
  class Schema3 < BaseSchema
3
- def initialize(*args, **kwargs, &block)
3
+ def initialize(...)
4
4
  super()
5
- @root = V3::Node.create(*args, **kwargs, &block)
5
+ @root = V3::Node.create(...)
6
6
  end
7
7
 
8
8
  # Validate data for the defined Schema
@@ -88,7 +88,7 @@ module Schemacop
88
88
 
89
89
  if option?(:cast) && self.class.klasses.size > 1
90
90
  fail Exceptions::InvalidSchemaError,
91
- "Casting is only allowed for single-value datatypes, but type #{self.class.inspect} has classes "\
91
+ "Casting is only allowed for single-value datatypes, but type #{self.class.inspect} has classes " \
92
92
  "#{self.class.klasses.map(&:inspect)}."
93
93
  end
94
94
  end
@@ -7,7 +7,7 @@ module Schemacop
7
7
  option :max
8
8
 
9
9
  def initialize(options = {})
10
- super(options)
10
+ super
11
11
 
12
12
  validate_options!
13
13
  end
@@ -5,6 +5,19 @@ module Schemacop
5
5
  :anyOf
6
6
  end
7
7
 
8
+ protected
9
+
10
+ def matches(data)
11
+ all_matches = super
12
+ if all_matches.size > 1
13
+ non_wrappers = all_matches.reject(&:cast_str_wrapper?)
14
+ return non_wrappers if non_wrappers.any?
15
+ end
16
+ all_matches
17
+ end
18
+
19
+ public
20
+
8
21
  def _validate(data, result:)
9
22
  super_data = super
10
23
  return if super_data.nil?
@@ -156,7 +156,7 @@ module Schemacop
156
156
  result = []
157
157
 
158
158
  value.each_with_index do |value_item, index|
159
- if cont_item.present? && item_matches?(cont_item, value_item)
159
+ if cont_item.present? && !cont_item.cast_str_wrapper? && item_matches?(cont_item, value_item)
160
160
  result << cont_item.cast(value_item)
161
161
  elsif list?
162
162
  result << list_item.cast(value_item)
@@ -168,8 +168,7 @@ module Schemacop
168
168
  result << value_item
169
169
  end
170
170
  else
171
- item = item_for_data(value_item)
172
- result << item.cast(value_item)
171
+ result << items[index].cast(value_item)
173
172
  end
174
173
  else
175
174
  result << value_item
@@ -97,7 +97,7 @@ module Schemacop
97
97
  fail "Schema #{path.inspect} does not define any schema."
98
98
  when 1
99
99
  if @schemas.include?(virtual_path)
100
- fail "Schema #{virtual_path.to_s.inspect} is defined in both load paths "\
100
+ fail "Schema #{virtual_path.to_s.inspect} is defined in both load paths " \
101
101
  "#{@load_paths_by_schemas[virtual_path].inspect} and #{load_path.inspect}."
102
102
  end
103
103
 
@@ -65,8 +65,8 @@ module Schemacop
65
65
  end
66
66
 
67
67
  json = {}
68
- json[:properties] = properties.values.map { |p| [p.name, p.as_json] }.to_h if properties.any?
69
- json[:patternProperties] = pattern_properties.values.map { |p| [V3.sanitize_exp(p.name), p.as_json] }.to_h if pattern_properties.any?
68
+ json[:properties] = properties.values.to_h { |p| [p.name, p.as_json] } if properties.any?
69
+ json[:patternProperties] = pattern_properties.values.to_h { |p| [V3.sanitize_exp(p.name), p.as_json] } if pattern_properties.any?
70
70
 
71
71
  # In schemacop, by default, additional properties are not allowed,
72
72
  # the users explicitly need to enable additional properties
@@ -168,21 +168,26 @@ module Schemacop
168
168
  property_names = options[:property_names]
169
169
  property_names = Regexp.compile(property_names) if property_names
170
170
 
171
+ # `name.to_sym` raises an EncodingError on a key with an invalid byte
172
+ # sequence, so the allow list is compared as strings.
173
+ if options[:ignore_obsolete_properties].is_a?(Enumerable)
174
+ obsolete_allow_list = options[:ignore_obsolete_properties].to_set(&:to_s)
175
+ end
176
+
171
177
  additional_properties.each do |name, additional_property|
172
- if property_names && !property_names.match?(name)
178
+ if property_names && !V3.match?(property_names, name.to_s)
173
179
  result.error "Property name #{name.inspect} does not match #{options[:property_names].inspect}."
174
180
  end
175
181
 
176
182
  if options[:additional_properties].is_a?(TrueClass)
177
183
  next
178
184
  elsif options[:additional_properties].is_a?(FalseClass) || options[:additional_properties].blank?
179
- match = property_patterns.keys.find { |p| p.match?(name.to_s) }
185
+ match = property_patterns.keys.find { |p| V3.match?(p, name.to_s) }
180
186
  if match
181
187
  result.in_path(name) do
182
188
  property_patterns[match]._validate(additional_property, result: result)
183
189
  end
184
- elsif (options[:ignore_obsolete_properties].is_a?(Enumerable) &&
185
- options[:ignore_obsolete_properties].exclude?(name.to_sym)) ||
190
+ elsif obsolete_allow_list&.exclude?(name.to_s) ||
186
191
  !options[:ignore_obsolete_properties]
187
192
  result.error "Obsolete property #{name.to_s.inspect}."
188
193
  end
@@ -196,7 +201,7 @@ module Schemacop
196
201
  # Validate dependencies #
197
202
  options[:dependencies]&.each do |source, targets|
198
203
  targets.each do |target|
199
- if data_hash[source].present? && data_hash[target].blank?
204
+ if !V3.blank?(data_hash[source]) && V3.blank?(data_hash[target])
200
205
  result.error "Missing property #{target.to_s.inspect} because #{source.to_s.inspect} is given."
201
206
  end
202
207
  end
@@ -268,23 +273,24 @@ module Schemacop
268
273
  end
269
274
 
270
275
  # Handle regex properties
276
+ # Property names are strings, see Node#init.
271
277
  specified_properties = @properties.keys.to_set + inline_ref_property_names
272
- additional_properties = data_hash.reject { |k, _v| specified_properties.include?(k.to_s.to_sym) }
278
+ additional_properties = data_hash.reject { |k, _v| specified_properties.include?(k.to_s) }
273
279
 
274
280
  if additional_properties.any? && property_patterns.any?
275
281
  additional_properties.each do |name, additional_property|
276
- match_key = property_patterns.keys.find { |p| p.match?(name.to_s) }
277
- match = property_patterns[match_key]
278
- result[name] = match.cast(additional_property)
282
+ match_key = property_patterns.keys.find { |p| V3.match?(p, name.to_s) }
283
+ next unless match_key
284
+
285
+ result[name] = property_patterns[match_key].cast(additional_property)
279
286
  end
280
287
  end
281
288
 
282
289
  # Handle additional properties
283
290
  if options[:additional_properties].is_a?(TrueClass)
284
- result = data_hash.merge(result)
291
+ result = data_hash.reject { |k, _v| specified_properties.include?(k.to_s) }.merge(result)
285
292
  elsif options[:additional_properties].is_a?(Node)
286
- add_prop_specified = @properties.keys.to_set + inline_ref_property_names
287
- additional_properties = data_hash.reject { |k, _v| add_prop_specified.include?(k.to_s.to_sym) }
293
+ additional_properties = data_hash.reject { |k, _v| specified_properties.include?(k.to_s) }
288
294
  if additional_properties.any?
289
295
  additional_properties_result = {}
290
296
  additional_properties.each do |key, value|
@@ -300,19 +306,15 @@ module Schemacop
300
306
  protected
301
307
 
302
308
  def as_json_with_inline_refs(properties, pattern_properties)
303
- all_of = []
304
-
305
309
  # Add each inline ref
306
- @inline_refs.each do |inline_ref|
307
- all_of << inline_ref.as_json
308
- end
310
+ all_of = @inline_refs.map(&:as_json)
309
311
 
310
312
  # Add own properties schema if any direct properties exist
311
313
  if properties.any? || pattern_properties.any?
312
314
  own_schema = {}
313
315
  own_schema[:type] = :object
314
- own_schema[:properties] = properties.values.map { |p| [p.name, p.as_json] }.to_h if properties.any?
315
- own_schema[:patternProperties] = pattern_properties.values.map { |p| [V3.sanitize_exp(p.name), p.as_json] }.to_h if pattern_properties.any?
316
+ own_schema[:properties] = properties.values.to_h { |p| [p.name, p.as_json] } if properties.any?
317
+ own_schema[:patternProperties] = pattern_properties.values.to_h { |p| [V3.sanitize_exp(p.name), p.as_json] } if pattern_properties.any?
316
318
 
317
319
  if options[:additional_properties].is_a?(TrueClass)
318
320
  own_schema[:additionalProperties] = true
@@ -45,6 +45,7 @@ module Schemacop
45
45
  self.node node
46
46
  str format: format, format_options: options
47
47
  end
48
+ node.instance_variable_set(:@cast_str_wrapper, true)
48
49
  end
49
50
 
50
51
  return node
@@ -136,6 +137,10 @@ module Schemacop
136
137
  (parent&.schemas || {}).merge(@schemas)
137
138
  end
138
139
 
140
+ def cast_str_wrapper?
141
+ !!@cast_str_wrapper
142
+ end
143
+
139
144
  def required?
140
145
  @required
141
146
  end
@@ -30,7 +30,7 @@ module Schemacop
30
30
  attrs -= %i[exclusive_minimum exclusive_maximum]
31
31
  end
32
32
 
33
- super attrs, json
33
+ super
34
34
  end
35
35
 
36
36
  def _validate(data, result:)
@@ -102,7 +102,7 @@ module Schemacop
102
102
  fail 'Option "minimum" can\'t be greater than "maximum".'
103
103
  end
104
104
 
105
- if options[:exclusive_minimum] && options[:exclusive_maximum]\
105
+ if options[:exclusive_minimum] && options[:exclusive_maximum] \
106
106
  && options[:exclusive_minimum] > options[:exclusive_maximum]
107
107
  fail 'Option "exclusive_minimum" can\'t be greater than "exclusive_maximum".'
108
108
  end
@@ -12,7 +12,7 @@ module Schemacop
12
12
  protected
13
13
 
14
14
  def allowed_types
15
- @classes.map { |c| [c, c.name] }.to_h
15
+ @classes.to_h { |c| [c, c.name] }
16
16
  end
17
17
 
18
18
  def init
@@ -13,7 +13,7 @@ module Schemacop
13
13
  item = match(value)
14
14
 
15
15
  unless item
16
- if options[:treat_blank_as_nil] && value.blank? && !value.is_a?(FalseClass)
16
+ if options[:treat_blank_as_nil] && V3.blank?(value) && !value.is_a?(FalseClass)
17
17
  return nil
18
18
  else
19
19
  return value
@@ -23,12 +23,25 @@ module Schemacop
23
23
  return item.cast(value)
24
24
  end
25
25
 
26
+ protected
27
+
28
+ def matches(data)
29
+ all_matches = super
30
+ if all_matches.size > 1
31
+ non_wrappers = all_matches.reject(&:cast_str_wrapper?)
32
+ return non_wrappers if non_wrappers.any?
33
+ end
34
+ all_matches
35
+ end
36
+
37
+ public
38
+
26
39
  def _validate(data, result:)
27
- if options[:treat_blank_as_nil] && data.blank? && !data.is_a?(FalseClass)
40
+ if options[:treat_blank_as_nil] && V3.blank?(data) && !data.is_a?(FalseClass)
28
41
  data = nil
29
42
  end
30
43
 
31
- super_data = super(data, result: result)
44
+ super_data = super
32
45
  return if super_data.nil?
33
46
 
34
47
  matches = matches(super_data)
@@ -55,10 +55,23 @@ module Schemacop
55
55
 
56
56
  def in_path(segment)
57
57
  prev_path = @current_path
58
- @current_path += [segment]
58
+ @current_path += [sanitize_segment(segment)]
59
59
  yield
60
60
  ensure
61
61
  @current_path = prev_path
62
62
  end
63
+
64
+ private
65
+
66
+ # A path segment can be a key of the validated data and therefore carry any
67
+ # encoding. Joining or splitting a message built from a string with an
68
+ # invalid byte sequence, or from one that is incompatible with the other
69
+ # messages, raises.
70
+ def sanitize_segment(segment)
71
+ return segment unless segment.is_a?(String)
72
+ return segment if segment.valid_encoding? && Encoding.compatible?(Encoding::UTF_8, segment)
73
+
74
+ return segment.scrub.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
75
+ end
63
76
  end
64
77
  end
@@ -8,7 +8,7 @@ module Schemacop
8
8
  ].freeze
9
9
 
10
10
  def self.allowed_options
11
- super + ATTRIBUTES + %i[format_options pattern allow_blank]
11
+ super + ATTRIBUTES + %i[format_options pattern allow_blank encoding]
12
12
  end
13
13
 
14
14
  def allowed_types
@@ -27,7 +27,7 @@ module Schemacop
27
27
  super_data = super
28
28
 
29
29
  # Validate blank #
30
- if options[:allow_blank].is_a?(FalseClass) && super_data.blank?
30
+ if options[:allow_blank].is_a?(FalseClass) && V3.blank?(super_data)
31
31
  result.error 'String is blank but must not be blank!'
32
32
  end
33
33
 
@@ -44,13 +44,29 @@ module Schemacop
44
44
  result.error "String is #{length} characters long but must be at most #{options[:max_length]}."
45
45
  end
46
46
 
47
+ # Validate encoding matches #
48
+ if options[:encoding]
49
+ allowed_encodings = Array(options[:encoding])
50
+ unless allowed_encodings.include?(super_data.encoding.name)
51
+ result.error "String has encoding #{super_data.encoding.name.inspect} but must be #{allowed_encodings.map(&:inspect).join(' or ')}."
52
+ end
53
+ end
54
+
55
+ # Validate byte sequence #
56
+ # `match?` raises an ArgumentError on an invalid byte sequence, so no
57
+ # check below may see such a string.
58
+ unless super_data.valid_encoding?
59
+ result.error "String has invalid #{super_data.encoding.name.inspect} encoding."
60
+ return
61
+ end
62
+
47
63
  # Validate pattern #
48
64
  if (pattern = options[:pattern])
49
65
  unless options[:pattern].is_a?(Regexp)
50
66
  pattern = Regexp.compile(pattern)
51
67
  end
52
68
 
53
- unless super_data.match?(pattern)
69
+ unless V3.match?(pattern, super_data)
54
70
  result.error "String does not match pattern #{V3.sanitize_exp(pattern).inspect}."
55
71
  end
56
72
  end
@@ -59,7 +75,7 @@ module Schemacop
59
75
  if options[:format] && Schemacop.string_formatters.include?(options[:format])
60
76
  pattern = Schemacop.string_formatters[options[:format]][:pattern]
61
77
 
62
- if pattern && !super_data.match?(pattern)
78
+ if pattern && !V3.match?(pattern, super_data)
63
79
  result.error "String does not match format #{options[:format].to_s.inspect}."
64
80
  elsif options[:format_options] && Node.resolve_class(options[:format])
65
81
  node = create(options[:format], **options[:format_options])
@@ -109,6 +125,18 @@ module Schemacop
109
125
  fail 'Option "min_length" can\'t be greater than "max_length".'
110
126
  end
111
127
 
128
+ if options[:encoding]
129
+ unless options[:encoding].is_a?(String) || (options[:encoding].is_a?(Array) && options[:encoding].all? { |e| e.is_a?(String) })
130
+ fail 'Option "encoding" must be a string or an array of strings.'
131
+ end
132
+
133
+ Array(options[:encoding]).each do |encoding|
134
+ Encoding.find(encoding)
135
+ rescue ArgumentError
136
+ fail "Option \"encoding\" contains unknown encoding #{encoding.inspect}."
137
+ end
138
+ end
139
+
112
140
  if options[:pattern]
113
141
  unless options[:pattern].is_a?(String) || options[:pattern].is_a?(Regexp)
114
142
  fail 'Option "pattern" must be a string or Regexp.'
data/lib/schemacop/v3.rb CHANGED
@@ -4,6 +4,38 @@ module Schemacop
4
4
  NodeRegistry.register(*args)
5
5
  end
6
6
 
7
+ # `String#blank?` matches a regular expression and therefore raises on a
8
+ # string it cannot compare: an `ArgumentError` on an invalid byte sequence,
9
+ # a `RegexpError` on a dummy encoding such as UTF-16. Neither kind of
10
+ # string is blank unless it is empty.
11
+ # @private
12
+ def self.blank?(value)
13
+ return false if invalid_encoding?(value)
14
+
15
+ return value.blank?
16
+ rescue Encoding::CompatibilityError, RegexpError
17
+ return value.empty?
18
+ end
19
+
20
+ # `Regexp#match?` raises on a string it cannot compare: an `ArgumentError`
21
+ # on an invalid byte sequence, an `Encoding::CompatibilityError` on an
22
+ # encoding incompatible with the pattern's. Neither kind of string matches.
23
+ # @private
24
+ def self.match?(pattern, value)
25
+ return false if invalid_encoding?(value)
26
+
27
+ begin
28
+ return pattern.match?(value)
29
+ rescue Encoding::CompatibilityError
30
+ return false
31
+ end
32
+ end
33
+
34
+ # @private
35
+ def self.invalid_encoding?(value)
36
+ return value.is_a?(String) && !value.valid_encoding?
37
+ end
38
+
7
39
  # @private
8
40
  def self.sanitize_exp(exp)
9
41
  return exp if exp.is_a?(String)