moxml 0.5.162 → 0.5.165

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3ff9c9a02aad66036e51b20f9dd72fb17adb09b51dbe3950bb5a5174a2f2158
4
- data.tar.gz: b4924b020e3b4738e08197d918594df3269d5206fda86ab0f582b27e801a1dbf
3
+ metadata.gz: 8dae9d144ec375224efd389406ed2cdb06c5409ae91d985e95221a972a1158ac
4
+ data.tar.gz: 308da2c1430d4f7f6b195eeec3d4ba9f651c9664f30c2a6c1381923572409fbd
5
5
  SHA512:
6
- metadata.gz: dcb4225b3f662a656232b4e4bccdaefeb261a186d9c01d5369ee54ec28829678202e69349372842e470dcd21bed702f6f28c0e58dc9e45b6c262fffab1ed937a
7
- data.tar.gz: 22fa41a31b5a8541e2c3f5fa2c8eb088975f173902ff8c046e325ec55c28246e3c459df5e49d004fb2e89ea1d1d8f9adb841d71cae8e4fe8bfaea14cd25c5d7c
6
+ metadata.gz: 794455e01d1021344dd5f792802d10403cc35a57b4fd167dee967d59c8079f2e3467a392433dff45c9959919569d2e3fc68b4f0db8535257bb646ce9f48821ea
7
+ data.tar.gz: a2e777a2b636b71432ee8baa0610254cb2b464b33843f57063d31a6688974394cfc25e8c6e91851448c97f563acd80261833c5542d5b9b962aa2dae240c8aa27
@@ -42,6 +42,7 @@ Generated by `bundle exec rake docs:coverage` from the coverage matrix and the d
42
42
  | validation | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
43
43
  | validation under-detections | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
44
44
  | validation version reporting | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
+ | validation regex dismissal | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
46
  | loud capability contracts | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
46
47
  |===
47
48
 
@@ -255,7 +255,7 @@ semantics.
255
255
 
256
256
  === Validation
257
257
 
258
- Pins:: `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
258
+ Pins:: `spec/moxml/xsd_regex_spec.rb`, `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
259
259
 
260
260
  XSD validation through engines that ship a validator (libxml2;
261
261
  leptris tier-1: recognized roots, one-level content descent, lexical
@@ -263,9 +263,22 @@ checks). `validate` returns accumulated `ValidationError` entries —
263
263
  message always; line/column/path where the engine reports them, nil
264
264
  where it does not; `[]` means valid. Documents outside the engine's
265
265
  scope validate laxly, except that strict `xs:any` /
266
- `xs:anyAttribute` wildcard matches and `xsi:nil` are re-checked from
267
- the schema source on every adapter — the re-check can only
268
- under-report, never false-report. A compiled schema reports the
266
+ `xs:anyAttribute` wildcard matches, `xsi:nil`, and the XSD 1.1
267
+ negative wildcards (`notQName` / `notNamespace` — an item excluded
268
+ by them has no particle and the content model cannot accept it) are
269
+ re-checked from the schema source on every adapter — the re-check
270
+ can only under-report, never false-report. Engine lexical findings
271
+ that the source's own `pattern` facets refute are dismissed: an
272
+ engine that cannot evaluate a dialect construct (e.g. the
273
+ `\p{Is...}` block escapes) rejects every occurrence of the facet,
274
+ so the facade re-checks the value against the facet translated to
275
+ the host regex dialect — only whitelisted constructs translate
276
+ (block escapes with Unicode-rename aliases, general categories,
277
+ single-character escapes; subtraction, `\i`, `\c` do not), an
278
+ engine finding is dropped only when every same-named instance
279
+ matches the facet, and anything untranslatable keeps the engine's
280
+ answer, so a dismissal can only remove false rejections, never
281
+ create false acceptances. A compiled schema reports the
269
282
  version its source declares: root `vc:minVersion="1.1"` scopes the
270
283
  document to 1.1, anything else is 1.0 (consumers scope 1.0-only
271
284
  checks with it; applying 1.1 semantics is the engine-side program).
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.162"
4
+ VERSION = "0.5.165"
5
5
  end
@@ -8,7 +8,11 @@ module Moxml
8
8
  #
9
9
  # * a strict `xs:any` wildcard with an undeclared element,
10
10
  # * a strict `xs:anyAttribute` with an undeclared attribute,
11
- # * `xsi:nil` on an element whose declaration is not nillable.
11
+ # * `xsi:nil` on an element whose declaration is not nillable,
12
+ # * the XSD 1.1 negative wildcards — `notQName` /
13
+ # `notNamespace` on `xs:any` / `xs:anyAttribute` (#460):
14
+ # engines that compile them without enforcing them absorb
15
+ # excluded items the content model cannot accept.
12
16
  #
13
17
  # The rules run from an index of the schema SOURCE (this gem's
14
18
  # own XML machinery — no engine involvement) and merge with the
@@ -20,22 +24,18 @@ module Moxml
20
24
  module SchemaRules
21
25
  XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
22
26
 
