lutaml-model 0.8.61 → 0.8.63

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: 60d029c4eab58efd3741553bd22cc4f689164143f6f1fed87d4c6956cf341cfd
4
- data.tar.gz: aefb886903788a07439e416b2a92ec7a5fe295f231aa6c137b606e6e9621b048
3
+ metadata.gz: e60ce4a1bedec13a3b778b0afea4c9be5c4779f721916bfafeb151e3bd8f8c1e
4
+ data.tar.gz: b6f519fabe6c9dc9c0c5edeb871d6afcc23d6439eff433b15b74b532b1a633d7
5
5
  SHA512:
6
- metadata.gz: 82a30dcc2a930524ebb5d3d8cdcd9c9eb712f5425105b61101263501af2b66e93e5395e2455925e57d87e9a10689b8dbd1b816e9e0f302e4472ba68f084ed365
7
- data.tar.gz: 76625bdefe2dde62258b0ec3da4ca257679fa16a9b669ca0a4453108097e9d670109e9b4f77f5b67c0cf76f95e18164edd2b477a885bb3ed5cc73c928174d6a8
6
+ metadata.gz: 646c4cfdfb4dc395f5f339332eb214b0d4cba7bf31710884357e5d8519db8825e0a8e937824f268bac8642ea5d23a7fee75686988b9d3a54923f508e50aaa3c0
7
+ data.tar.gz: d738e9c618790d6575691acc7349991f1b4f28986947e75a3dc40a883978cbc3ce7f6ef28021b7c85f822037c6c63137ed22f1930fe6aba36dd0b266f84a451b
@@ -44,6 +44,12 @@ jobs:
44
44
  runs-on: ubuntu-latest
45
45
  steps:
46
46
  - uses: actions/checkout@v6
47
+ # The release job commits the version bump to main while this job
48
+ # runs; a checkout of the dispatch-time HEAD reads the previous
49
+ # version and smoke-tests the wrong gem (0.8.60 was smoke-checked
50
+ # for the 0.8.61 release). Pull post-bump main before resolving.
51
+ with:
52
+ fetch-depth: 5
47
53
  - uses: ruby/setup-ruby@v1
48
54
  with:
49
55
  ruby-version: "3.4"
@@ -52,8 +58,13 @@ jobs:
52
58
  # From the version file, not git describe: the default checkout
53
59
  # is shallow with no tags, so git describe failed and the empty
54
60
  # version produced `gem install "lutaml-model:"`.
55
- run: echo "version=$(ruby -e 'print File.read(%q{lib/lutaml/model/version.rb})[/VERSION\s*=\s*"([^"]+)"/, 1]')" >> "$GITHUB_OUTPUT"
61
+ run: |
62
+ git pull --ff-only origin main
63
+ echo "version=$(ruby -e 'print File.read(%q{lib/lutaml/model/version.rb})[/VERSION\s*=\s*"([^"]+)"/, 1]')" >> "$GITHUB_OUTPUT"
56
64
  - name: Install and smoke the released gem
57
65
  run: |
58
66
  gem install "lutaml-model:${{ steps.version.outputs.version }}" --no-document
67
+ # The round-trip script exercises all five formats; the TOML
68
+ # legs require an adapter gem to be present.
69
+ gem install tomlib toml-rb --no-document
59
70
  ruby .github/scripts/test_installed_gem.rb
@@ -23,19 +23,22 @@ module Lutaml
23
23
  },
24
24
  }
25
25
 
26
- # Choice validation: each choice is its own group. Independent
27
- # choices combine conjunctively (allOf), so a document may
28
- # satisfy every group at once; a single choice renders its
29
- # group directly. Nested choices render as a nested group
30
- # schema, which a document satisfies through its own
31
- # oneOf/anyOf (#864, #865).
26
+ # Choice validation follows the serializer's contract: unset
27
+ # or empty members are omitted from output, and instances that
28
+ # leave a whole group empty are accepted (#869). Members are
29
+ # therefore never `required`; a max:1 group contributes an
30
+ # at-most-one constraint over its members, and independent
31
+ # groups combine conjunctively under allOf. Groups whose range
32
+ # admits several members impose no serialization constraint.
32
33
  if type.choice_attributes.any?
33
- if type.choice_attributes.one?
34
- @schema[name].merge!(choice_schema(type.choice_attributes.first))
35
- else
36
- @schema[name]["allOf"] = type.choice_attributes.map do |choice|
37
- choice_schema(choice)
38
- end
34
+ groups = type.choice_attributes
35
+ .select { |choice| choice.max == 1 }
36
+ .map { |choice| { "oneOf" => exclusive_branches(choice) } }
37
+
38
+ if groups.one?
39
+ @schema[name].merge!(groups.first)
40
+ elsif groups.any?
41
+ @schema[name]["allOf"] = groups
39
42
  end
