moxml 0.5.163 → 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: 66e9a76f3fb1a9a8e41d39d3f49c6ea97536a1d08a7aaf145169eb2ca8b2e72e
4
- data.tar.gz: 5190677a144235ce163990b3cd3f7f6ef2a123ef4e02b1b81626e2467d74bcaf
3
+ metadata.gz: 8dae9d144ec375224efd389406ed2cdb06c5409ae91d985e95221a972a1158ac
4
+ data.tar.gz: 308da2c1430d4f7f6b195eeec3d4ba9f651c9664f30c2a6c1381923572409fbd
5
5
  SHA512:
6
- metadata.gz: 8dae5f5b85c572bf5f31efff0dcd1694854a5fffcf03feb066997136b53a44e2423a8208c08944c2159cd28ecdf83954cfe9856ed8354597d8ae8a664d7c434f
7
- data.tar.gz: 0347c21ed0aecc388117e2c69632f9be715f0f1a073b5ac1dfad06fc8a0760cf07d652fc7cb39b0641ff5358b8a0d075f22de71befa72bdd687e24bf9ea1f2b4
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
@@ -267,7 +267,18 @@ scope validate laxly, except that strict `xs:any` /
267
267
  negative wildcards (`notQName` / `notNamespace` — an item excluded
268
268
  by them has no particle and the content model cannot accept it) are
269
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
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
271
282
  version its source declares: root `vc:minVersion="1.1"` scopes the
272
283
  document to 1.1, anything else is 1.0 (consumers scope 1.0-only
273
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.163"
4
+ VERSION = "0.5.165"
5
5
  end
@@ -143,12 +143,94 @@ module Moxml
143
143
  :declared_attributes, :any_attribute)
144
144
  SchemaIndex = Struct.new(:target_namespace, :element_default,
145
145
  :attribute_default, :elements,
146
- :attributes, :contents, :version)
146
+ :attributes, :contents, :version,
147
+ :element_patterns, :attribute_patterns)
147
148
 
148
149
  # The XSD 1.1 version-tagging namespace (Structures §C.3):
149
150
  # vc:minVersion / vc:maxVersion gate 1.1 constructs.
150
151
  VC_NS = "http://www.w3.org/2001/XMLSchema-versioning"
151
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
+
152
234
  class << self
153
235
  # Index one schema document. Returns nil when the source
154
236
  # cannot be indexed — the engine's answer then stands alone
@@ -161,7 +243,7 @@ module Moxml
161
243
  root["targetNamespace"].to_s,
162
244
  root["elementFormDefault"] == "qualified",
163
245
  root["attributeFormDefault"] == "qualified",
164
- {}, {}, {}, schema_version(root)
246
+ {}, {}, {}, schema_version(root), {}, {}
165
247
  )
166
248
  child_elements(root).each do |decl|
167
249
  case decl.name
@@ -169,6 +251,7 @@ module Moxml
169
251
  when "attribute" then index_global_attribute(decl, index)
170
252
  end
171
253
  end
254
+ index_patterns(root, index)
172
255
  index
173
256
  rescue StandardError
174
257
  nil
@@ -188,6 +271,142 @@ module Moxml
188
271
  declared.to_s.start_with?("1.1") ? "1.1" : "1.0"
189
272
  end
190
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
+
191
410
  # Post-validation findings for one instance document.
192
411
  def findings(doc, index)
193
412
  return [] unless index
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
@@ -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.163
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-25 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