23
- # A wildcard's namespace attribute, per XML Schema
24
- # Structures §3.10.2 (##any default; processContents default
25
- # strict). :tns / :local are resolved against the indexed
26
- # schema at check time.
27
+ # A wildcard's namespace constraint and disallowed names, per
28
+ # XML Schema Structures §3.10.1–3.10.2 (##any default;
29
+ # processContents default strict; notNamespace / notQName are
30
+ # XSD 1.1). :tns / :local are resolved against the indexed
31
+ # schema at check time; notQName QNames are resolved against
32
+ # the schema element's in-scope bindings at index time.
27
33
  class Wildcard
28
- def initialize(value, process)
34
+ def initialize(value, process, not_namespace: nil, not_qname: nil,
35
+ element: nil)
29
36
  @process = (process || "strict").to_sym
30
- case value
31
- when nil, "##any", ""
32
- @mode = :any
33
- when "##other"
34
- @mode = :other
35
- else
36
- @mode = :list
37
- @namespaces = value.split(/\s+/).reject(&:empty?).freeze
38
- end
37
+ @mode, @namespaces = namespace_constraint(value, not_namespace)
38
+ @disallowed = disallowed_names(not_qname, element).freeze
39
39
  end
40
40
 
41
41
  def strict?
@@ -47,13 +47,89 @@ module Moxml
47
47
  when :any then true
48
48
  when :other
49
49
  namespace != :local && namespace != :tns
50
+ when :not
51
+ @namespaces.none? { |entry| entry_covers?(entry, namespace) }
50
52
  else
51
53
  @namespaces.any? { |entry| entry_covers?(entry, namespace) }
52
54
  end
53
55
  end
54
56
 
57
+ # Why the wildcard refuses an item outright (Structures
58
+ # §3.10.4.1 Item Valid): a refusal is a match-level failure,
59
+ # so it applies at every processContents — the content model
60
+ # has no particle for the item and cannot accept it. 1.0
61
+ # namespace lists stay the engine's enforcement and never
62
+ # name an exclusion here.
63
+ def negative_exclusion(qname, namespace, defined:,
64
+ sibling_names: [])
65
+ @disallowed.each do |entry|
66
+ case entry
67
+ when :defined
68
+ return :not_qname if defined
69
+ when :sibling
70
+ return :not_qname if sibling_names.include?(qname)
71
+ else
72
+ return :not_qname if entry == qname
73
+ end
74
+ end
75
+ return :not_namespace if @mode == :not &&
76
+ @namespaces.any? { |entry| entry_covers?(entry, namespace) }
77
+
78
+ nil
79
+ end
80
+
55
81
  private
56
82
 
83
+ def namespace_constraint(value, not_namespace)
84
+ if not_namespace
85
+ [:not, not_namespace.split(/\s+/).reject(&:empty?)]
86
+ elsif value.nil? || value == "##any" || value == ""
87
+ [:any, []]
88
+ elsif value == "##other"
89
+ [:other, []]
90
+ else
91
+ [:list, value.split(/\s+/).reject(&:empty?)]
92
+ end
93
+ end
94
+
95
+ # notQName entries (§3.10.2): QName | ##defined |
96
+ # ##definedSibling (xs:any; ##defined only on
97
+ # xs:anyAttribute). Prefixed QNames resolve against the
98
+ # wildcard element's in-scope bindings; unprefixed QNames
99
+ # carry no namespace — XSD QName values take no default
100
+ # namespace. Unresolvable prefixes are dropped: the index
101
+ # under-reports, never false-reports.
102
+ def disallowed_names(not_qname, element)
103
+ not_qname.to_s.split(/\s+/).reject(&:empty?).filter_map do |token|
104
+ case token
105
+ when "##defined" then :defined
106
+ when "##definedSibling" then :sibling
107
+ when /\A([^:]+):(.+)\z/
108
+ uri = prefix_uri(Regexp.last_match(1), element)
109
+ "{#{uri}}#{Regexp.last_match(2)}" unless uri.empty?
110
+ else
111
+ "{}#{token}"
112
+ end
113
+ end
114
+ end
115
+
116
+ # node.namespaces is adapter-divergent (some engines return
117
+ # in-scope bindings, others own-node declarations only), so
118
+ # the in-scope map is walked out by hand — per-node
119
+ # definitions merged along the ancestor axis, deeper
120
+ # overriding shallower, identically on every adapter.
121
+ def prefix_uri(prefix, element)
122
+ bindings = {}
123
+ node = element
124
+ while node
125
+ node.namespaces.each do |namespace|
126
+ bindings[namespace.prefix] = namespace.uri
127
+ end
128
+ node = node.parent&.element? ? node.parent : nil
129
+ end
130
+ bindings[prefix].to_s
131
+ end
132
+
57
133
  def entry_covers?(entry, namespace)
58
134
  namespace == case entry
59
135
  when "##targetNamespace" then :tns
@@ -67,12 +143,94 @@ module Moxml
67
143
  :declared_attributes, :any_attribute)
68
144
  SchemaIndex = Struct.new(:target_namespace, :element_default,
69
145
  :attribute_default, :elements,
70
- :attributes, :contents, :version)
146
+ :attributes, :contents, :version,
147
+ :element_patterns, :attribute_patterns)
71
148
 
72
149
  # The XSD 1.1 version-tagging namespace (Structures §C.3):