40
43
  end
41
44
 
@@ -44,30 +47,47 @@ module Lutaml
44
47
 
45
48
  private
46
49
 
47
- # min == max == 1 makes the members mutually exclusive (oneOf);
48
- # any other range admits several members together (anyOf with
49
- # per-member requirement).
50
- def choice_schema(choice)
51
- key = choice.min == 1 && choice.max == 1 ? "oneOf" : "anyOf"
52
- { key => choice_branches(choice) }
53
- end
54
-
55
- def choice_branches(choice)
56
- choice.attributes.map do |member|
50
+ # Leaves of the group (nested choices collapse into it), as
51
+ # [name, attribute] pairs.
52
+ def choice_leaves(choice)
53
+ choice.attributes.flat_map do |member|
57
54
  if member.is_a?(Lutaml::Model::Choice)
58
- choice_schema(member)
55
+ choice_leaves(member)
59
56
  else
60
- {
61
- "type" => "object",
62
- "properties" => PropertiesCollection.from_attributes(
63
- [member], extract_register_from(type)
64
- ).to_schema,
65
- "required" => [member.name.to_s],
66
- }
57
+ [[member.name.to_s, member]]
67
58
  end
68
59
  end
69
60
  end
70
61
 
62
+ # Exactly-one-present construction over the leaves: no leaf, or
63
+ # exactly one leaf present without the others. Accepts the
64
+ # all-empty document the serializer emits; rejects documents
65
+ # with two members of the group at once (max:1).
66
+ def exclusive_branches(choice)
67
+ leaves = choice_leaves(choice)
68
+ names = leaves.map(&:first)
69
+ none = {
70
+ "not" => {
71
+ "anyOf" => names.map { |n| { "required" => [n] } },
72
+ },
73
+ }
74
+ singles = leaves.map do |name, attribute|
75
+ others = names - [name]
76
+ {
77
+ "allOf" => (
78
+ [{
79
+ "type" => "object",
80
+ "properties" => PropertiesCollection.from_attributes(
81
+ [attribute], extract_register_from(type)
82
+ ).to_schema,
83
+ "required" => [name],
84
+ }] + others.map { |o| { "not" => { "required" => [o] } } }
85
+ ),
86
+ }
87
+ end
88
+ [none] + singles
89
+ end
90
+
71
91
  def properties_to_schema(type)
72
92
  PropertiesCollection.from_class(type).to_schema
73
93
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.61"
5
+ VERSION = "0.8.63"
6
6
  end
7
7
  end
@@ -20,6 +20,7 @@ module Lutaml
20
20
  raw_xml = xml
21
21
  xml = normalize_xml_for_parse(xml)
22
22
  parsed = parse_with_moxml(xml, parse_encoding)
23
+ assert_no_truncated_recovered_parse!(parsed)
23
24
  root_element = parsed.root
24
25
 
25
26
  raise_empty_document_error if root_element.nil?
@@ -30,6 +31,25 @@ module Lutaml
30
31
  new(root, parse_encoding, **parse_document_options(raw_xml))
31
32
  end
32
33
 
34
+ # Recover-mode parsing must never masquerade input truncation as
35
+ # success.
36
+ #
37
+ # libxml2 (the Nokogiri backend) caps its input buffer at 10 MB;
38
+ # longer documents trip a fatal "Resource limit exceeded: Buffer
39
+ # size limit exceeded, try XML_PARSE_HUGE" error mid-input. In
40
+ # recover mode Nokogiri records that error on the document and
41
+ # returns the partial tree, so every node past the limit silently
42
+ # vanishes (lutaml-model#871). A resource-limit fatal means the
43
+ # engine stopped early with input left — refuse to deserialize the
44
+ # partial document instead of dropping trailing content without a
45
+ # trace.
46
+ #
47
+ # Other recover-mode fatals (e.g. an XML declaration after leading
48
+ # whitespace) do not drop content and keep the long-standing
49
+ # recover behavior; only the resource-limit family is truncating.
50
+ TRUNCATION_FATAL_MARKER =
51
+ /Resource limit exceeded|Buffer size limit exceeded/
52
+
33
53
  private
34
54
 
35
55
  def normalize_xml_for_parse(xml)
@@ -62,6 +82,23 @@ module Lutaml
62
82
  end
63
83
  end
64
84
 
