moxml 0.5.162 → 0.5.163

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: 66e9a76f3fb1a9a8e41d39d3f49c6ea97536a1d08a7aaf145169eb2ca8b2e72e
4
+ data.tar.gz: 5190677a144235ce163990b3cd3f7f6ef2a123ef4e02b1b81626e2467d74bcaf
5
5
  SHA512:
6
- metadata.gz: dcb4225b3f662a656232b4e4bccdaefeb261a186d9c01d5369ee54ec28829678202e69349372842e470dcd21bed702f6f28c0e58dc9e45b6c262fffab1ed937a
7
- data.tar.gz: 22fa41a31b5a8541e2c3f5fa2c8eb088975f173902ff8c046e325ec55c28246e3c459df5e49d004fb2e89ea1d1d8f9adb841d71cae8e4fe8bfaea14cd25c5d7c
6
+ metadata.gz: 8dae5f5b85c572bf5f31efff0dcd1694854a5fffcf03feb066997136b53a44e2423a8208c08944c2159cd28ecdf83954cfe9856ed8354597d8ae8a664d7c434f
7
+ data.tar.gz: 0347c21ed0aecc388117e2c69632f9be715f0f1a073b5ac1dfad06fc8a0760cf07d652fc7cb39b0641ff5358b8a0d075f22de71befa72bdd687e24bf9ea1f2b4
@@ -263,9 +263,11 @@ 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. A compiled schema reports the
269
271
  version its source declares: root `vc:minVersion="1.1"` scopes the
270
272
  document to 1.1, anything else is 1.0 (consumers scope 1.0-only
271
273
  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.163"
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
@@ -193,12 +269,9 @@ module Moxml
193
269
  add_particle(child, index, model,
194
270
  model.declared_attributes)
195
271
  when "any"
196
- model.any ||= Wildcard.new(child["namespace"],
197
- child["processContents"])
272
+ model.any ||= wildcard_from(child)
198
273
  when "anyAttribute"
199
- model.any_attribute ||=
200
- Wildcard.new(child["namespace"],
201
- child["processContents"])
274
+ model.any_attribute ||= wildcard_from(child)
202
275
  else
203
276
  collect_model(child, index, model)
204
277
  end
@@ -234,19 +307,25 @@ module Moxml
234
307
  end
235
308
  end
236
309
 
310
+ def wildcard_from(node)
311
+ Wildcard.new(
312
+ node["namespace"], node["processContents"],
313
+ not_namespace: node["notNamespace"],
314
+ not_qname: node["notQName"],
315
+ element: node
316
+ )
317
+ end
318
+
237
319
  def child_findings(child, model, index)
238
320
  qname = qname(child)
239
321
  findings = []
240
322
  if model.declared_children.include?(qname)
241
323
  declaration = index.elements[qname]
242
324
  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
- )
325
+ elsif model.any &&
326
+ (finding = wildcard_child_finding(child, model, index,
327
+ qname))
328
+ findings << finding
250
329
  end
251
330
  findings.concat(
252
331
  attribute_findings(child, index.contents[qname], index),
@@ -254,13 +333,34 @@ module Moxml
254
333
  findings
255
334
  end
256
335
 
257
- def undeclared_strict_wildcard_match?(_child, model, index, qname)
336
+ def wildcard_child_finding(child, model, index, qname)
258
337
  wildcard = model.any
259
- return false unless wildcard&.strict?
260
- return false if index.elements.key?(qname)
261
-
262
338
  namespace = resolve_namespace(qname, index)
263
- wildcard.covers?(namespace) && !index.elements.key?(qname)
339
+ exclusion = wildcard.negative_exclusion(
340
+ qname, namespace,
341
+ defined: index.elements.key?(qname),
342
+ sibling_names: model.declared_children
343
+ )
344
+ return excluded_child_finding(child, qname, exclusion) if exclusion
345
+
346
+ return unless wildcard.covers?(namespace) && wildcard.strict?
347
+ return if index.elements.key?(qname)
348
+
349
+ Moxml::XSD::ValidationError.new(
350
+ message: "element <#{local(qname)}> matches a strict " \
351
+ "wildcard but is not declared",
352
+ path: path_of(child),
353
+ )
354
+ end
355
+
356
+ def excluded_child_finding(child, qname, exclusion)
357
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
358
+ Moxml::XSD::ValidationError.new(
359
+ message: "element <#{local(qname)}> is disallowed by a " \
360
+ "wildcard (#{reason}) and is not declared in the " \
361
+ "content model",
362
+ path: path_of(child),
363
+ )
264
364
  end
265
365
 
266
366
  def attribute_findings(element, model, index)
@@ -271,14 +371,19 @@ module Moxml
271
371
  next if attribute.namespace&.uri == XSI_NS
272
372
 
273
373
  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),
374
+ next if model.declared_attributes.include?(qname)
375
+ next unless any_attribute
376
+
377
+ namespace = resolve_namespace(qname, index)
378
+ exclusion = any_attribute.negative_exclusion(
379
+ qname, namespace,
380
+ defined: index.attributes.key?(qname)
281
381
  )
382
+ next excluded_attribute_finding(element, qname, exclusion) if exclusion
383
+
384
+ next unless any_attribute.covers?(namespace)
385
+ next unless any_attribute.strict?
386
+ next if index.attributes.key?(qname)
282
387
 
283
388
  Moxml::XSD::ValidationError.new(
284
389
  message: "attribute #{local(qname)} matches a strict " \
@@ -288,6 +393,16 @@ module Moxml
288
393
  end
289
394
  end
290
395
 
396
+ def excluded_attribute_finding(element, qname, exclusion)
397
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
398
+ Moxml::XSD::ValidationError.new(
399
+ message: "attribute #{local(qname)} is disallowed by a " \
400
+ "wildcard (#{reason}) and is not declared in the " \
401
+ "content model",
402
+ path: path_of(element),
403
+ )
404
+ end
405
+
291
406
  def nil_finding(element, declaration)
292
407
  return if declaration[:nillable]
293
408
 
@@ -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)
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.163
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-25 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