73
150
  # vc:minVersion / vc:maxVersion gate 1.1 constructs.
74
151
  VC_NS = "http://www.w3.org/2001/XMLSchema-versioning"
75
152
 
153
+ # XSD pattern-facet dialect → Ruby regex, bounded to the
154
+ # subset the facade vouches for (Datatypes Appendix F). Block
155
+ # escapes \p{Is…}/\P{Is…} map to Ruby's In-prefixed blocks
156
+ # (a few Unicode-renamed blocks carry an alias); general
157
+ # categories and the single-character escapes pass through;
158
+ # \i, \c and class subtraction are NOT translated. Anything
159
+ # unrecognized — or a translation Ruby itself rejects —
160
+ # returns nil: the engine's answer then stands.
161
+ module XsdRegex
162
+ BLOCK_ALIASES = {
163
+ "Greek" => "GreekandCoptic",
164
+ "CombiningMarksforSymbols" =>
165
+ "CombiningDiacriticalMarksforSymbols",
166
+ "Turkish" => "Latin-1Supplement",
167
+ }.freeze
168
+
169
+ PASS_THROUGH_ESCAPES = %w[
170
+ n r t \\ . | ? * + ( ) [ ] { } ^ $ -
171
+ ].freeze
172
+
173
+ module_function
174
+
175
+ def translate(pattern)
176
+ translated = scan(pattern.to_s)
177
+ return nil unless translated
178
+
179
+ Regexp.new("\\A(?:#{translated})\\z")
180
+ rescue RegexpError
181
+ nil
182
+ end
183
+
184
+ def scan(pattern)
185
+ out = +""
186
+ index = 0
187
+ while index < pattern.length
188
+ char = pattern[index]
189
+ if char == "\\"
190
+ consumed = escape(pattern, index, out)
191
+ return nil unless consumed
192
+
193
+ index += consumed
194
+ else
195
+ return nil if char == "-" && pattern[index + 1] == "["
196
+
197
+ out << char
198
+ index += 1
199
+ end
200
+ end
201
+ out
202
+ end
203
+
204
+ # Appends the translation of the escape at index to out and
205
+ # returns the chars consumed, or nil when unrecognized.
206
+ def escape(pattern, index, out)
207
+ kind = pattern[index + 1]
208
+ case kind
209
+ when "p", "P"
210
+ close = pattern.index("}", index)
211
+ return nil unless close
212
+
213
+ body = pattern[index + 3, close - index - 3]
214
+ if body&.start_with?("Is")
215
+ mapped = body.delete_prefix("Is")
216
+ mapped = BLOCK_ALIASES.fetch(mapped, mapped)
217
+ begin
218
+ Regexp.new("\\p{In#{mapped}}")
219
+ rescue RegexpError
220
+ return nil
221
+ end
222
+ out << "\\#{kind}{In#{mapped}}"
223
+ else
224
+ out << pattern[index..close]
225
+ end
226
+ close - index + 1
227
+ when *PASS_THROUGH_ESCAPES
228
+ out << pattern[index, 2]
229
+ 2
230
+ end
231
+ end
232
+ end
233
+
76
234
  class << self
77
235
  # Index one schema document. Returns nil when the source
78
236
  # cannot be indexed — the engine's answer then stands alone
@@ -85,7 +243,7 @@ module Moxml
85
243
  root["targetNamespace"].to_s,
86
244
  root["elementFormDefault"] == "qualified",
87
245
  root["attributeFormDefault"] == "qualified",
88
- {}, {}, {}, schema_version(root)
246
+ {}, {}, {}, schema_version(root), {}, {}
89
247
  )
90
248
  child_elements(root).each do |decl|
91
249
  case decl.name
@@ -93,6 +251,7 @@ module Moxml
93
251
  when "attribute" then index_global_attribute(decl, index)
94
252
  end
95
253
  end
254
+ index_patterns(root, index)
96
255
  index
97
256
  rescue StandardError
98
257
  nil
@@ -112,6 +271,142 @@ module Moxml
112
271
  declared.to_s.start_with?("1.1") ? "1.1" : "1.0"
113
272
  end
114
273
 