85
+ def assert_no_truncated_recovered_parse!(parsed)
86
+ errors = parsed.parse_errors
87
+ return if errors.nil? || errors.empty?
88
+
89
+ fatal = errors.find do |message|
90
+ message.match?(TRUNCATION_FATAL_MARKER)
91
+ end
92
+ return if fatal.nil?
93
+
94
+ raise Lutaml::Model::InvalidFormatError.new(
95
+ :xml,
96
+ "the XML engine reported a fatal resource limit and recovered " \
97
+ "with a truncated document; refusing to deserialize " \
98
+ "partial input (#{fatal})",
99
+ )
100
+ end
101
+
65
102
  def parse_document_options(xml)
66
103
  {
67
104
  doctype: extract_doctype_from_xml(xml),
@@ -70,25 +70,60 @@ RSpec.describe "JSON/YAML schema generation regressions (#863, #864, #865, #866)
70
70
  end
71
71
 
72
72
  describe "#864: nested choice blocks" do
73
- it "renders the nested group as its own oneOf" do
73
+ it "collapses the nested group into the outer at-most-one set" do
74
74
  schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Outer))
75
75
  one_of = schema["$defs"]["SchemaRepro_Outer"]["oneOf"]
76
- at_branch = one_of.find { |b| b["required"] == %w[at] }
77
- inner = one_of.find { |b| b["oneOf"] }
78
- expect(at_branch).to be_truthy
79
- expect(inner["oneOf"].map { |b| b["required"] })
80
- .to contain_exactly(%w[from], %w[to])
76
+ requireds = one_of.map { |b| b["allOf"].to_a.filter_map { |s| s["required"]&.first } }
77
+ expect(requireds).to contain_exactly([], %w[from], %w[to], %w[at])
81
78
  end
82
79
  end
83
80
 
84
81
  describe "#865: independent choices combine conjunctively" do
85
- it "renders an allOf of oneOf groups with discriminating required keys" do
82
+ it "renders an allOf of oneOf at-most-one groups" do
86
83
  schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
87
84
  all_of = schema["$defs"]["SchemaRepro_Relation"]["allOf"]
88
85
  expect(all_of.length).to eq(2)
89
86
  all_of.each do |group|
90
87
  expect(group).to have_key("oneOf")
91
- group["oneOf"].each { |branch| expect(branch["required"]).to be_truthy }
88
+ expect(group["oneOf"].length).to eq(3)
89
+ end
90
+ end
91
+ end
92
+
93
+ describe "#869: the serializer's own output validates" do
94
+ it "emits no required keys for initialize_empty choice members" do
95
+ schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
96
+ definition = schema["$defs"]["SchemaRepro_Relation"]
97
+ groups = definition["allOf"] || [definition]
98
+ groups.each do |group|
99
+ group["oneOf"].each do |branch|
100
+ Array(branch["allOf"]).each do |sub|
101
+ expect(sub).not_to have_key("required") if sub.key?("not")
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ it "accepts the document the default instance serializes to" do
108
+ inst = SchemaRepro::Relation.new(type: "includes")
109
+ doc = inst.to_hash
110
+ expect(doc).to eq("type" => "includes")
111
+ schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
112
+ definition = schema["$defs"]["SchemaRepro_Relation"]
113
+ groups = definition["allOf"] || [definition]
114
+ groups.each do |group|
115
+ satisfied = group["oneOf"].count do |branch|
116
+ Array(branch["allOf"]).all? do |sub|
117
+ if sub.key?("not")
118
+ Array(sub["not"]["anyOf"]).none? do |req|
119
+ doc.key?(req["required"].first)
120
+ end
121
+ else
122
+ doc.key?(sub["required"].first)
123
+ end
124
+ end
125
+ end
126
+ expect(satisfied).to eq(1)
92
127
  end
93
128
  end
94
129
  end
@@ -161,26 +161,7 @@ RSpec.describe Lutaml::Model::Schema::JsonSchema do
161
161
  "type" => ["string", "null"],
162
162
  },
163
163
  },
164
- "anyOf" => [
165
- {
166
- "type" => "object",
167
- "properties" => {
168
- "email" => {
169
- "type" => ["string", "null"],
170
- },
171
- },
172
- "required" => ["email"],
173
- },
174
- {
175
- "type" => "object",
176
- "properties" => {
177
- "phone" => {
178
- "type" => ["string", "null"],
179
- },
180
- },
181
- "required" => ["phone"],
182
- },
183
- ],
164
+
184
165
  },
185
166
  },
186
167
  }
@@ -136,23 +136,6 @@ RSpec.describe Lutaml::Model::Schema::YamlSchema do
136
136
  type:
137
137
  - string
138
138
  - 'null'
139
- anyOf:
140
- - type: object
141
- properties:
142
- email:
143
- type:
144
- - string
145
- - 'null'
146
- required:
147
- - email
148
- - type: object
149
- properties:
150
- phone:
151
- type:
152
- - string
153
- - 'null'
154
- required:
155
- - phone
156
139
  YAML
157
140
  end
158
141
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+ require "lutaml/model"
6
+ require "lutaml/xml"
7
+ require "lutaml/xml/adapter/nokogiri_adapter"
8
+
9
+ # Regression tests for lutaml-model#871.
10
+ #
11
+ # libxml2 (the Nokogiri backend) caps its input buffer at 10 MB. Parsing a
12
+ # longer document in recover mode records a FATAL "Resource limit exceeded:
13
+ # Buffer size limit exceeded, try XML_PARSE_HUGE" error on the document and
14
+ # returns the partial tree, so every node past the cap silently vanishes.
15
+ # XmlParser now refuses documents whose parse_errors carry a fatal error
16
+ # instead of deserializing the truncated input.
17
+ RSpec.describe "recovered fatal parse errors" do
18
+ let(:annex_class) do
19
+ Class.new(Lutaml::Model::Serializable) do
20
+ attribute :id, :string
21
+
22
+ xml do
23
+ element "annex"
24
+ map_attribute "id", to: :id
25
+ end
26
+ end
27
+ end
28
+
29
+ let(:root_class) do
30
+ annex = annex_class
31
+
32
+ Class.new(Lutaml::Model::Serializable) do
33
+ attribute :annexes, annex, collection: true
34
+
35
+ xml do
36
+ element "metanorma"
37
+ map_element "annex", to: :annexes
38
+ end
39
+ end
40
+ end
41
+
42
+ def build_document(chunk_count:, chunk_size:, with_tail: false)
43
+ filler = "A" * chunk_size
44
+ document = +"<metanorma>"
45
+ chunk_count.times do |index|
46
+ document << %(<annex id="annex#{index}" data="#{filler}"/>)
47
+ end
48
+ document << %(<annex id="annex-tail-marker"/>) if with_tail
49
+ document << "</metanorma>"
50
+ document
51
+ end
52
+
53
+ # Six 1.7 MB attribute values push the cumulative input past libxml2's
54
+ # 10 MB buffer cap mid-document, mirroring the issue's failing manifest.
55
+ def over_cap_document
56
+ build_document(chunk_count: 6, chunk_size: 1_700_000, with_tail: true)
57
+ end
58
+
59
+ it "hits the libxml2 buffer cap in recover mode past 10 MB" do
60
+ errors = Nokogiri::XML(over_cap_document) { |config| config.recover.nonet }.errors
61
+
62
+ expect(errors.map(&:message).join("\n")).to match(/\bFATAL:/)
63
+ end
64
+
65
+ it "raises instead of silently truncating on the nokogiri path" do
66
+ Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
67
+ expect { root_class.from_xml(over_cap_document) }
68
+ .to raise_error(Lutaml::Model::InvalidFormatError, /resource limit.*truncated/m)
69
+ end
70
+ end
71
+
72
+ it "raises from the adapter layer directly" do
73
+ Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
74
+ expect { Lutaml::Xml::Adapter::NokogiriAdapter.parse(over_cap_document) }
75
+ .to raise_error(Lutaml::Model::InvalidFormatError, /XML_PARSE_HUGE/)
76
+ end
77
+ end
78
+
79
+ it "keeps parsing documents whose fatals do not drop content" do
80
+ xml = " <?xml version=\"1.0\"?>\n<metanorma><annex id=\"a\"/></metanorma>"
81
+
82
+ Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
83
+ model = root_class.from_xml(xml)
84
+
85
+ expect(model.annexes.map(&:id)).to eq(["a"])
86
+ end
87
+ end
88
+
89
+ it "still parses smaller documents without any fatal-error handling" do
90
+ Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
91
+ model = root_class.from_xml(
92
+ build_document(chunk_count: 2, chunk_size: 500_000, with_tail: true),
93
+ )
94
+
95
+ expect(model.annexes.map(&:id))
96
+ .to eq(["annex0", "annex1", "annex-tail-marker"])
97
+ end
98
+ end
99
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.61
4
+ version: 0.8.63
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1902,6 +1902,7 @@ files:
1902
1902
  - spec/lutaml/xml/adapter/oga_adapter_spec.rb
1903
1903
  - spec/lutaml/xml/adapter/order_spec.rb
1904
1904
  - spec/lutaml/xml/adapter/ox_adapter_spec.rb
1905
+ - spec/lutaml/xml/adapter/recovered_fatal_parse_spec.rb
1905
1906
  - spec/lutaml/xml/adapter/rexml_adapter_spec.rb
1906
1907
  - spec/lutaml/xml/adapter/xml_namespace_spec.rb
1907
1908
  - spec/lutaml/xml/adapter_loader_spec.rb