274
+ # Index the pattern facets the dismissal may vouch with
275
+ # (#464): named simpleTypes resolve through one type-ref
276
+ # hop, inline simpleTypes directly; keys are local names.
277
+ # A name declared twice with different pattern sets is
278
+ # ambiguous for name-based matching and is removed — an
279
+ # ambiguous vouch could mask a real violation.
280
+ def index_patterns(root, index)
281
+ named = {}
282
+ collect_named_patterns(root, named)
283
+ collect_decl_patterns(root, index, named)
284
+ end
285
+
286
+ def collect_named_patterns(node, named)
287
+ child_elements(node).each do |child|
288
+ if child.name == "simpleType" && child["name"] &&
289
+ (patterns = restriction_patterns(child))
290
+ named[child["name"]] = patterns
291
+ else
292
+ collect_named_patterns(child, named)
293
+ end
294
+ end
295
+ end
296
+
297
+ def collect_decl_patterns(node, index, named)
298
+ child_elements(node).each do |child|
299
+ case child.name
300
+ when "element"
301
+ register_pattern(index.element_patterns, child["name"],
302
+ decl_patterns(child, named))
303
+ when "attribute"
304
+ register_pattern(index.attribute_patterns, child["name"],
305
+ decl_patterns(child, named))
306
+ end
307
+ collect_decl_patterns(child, index, named)
308
+ end
309
+ end
310
+
311
+ def decl_patterns(decl, named)
312
+ inline = child_elements(decl).find do |child|
313
+ child.name == "simpleType"
314
+ end
315
+ if inline
316
+ restriction_patterns(inline)
317
+ elsif decl["type"]
318
+ named[decl["type"].split(":").last]
319
+ end
320
+ end
321
+
322
+ def restriction_patterns(simple_type)
323
+ restriction = child_elements(simple_type).find do |child|
324
+ child.name == "restriction"
325
+ end
326
+ return nil unless restriction
327
+
328
+ patterns = child_elements(restriction)
329
+ .select { |facet| facet.name == "pattern" }
330
+ .map { |facet| XsdRegex.translate(facet["value"].to_s) }
331
+ return nil if patterns.any?(&:nil?)
332
+
333
+ patterns
334
+ end
335
+
336
+ def register_pattern(sink, name, patterns)
337
+ return unless name && patterns && !patterns.empty?
338
+
339
+ sink[name] = sink.key?(name) ? nil : patterns
340
+ end
341
+
342
+ # Dismisses engine lexical findings the source's own
343
+ # pattern facets refute (#464): an engine that cannot
344
+ # evaluate a dialect construct rejects every occurrence of
345
+ # the facet — including values the facet accepts. A
346
+ # finding is dropped only when every same-named instance
347
+ # in the document matches; one non-matching value keeps
348
+ # the engine's answer, so a dismissal can only remove
349
+ # false rejections, never create false acceptances.
350
+ def dismiss(engine_errors, doc, index)
351
+ return engine_errors unless index
352
+
353
+ vouched = vouch_table(doc, index)
354
+ engine_errors.reject do |error|
355
+ subject = lexical_subject(error.message)
356
+ subject && vouched[subject]
357
+ end
358
+ end
359
+
360
+ LEXICAL_MESSAGE = /\A(?:element|attribute) '([^']+)' @\d+:\d+: (element|attribute) '([^']+)': lexical value does not match its type\z/
361
+
362
+ private_constant :LEXICAL_MESSAGE
363
+
364
+ def lexical_subject(message)
365
+ match = LEXICAL_MESSAGE.match(message.to_s)
366
+ return unless match
367
+
368
+ [match[2], match[3]]
369
+ end
370
+
371
+ def vouch_table(doc, index)
372
+ root = doc.root
373
+ return {} unless root
374
+
375
+ elements = Hash.new { |hash, key| hash[key] = [] }
376
+ attributes = Hash.new { |hash, key| hash[key] = [] }
377
+ collect_witnesses(root, index, elements, attributes)
378
+ vouched = {}
379
+ elements.each do |name, matches|
380
+ vouched[["element", name]] = !matches.empty? && matches.all?
381
+ end
382
+ attributes.each do |name, matches|
383
+ vouched[["attribute", name]] = !matches.empty? && matches.all?
384
+ end
385
+ vouched
386
+ end
387
+
388
+ def collect_witnesses(node, index, elements, attributes)
389
+ child_elements(node).each do |child|
390
+ if (patterns = index.element_patterns[child.name])
391
+ elements[child.name] << (text_only?(child) &&
392
+ patterns.any? { |regex| regex.match?(child.text.to_s) })
393
+ end
394
+ child.attributes.each do |attribute|
395
+ next if attribute.namespace&.uri == XSI_NS
396
+
397
+ if (patterns = index.attribute_patterns[attribute.name])
398
+ attributes[attribute.name] <<
399
+ patterns.any? { |regex| regex.match?(attribute.value.to_s) }
400
+ end
401
+ end
402
+ collect_witnesses(child, index, elements, attributes)
403
+ end
404
+ end
405
+
406
+ def text_only?(element)
407
+ child_elements(element).empty?
408
+ end
409
+
115
410
  # Post-validation findings for one instance document.
116
411
  def findings(doc, index)
117
412
  return [] unless index
@@ -193,12 +488,9 @@ module Moxml
193
488
  add_particle(child, index, model,
194
489
  model.declared_attributes)
195
490
  when "any"
196
- model.any ||= Wildcard.new(child["namespace"],
197
- child["processContents"])
491
+ model.any ||= wildcard_from(child)
198
492
  when "anyAttribute"
199
- model.any_attribute ||=
200
- Wildcard.new(child["namespace"],
201
- child["processContents"])
493
+ model.any_attribute ||= wildcard_from(child)
202
494
  else
203
495
  collect_model(child, index, model)
204
496
  end
@@ -234,19 +526,25 @@ module Moxml
234
526
  end
235
527
  end
236
528
 
529
+ def wildcard_from(node)
530
+ Wildcard.new(
531
+ node["namespace"], node["processContents"],
532
+ not_namespace: node["notNamespace"],
533
+ not_qname: node["notQName"],
534
+ element: node
535
+ )
536
+ end
537
+
237
538
  def child_findings(child, model, index)
238
539
  qname = qname(child)
239
540
  findings = []
240
541
  if model.declared_children.include?(qname)
241
542
  declaration = index.elements[qname]
242
543
  findings << nil_finding(child, declaration) if declaration
243
- elsif undeclared_strict_wildcard_match?(child, model, index,
244
- qname)
245
- findings << Moxml::XSD::ValidationError.new(
246
- message: "element <#{local(qname)}> matches a strict " \
247
- "wildcard but is not declared",
248
- path: path_of(child),
249
- )
544
+ elsif model.any &&
545
+ (finding = wildcard_child_finding(child, model, index,
546
+ qname))
547
+ findings << finding
250
548
  end
251
549
  findings.concat(
252
550
  attribute_findings(child, index.contents[qname], index),
@@ -254,13 +552,34 @@ module Moxml
254
552
  findings
255
553
  end
256
554
 
257
- def undeclared_strict_wildcard_match?(_child, model, index, qname)
555
+ def wildcard_child_finding(child, model, index, qname)
258
556
  wildcard = model.any
259
- return false unless wildcard&.strict?
260
- return false if index.elements.key?(qname)
261
-
262
557
  namespace = resolve_namespace(qname, index)
263
- wildcard.covers?(namespace) && !index.elements.key?(qname)
558
+ exclusion = wildcard.negative_exclusion(
559
+ qname, namespace,
560
+ defined: index.elements.key?(qname),
561
+ sibling_names: model.declared_children
562
+ )
563
+ return excluded_child_finding(child, qname, exclusion) if exclusion
564
+
565
+ return unless wildcard.covers?(namespace) && wildcard.strict?
566
+ return if index.elements.key?(qname)
567
+
568
+ Moxml::XSD::ValidationError.new(
569
+ message: "element <#{local(qname)}> matches a strict " \
570
+ "wildcard but is not declared",
571
+ path: path_of(child),
572
+ )
573
+ end
574
+
575
+ def excluded_child_finding(child, qname, exclusion)
576
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
577
+ Moxml::XSD::ValidationError.new(
578
+ message: "element <#{local(qname)}> is disallowed by a " \
579
+ "wildcard (#{reason}) and is not declared in the " \
580
+ "content model",
581
+ path: path_of(child),
582
+ )
264
583
  end
265
584
 
266
585
  def attribute_findings(element, model, index)
@@ -271,14 +590,19 @@ module Moxml
271
590
  next if attribute.namespace&.uri == XSI_NS
272
591
 
273
592
  qname = attribute_qname(attribute)
274
- declared = model.declared_attributes.include?(qname) ||
275
- index.attributes.key?(qname)
276
- next if declared
277
- next unless any_attribute&.strict?
278
- next if index.attributes.key?(qname)
279
- next unless any_attribute.covers?(
280
- resolve_namespace(qname, index),
593
+ next if model.declared_attributes.include?(qname)
594
+ next unless any_attribute
595
+
596
+ namespace = resolve_namespace(qname, index)
597
+ exclusion = any_attribute.negative_exclusion(
598
+ qname, namespace,
599
+ defined: index.attributes.key?(qname)
281
600
  )
601
+ next excluded_attribute_finding(element, qname, exclusion) if exclusion
602
+
603
+ next unless any_attribute.covers?(namespace)
604
+ next unless any_attribute.strict?
605
+ next if index.attributes.key?(qname)
282
606
 
283
607
  Moxml::XSD::ValidationError.new(
284
608
  message: "attribute #{local(qname)} matches a strict " \
@@ -288,6 +612,16 @@ module Moxml
288
612
  end
289
613
  end
290
614
 
615
+ def excluded_attribute_finding(element, qname, exclusion)
616
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
617
+ Moxml::XSD::ValidationError.new(
618
+ message: "attribute #{local(qname)} is disallowed by a " \
619
+ "wildcard (#{reason}) and is not declared in the " \
620
+ "content model",
621
+ path: path_of(element),
622
+ )
623
+ end
624
+
291
625
  def nil_finding(element, declaration)
292
626
  return if declaration[:nillable]
293
627
 
data/lib/moxml/xsd.rb CHANGED
@@ -157,9 +157,11 @@ module Moxml
157
157
  return engine_errors unless @rules_index
158
158
 
159
159
  # The #452 post-validation rules only ever add findings for
160
- # documents the engine accepted; exact duplicates are
161
- # dropped for engines that already detect them.
162
- (engine_errors +
160
+ # documents the engine accepted; the #464 dismissal removes
161
+ # only engine lexical findings the source's own pattern
162
+ # facets refute. Exact duplicates are dropped for engines
163
+ # that already detect them.
164
+ (SchemaRules.dismiss(engine_errors, doc, @rules_index) +
163
165
  SchemaRules.findings(doc, @rules_index)).uniq
164
166
  end
165
167
 
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Processing model §Validation (normative): pinned here.
6
+ # #464: an engine that cannot evaluate an XSD pattern-facet
7
+ # dialect construct (e.g. the \p{Is...} block escapes) rejects
8
+ # every occurrence of the facet — including values the facet
9
+ # accepts — false-positiving valid documents. The Ruby-side
10
+ # SchemaRules dismissal removes such engine findings only when the
11
+ # schema source's own pattern facets vouch for every same-named
12
+ # instance: one non-matching value keeps the engine's answer, so
13
+ # a dismissal can only remove false rejections, never create
14
+ # false acceptances. Constructs outside the translated dialect
15
+ # subset keep the engine's answer.
16
+ RSpec.describe "Moxml::XSD pattern-facet dismissal (#464)" do
17
+ describe "bounded dialect translator" do
18
+ let(:translator) { Moxml::XSD::SchemaRules::XsdRegex }
19
+
20
+ it "translates block escapes and categories" do
21
+ expect(translator.translate('\p{IsBasicLatin}+').source)
22
+ .to eq("\\A(?:\\p{InBasicLatin}+)\\z")
23
+ expect(translator.translate('\P{IsLatin-1Supplement}').source)
24
+ .to eq("\\A(?:\\P{InLatin-1Supplement})\\z")
25
+ expect(translator.translate('\p{IsGreek}').source)
26
+ .to eq("\\A(?:\\p{InGreekandCoptic})\\z")
27
+ expect(translator.translate('\p{L}').source)
28
+ .to eq("\\A(?:\\p{L})\\z")
29
+ end
30
+
31
+ it "refuses constructs outside the translated subset" do
32
+ expect(translator.translate('\i\c*')).to be_nil
33
+ expect(translator.translate('[\p{IsBasicLatin}-[abc]]+'))
34
+ .to be_nil
35
+ expect(translator.translate('\p{IsNoSuchBlock}')).to be_nil
36
+ end
37
+ end
38
+
39
+ %i[nokogiri leptris].each do |adapter_name|
40
+ describe "on #{adapter_name}" do
41
+ let(:ctx) { Moxml.new(adapter_name) }
42
+
43
+ before do
44
+ skip "#{adapter_name} requires an XSD-capable engine" unless
45
+ Moxml::XSD.supported?(ctx)
46
+ end
47
+
48
+ describe "block-escape pattern on an element" do
49
+ let(:schema) do
50
+ ctx.xsd(<<~'XSD')
51
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
52
+ <xs:element name="doc">
53
+ <xs:complexType>
54
+ <xs:sequence>
55
+ <xs:element name="elem" type="regex"
56
+ minOccurs="1" maxOccurs="unbounded"/>
57
+ </xs:sequence>
58
+ </xs:complexType>
59
+ </xs:element>
60
+ <xs:simpleType name="regex">
61
+ <xs:restriction base="xs:string">
62
+ <xs:pattern value="\p{IsBasicLatin}+"/>
63
+ </xs:restriction>
64
+ </xs:simpleType>
65
+ </xs:schema>
66
+ XSD
67
+ end
68
+
69
+ it "accepts the value the facet accepts" do
70
+ doc = ctx.parse(
71
+ "<doc><elem>&#x9;&#xA;&#xD;&#x20;&#x7F;</elem></doc>",
72
+ )
73
+ expect(schema.valid?(doc)).to be(true)
74
+ end
75
+
76
+ it "rejects the value the facet rejects" do
77
+ doc = ctx.parse(
78
+ "<doc><elem>abc</elem><elem>ab&#233;c</elem></doc>",
79
+ )
80
+ expect(schema.valid?(doc)).to be(false)
81
+ end
82
+ end
83
+
84
+ describe "negated block escape" do
85
+ let(:schema) do
86
+ ctx.xsd(<<~'XSD')
87
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
88
+ <xs:element name="doc">
89
+ <xs:complexType>
90
+ <xs:sequence>
91
+ <xs:element name="elem" type="nonbasic"
92
+ minOccurs="1"/>
93
+ </xs:sequence>
94
+ </xs:complexType>
95
+ </xs:element>
96
+ <xs:simpleType name="nonbasic">
97
+ <xs:restriction base="xs:string">
98
+ <xs:pattern value="\P{IsBasicLatin}"/>
99
+ </xs:restriction>
100
+ </xs:simpleType>
101
+ </xs:schema>
102
+ XSD
103
+ end
104
+
105
+ it "accepts a value outside the negated block" do
106
+ doc = ctx.parse("<doc><elem>&#233;</elem></doc>")
107
+ expect(schema.valid?(doc)).to be(true)
108
+ end
109
+ end
110
+
111
+ describe "block-escape pattern on an attribute" do
112
+ let(:schema) do
113
+ ctx.xsd(<<~'XSD')
114
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
115
+ <xs:element name="doc">
116
+ <xs:complexType>
117
+ <xs:sequence>
118
+ <xs:element name="elem" minOccurs="1"
119
+ maxOccurs="unbounded">
120
+ <xs:complexType>
121
+ <xs:simpleContent>
122
+ <xs:extension base="xs:string">
123
+ <xs:attribute name="code">
124
+ <xs:simpleType>
125
+ <xs:restriction base="xs:string">
126
+ <xs:pattern
127
+ value="\p{IsBasicLatin}{2}"/>
128
+ </xs:restriction>
129
+ </xs:simpleType>
130
+ </xs:attribute>
131
+ </xs:extension>
132
+ </xs:simpleContent>
133
+ </xs:complexType>
134
+ </xs:element>
135
+ </xs:sequence>
136
+ </xs:complexType>
137
+ </xs:element>
138
+ </xs:schema>
139
+ XSD
140
+ end
141
+
142
+ it "accepts when every instance matches the facet" do
143
+ doc = ctx.parse(
144
+ '<doc><elem code="ab">x</elem><elem code="z9">y</elem></doc>',
145
+ )
146
+ expect(schema.valid?(doc)).to be(true)
147
+ end
148
+
149
+ it "rejects when one instance mismatches" do
150
+ doc = ctx.parse(
151
+ '<doc><elem code="ab">x</elem><elem code="1">y</elem></doc>',
152
+ )
153
+ expect(schema.valid?(doc)).to be(false)
154
+ end
155
+ end
156
+
157
+ describe "class subtraction rides the engine unchanged" do
158
+ let(:schema) do
159
+ ctx.xsd(<<~'XSD')
160
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
161
+ <xs:element name="doc">
162
+ <xs:complexType>
163
+ <xs:sequence>
164
+ <xs:element name="elem" type="latinish"
165
+ minOccurs="1"/>
166
+ </xs:sequence>
167
+ </xs:complexType>
168
+ </xs:element>
169
+ <xs:simpleType name="latinish">
170
+ <xs:restriction base="xs:string">
171
+ <xs:pattern value="[\p{IsBasicLatin}-[abc]]+"/>
172
+ </xs:restriction>
173
+ </xs:simpleType>
174
+ </xs:schema>
175
+ XSD
176
+ end
177
+
178
+ it "keeps the engine's answer" do
179
+ # The translation refuses subtraction, so the dismissal
180
+ # never fires: both engines answer per their own
181
+ # dialect coverage, unmodified by the facade.
182
+ doc = ctx.parse("<doc><elem>xyz</elem></doc>")
183
+ expect(schema.valid?(doc)).to be(true)
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -4,11 +4,11 @@ require "spec_helper"
4
4
 
5
5
  # Processing model §Validation (normative): pinned here.
6
6
  # #452: tier-1 engines can accept, inside their recognized scope,
7
- # instances that violate strict wildcards or xsi:nil. The
8
- # Ruby-side SchemaRules post-validation closes those gaps from the
9
- # schema source; findings merge with the engine's (duplicates
10
- # dropped), so every adapter answers invalid for the four
11
- # corpus shapes.
7
+ # instances that violate strict wildcards or xsi:nil. #460: engines
8
+ # may compile the 1.1 negative wildcards without enforcing them.
9
+ # The Ruby-side SchemaRules post-validation closes those gaps from
10
+ # the schema source; findings merge with the engine's (duplicates
11
+ # dropped), so every adapter answers invalid for the corpus shapes.
12
12
  RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
13
13
  %i[nokogiri leptris].each do |adapter_name|
14
14
  describe "on #{adapter_name}" do
@@ -152,6 +152,172 @@ RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
152
152
  end
153
153
  end
154
154
 
155
+ describe "negative wildcards (XSD 1.1, #460)" do
156
+ before do
157
+ if adapter_name == :nokogiri
158
+ skip "#{adapter_name} requires an XSD 1.1 wildcard-capable " \
159
+ "engine"
160
+ end
161
+ end
162
+
163
+ describe "xs:any notQName naming an excluded element" do
164
+ let(:schema) do
165
+ ctx.xsd(<<~XSD)
166
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
167
+ xmlns:w="http://example.com/w"
168
+ targetNamespace="http://example.com/w"
169
+ elementFormDefault="qualified">
170
+ <xs:element name="container">
171
+ <xs:complexType>
172
+ <xs:sequence>
173
+ <xs:any processContents="strict"
174
+ notQName="w:banned" minOccurs="0"
175
+ maxOccurs="unbounded"/>
176
+ </xs:sequence>
177
+ </xs:complexType>
178
+ </xs:element>
179
+ <xs:element name="banned" type="xs:string"/>
180
+ <xs:element name="known" type="xs:string"/>
181
+ </xs:schema>
182
+ XSD
183
+ end
184
+
185
+ it "rejects the excluded element although it is declared" do
186
+ doc = ctx.parse(
187
+ '<container xmlns="http://example.com/w"><banned>x</banned></container>',
188
+ )
189
+ expect(schema.valid?(doc)).to be(false)
190
+ expect(schema.validate(doc).map(&:message)).to include(
191
+ /disallowed by a wildcard \(notQName\)/,
192
+ )
193
+ end
194
+
195
+ it "accepts a wildcard-matched declared element" do
196
+ doc = ctx.parse(
197
+ '<container xmlns="http://example.com/w"><known>x</known></container>',
198
+ )
199
+ expect(schema.valid?(doc)).to be(true)
200
+ end
201
+ end
202
+
203
+ describe "xs:any notQName=##defined" do
204
+ let(:schema) do
205
+ ctx.xsd(<<~XSD)
206
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
207
+ targetNamespace="http://example.com/w"
208
+ elementFormDefault="qualified">
209
+ <xs:element name="container">
210
+ <xs:complexType>
211
+ <xs:sequence>
212
+ <xs:any processContents="lax"
213
+ notQName="##defined" minOccurs="0"
214
+ maxOccurs="unbounded"/>
215
+ </xs:sequence>
216
+ </xs:complexType>
217
+ </xs:element>
218
+ <xs:element name="known" type="xs:string"/>
219
+ </xs:schema>
220
+ XSD
221
+ end
222
+
223
+ it "rejects a declared element the ##defined wildcard excludes" do
224
+ doc = ctx.parse(
225
+ '<container xmlns="http://example.com/w"><known>x</known></container>',
226
+ )
227
+ expect(schema.valid?(doc)).to be(false)
228
+ expect(schema.validate(doc).map(&:message)).to include(
229
+ /disallowed by a wildcard \(notQName\)/,
230
+ )
231
+ end
232
+
233
+ it "absorbs an undeclared element laxly" do
234
+ doc = ctx.parse(
235
+ '<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
236
+ )
237
+ expect(schema.valid?(doc)).to be(true)
238
+ end
239
+ end
240
+
241
+ describe "xs:any notNamespace" do
242
+ let(:schema) do
243
+ ctx.xsd(<<~XSD)
244
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
245
+ targetNamespace="http://example.com/w"
246
+ elementFormDefault="qualified">
247
+ <xs:element name="container">
248
+ <xs:complexType>
249
+ <xs:sequence>
250
+ <xs:any processContents="lax"
251
+ notNamespace="##targetNamespace"
252
+ minOccurs="0" maxOccurs="unbounded"/>
253
+ </xs:sequence>
254
+ </xs:complexType>
255
+ </xs:element>
256
+ </xs:schema>
257
+ XSD
258
+ end
259
+
260
+ it "rejects an element in the disallowed namespace" do
261
+ doc = ctx.parse(
262
+ '<container xmlns="http://example.com/w"><stray/></container>',
263
+ )
264
+ expect(schema.valid?(doc)).to be(false)
265
+ expect(schema.validate(doc).map(&:message)).to include(
266
+ /disallowed by a wildcard \(notNamespace\)/,
267
+ )
268
+ end
269
+
270
+ it "absorbs an element from an allowed namespace" do
271
+ doc = ctx.parse(
272
+ '<container xmlns="http://example.com/w">' \
273
+ '<stray xmlns="http://example.com/other"/></container>',
274
+ )
275
+ expect(schema.valid?(doc)).to be(true)
276
+ end
277
+ end
278
+
279
+ describe "xs:anyAttribute notQName=##defined" do
280
+ let(:schema) do
281
+ ctx.xsd(<<~XSD)
282
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
283
+ targetNamespace="http://example.com/w"
284
+ elementFormDefault="qualified"
285
+ attributeFormDefault="qualified">
286
+ <xs:element name="settings">
287
+ <xs:complexType>
288
+ <xs:complexContent>
289
+ <xs:restriction base="xs:anyType">
290
+ <xs:anyAttribute processContents="lax"
291
+ notQName="##defined"/>
292
+ </xs:restriction>
293
+ </xs:complexContent>
294
+ </xs:complexType>
295
+ </xs:element>
296
+ <xs:attribute name="marked" type="xs:string"/>
297
+ </xs:schema>
298
+ XSD
299
+ end
300
+
301
+ it "rejects a declared attribute the ##defined wildcard excludes" do
302
+ doc = ctx.parse(
303
+ '<settings xmlns="http://example.com/w" ' \
304
+ 'xmlns:w="http://example.com/w" w:marked="x"/>',
305
+ )
306
+ expect(schema.valid?(doc)).to be(false)
307
+ expect(schema.validate(doc).map(&:message)).to include(
308
+ /attribute marked is disallowed by a wildcard \(notQName\)/,
309
+ )
310
+ end
311
+
312
+ it "absorbs an undeclared attribute laxly" do
313
+ doc = ctx.parse(
314
+ '<settings xmlns="http://example.com/w" free="x"/>',
315
+ )
316
+ expect(schema.valid?(doc)).to be(true)
317
+ end
318
+ end
319
+ end
320
+
155
321
  describe "wrong-namespace root" do
156
322
  let(:schema) do
157
323
  ctx.xsd(<<~XSD)
@@ -89,6 +89,10 @@ module ModelCoverage
89
89
  section: "Validation",
90
90
  pin: "spec/moxml/xsd_version_spec.rb",
91
91
  },
92
+ "validation regex dismissal" => {
93
+ section: "Validation",
94
+ pin: "spec/moxml/xsd_regex_spec.rb",
95
+ },
92
96
  "loud capability contracts" => {
93
97
  section: "Errors and loud degradation",
94
98
  pin: "spec/consistency/loud_degradation_spec.rb",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.162
4
+ version: 0.5.165
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API
@@ -631,6 +631,7 @@ files:
631
631
  - spec/moxml/xpath_spec.rb
632
632
  - spec/moxml/xsd_error_contract_spec.rb
633
633
  - spec/moxml/xsd_handle_mismatch_spec.rb
634
+ - spec/moxml/xsd_regex_spec.rb
634
635
  - spec/moxml/xsd_release_spec.rb
635
636
  - spec/moxml/xsd_spec.rb
636
637
  - spec/moxml/xsd_underdetection_spec.rb