moxml 0.5.95 → 0.5.97

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: de8e16de76cef81c34b0a4e097d5aa16196fdbc03d9bfe2cbee1e765312f1599
4
- data.tar.gz: ab5cc00b4c1a4a209065620c89b7541e107ed3d0a80e1e91e4455cb10c17fd98
3
+ metadata.gz: ec3d9011525010958b918f5c9665d47f54c202c96d0e3917c65d90ced667233b
4
+ data.tar.gz: 4d7d535d9960f935a6419006c586db0b49981de555cfa362eee641c548c51c1b
5
5
  SHA512:
6
- metadata.gz: bf21f0b903d142e6ebce40004d4e3bb657ec812396a2e8e6bd49c2eb06b6763fa6917f18c6918a134784d279360092c127fad56b2a75449f21d7e2dc94ae2e3c
7
- data.tar.gz: 0bc9b8912c18c946101b76c88ce2c5c1d7f3ab2d8f643d06a8760d78332e1fd0d426844caff21efd8bb5241af9b35471478223fff9754b78405c3dda28d1990b
6
+ metadata.gz: 8e39fcd9157a62fc686befcae1e4ca077e757c6b9bfc399b590f02377199be55e45f281f9cd50dcac5eaff9d32abd8aa2d40de7fc3250b3d02dd69aadd1dfefe
7
+ data.tar.gz: d947c13ae056a34a65d91d27648b54b89d4bca1c0d7a56712a77c560e7ef89cf798538af3871298ddcf5146379cc57442a7f02d359013228057a707765003f11
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gemspec
9
9
  gem "benchmark"
10
10
  gem "benchmark-ips"
11
11
  gem "get_process_mem"
12
- gem "leptris", "~> 1.9.238"
12
+ gem "leptris", "~> 1.9.280"
13
13
  gem "libxml-ruby", ">= 5.0"
14
14
  gem "nokogiri", "~> 1.18"
15
15
  gem "openssl", "~> 3.0"
@@ -779,41 +779,52 @@ module Moxml
779
779
  if node.is_a?(::Ox::Element) && (doc = document(node))
780
780
  return true if attachments.get(doc, :has_entity_refs)
781
781
  return true if attachments.get(doc, :has_cdata_end_markers)
782
+ return true if attachments.get(doc, :has_patched_text)
782
783
  return false if attachments.key?(doc, :has_entity_refs) &&
783
- attachments.key?(doc, :has_cdata_end_markers)
784
+ attachments.key?(doc, :has_cdata_end_markers) &&
785
+ attachments.key?(doc, :has_patched_text)
784
786
 
785
787
  scan_root = doc
786
788
  end
787
789
 
788
790
  # One merged tree scan instead of two; flags cache on the
789
791
  # document either way.
790
- has_er, has_cdata = tree_scan_custom_needs(scan_root)
792
+ has_er, has_cdata, has_patched = tree_scan_custom_needs(scan_root)
791
793
  if scan_root.is_a?(::Ox::Document)
792
794
  attachments.set(scan_root, :has_entity_refs, has_er)
793
795
  attachments.set(scan_root, :has_cdata_end_markers, has_cdata)
796
+ attachments.set(scan_root, :has_patched_text, has_patched)
794
797
  end
795
798
 
796
- has_er || has_cdata
799
+ has_er || has_cdata || has_patched
797
800
  end
798
801
 
799
- # Single walk collecting both custom-serialize triggers.
802
+ # Single walk collecting the custom-serialize triggers.
800
803
  def tree_scan_custom_needs(node)
801
804
  has_er = false
802
805
  has_cdata = false
806
+ # Text enumeration hands out CustomizedOx::Text wrappers
807
+ # (node.rb patches String children so they can carry a
808
+ # parent); dup/append of an enumerated wrapper plants the
809
+ # subclass instance in the raw tree, and ::Ox.dump rejects
810
+ # classes it does not know (moxml#301).
811
+ has_patched = false
803
812
  stack = [node]
804
813
  until stack.empty?
805
814
  current = stack.pop
806
815
  case current
807
816
  when ::Moxml::Adapter::CustomizedOx::EntityReference
808
817
  has_er = true
818
+ when ::Moxml::Adapter::CustomizedOx::Text
819
+ has_patched = true
809
820
  when ::Ox::CData
810
821
  has_cdata = true if current.value&.include?("]]>")
811
822
  when ::Ox::Element, ::Ox::Document
812
823
  current.nodes&.each { |child| stack << child }
813
824
  end
814
- return [true, has_cdata] if has_er
825
+ return [true, has_cdata, has_patched] if has_er
815
826
  end
816
- [has_er, has_cdata]
827
+ [has_er, has_cdata, has_patched]
817
828
  end
818
829
 
819
830
  def has_declaration?(native_doc, _wrapper)
@@ -25,12 +25,12 @@ module Moxml
25
25
  def parse(xml, options = {}, _context = nil)
26
26
  xml = "" if xml.nil?
27
27
 
28
- # Handle frozen strings by creating a mutable copy
29
- processed_xml = if xml.frozen?
30
- xml.dup.force_encoding("UTF-8").encode("UTF-8")
31
- else
32
- xml.force_encoding("UTF-8").encode("UTF-8")
33
- end
28
+ # Transcode from the input's own encoding tag; encode returns
29
+ # a fresh string (or self, already UTF-8) — the caller's
30
+ # buffer is never retagged in place. The old non-frozen
31
+ # branch force_encoding!-mutated the CALLER's string,
32
+ # corrupting it (invalid UTF-8 tag) for every later parse.
33
+ processed_xml = xml.encode("UTF-8")
34
34
 
35
35
  # Preprocess entities to avoid double-escaping on output
36
36
  processed_xml = preprocess_entities(processed_xml)
data/lib/moxml/entity.rb CHANGED
@@ -112,6 +112,7 @@ module Moxml
112
112
  else
113
113
  xml.encode("UTF-8")
114
114
  end
115
+ str = rewrite_declared_encoding(str)
115
116
  # Fast path: no `&` means no entity references to mark — skip
116
117
  # the regex scan and string allocation entirely. The vast
117
118
  # majority of XML payloads contain no entity references.
@@ -139,6 +140,24 @@ module Moxml
139
140
  preprocess_with_marker_flag(xml)[0]
140
141
  end
141
142
 
143
+ # Engines honor the DECLARED encoding and re-decode the bytes they
144
+ # are handed — moxml always feeds UTF-8 (see the normalization
145
+ # above), so a declaration naming another encoding must be
146
+ # rewritten to match, or the already-transcoded bytes decode a
147
+ # second time into mojibake ("café" -> "café"). The anchored match
148
+ # fails on the first two bytes for documents without a declaration.
149
+ DECLARED_ENCODING_RE = /\A(<\?xml[^>]*?encoding=)['"][^'"]*['"]/
150
+ private_constant :DECLARED_ENCODING_RE
151
+
152
+ def rewrite_declared_encoding(str)
153
+ # The passthrough contract (Entity spec: plain text returns the
154
+ # SAME object) requires no allocation when no declaration names
155
+ # another encoding — both gates are allocation-free.
156
+ return str unless str.start_with?("<?xml") && str.include?("encoding=")
157
+
158
+ str.sub(DECLARED_ENCODING_RE) { "#{Regexp.last_match(1)}\"UTF-8\"" }
159
+ end
160
+
142
161
  # Resolve numeric (&#NN; / &#xNN;) and the five standard named
143
162
  # (&amp; &lt; &gt; &quot; &apos;) XML entity references in one
144
163
  # single pass. The resulting characters are data; no further
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.95"
4
+ VERSION = "0.5.97"
5
5
  end
@@ -110,4 +110,34 @@ RSpec.describe Moxml::Adapter::Ox do
110
110
  end
111
111
  end
112
112
  end
113
+
114
+ # moxml#301: text enumeration hands out CustomizedOx::Text
115
+ # (String children are patched so they can carry a parent); dup or
116
+ # append of an enumerated wrapper plants the subclass instance in
117
+ # the raw tree, and ::Ox.dump rejects classes it does not know.
118
+ # The custom-serialize gate must trip on it (has_patched_text).
119
+ describe "duped and appended text nodes serialize" do
120
+ let(:ctx) { Moxml.new(:ox) }
121
+
122
+ it "round-trips a duped text appended to a parsed tree" do
123
+ doc = ctx.parse(%(<r><a>t</a></r>))
124
+ doc.root.add_child(doc.root.children[0].children[0].dup)
125
+ expect(doc.to_xml).to eq(%(<r><a>t</a>t</r>))
126
+ end
127
+
128
+ it "round-trips an enumerated text moved into a built document" do
129
+ src = ctx.parse(%(<r>x</r>))
130
+ built = ctx.create_document
131
+ root = built.create_element("r")
132
+ built.root = root
133
+ root.add_child(src.root.children[0].dup)
134
+ expect(built.to_xml).to eq("<r>x</r>")
135
+ end
136
+
137
+ it "round-trips a cloned text appended deep" do
138
+ doc = ctx.parse(%(<r><a>keep</a></r>))
139
+ doc.root.children[0].add_child(doc.root.children[0].children[0].clone)
140
+ expect(doc.to_xml).to eq(%(<r><a>keepkeep</a></r>))
141
+ end
142
+ end
113
143
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ # TODO.c14n/13: encoding edge cases. moxml normalizes every parse to
7
+ # UTF-8 bytes and rewrites the declaration to match — engines honor
8
+ # the DECLARED encoding and would otherwise re-decode the already
9
+ # transcoded bytes into mojibake.
10
+ CAF_E_UTF8 = [60, 114, 62, 99, 97, 102, 195, 169, 60, 47, 114, 62].freeze
11
+ LATIN1_XML = (+"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><r>caf\xE9</r>").force_encoding("ISO-8859-1")
12
+ BOM_XML = (+"\xEF\xBB\xBF<r/>").force_encoding("UTF-8")
13
+ NFC_XML = (+"<r>caf\xC3\xA9</r>").force_encoding("UTF-8")
14
+
15
+ # oga's parse layer leaks the declaration as a PI node and the BOM as
16
+ # text (pre-existing, documented in the W3C examples spec).
17
+ OGA_LEAK = %i[oga].freeze
18
+
19
+ RSpec.describe "Moxml character encoding at the parse boundary" do
20
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
21
+ context "with the #{adapter_name} adapter" do
22
+ let(:ctx) { Moxml.new(adapter_name) }
23
+
24
+ unless OGA_LEAK.include?(adapter_name)
25
+ it "transcodes declared Latin-1 to UTF-8 bytes" do
26
+ doc = ctx.parse(LATIN1_XML.dup)
27
+ expect(Moxml::C14n.canonicalize(doc).bytes).to eq(CAF_E_UTF8)
28
+ end
29
+
30
+ it "strips a leading UTF-8 BOM" do
31
+ doc = ctx.parse(BOM_XML.dup)
32
+ expect(Moxml::C14n.canonicalize(doc)).to eq("<r></r>")
33
+ end
34
+ end
35
+
36
+ it "passes NFC content through without normalization" do
37
+ doc = ctx.parse(NFC_XML.dup)
38
+ expect(Moxml::C14n.canonicalize(doc).bytes).to eq(CAF_E_UTF8)
39
+ end
40
+
41
+ it "does not mutate the caller's input string" do
42
+ input = LATIN1_XML.dup
43
+ expect(input).not_to be_frozen
44
+ ctx.parse(input)
45
+ expect(input.encoding).to eq(Encoding::ISO_8859_1)
46
+ expect(input.valid_encoding?).to be(true)
47
+ expect(input.bytes).to eq(LATIN1_XML.bytes)
48
+ end
49
+
50
+ it "serializes transcoded documents as UTF-8 declarations" do
51
+ doc = ctx.parse(LATIN1_XML.dup)
52
+ expect(doc.to_xml).to include("UTF-8")
53
+ expect(doc.to_xml).not_to include("ISO-8859-1")
54
+ end
55
+ end
56
+ end
57
+ end
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.95
4
+ version: 0.5.97
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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 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
@@ -461,6 +461,7 @@ files:
461
461
  - spec/moxml/c14n/adapter_delegation_spec.rb
462
462
  - spec/moxml/c14n/api_spec.rb
463
463
  - spec/moxml/c14n/c14n_spec.rb
464
+ - spec/moxml/c14n/character_encoding_spec.rb
464
465
  - spec/moxml/c14n/comments_pis_spec.rb
465
466
  - spec/moxml/c14n/inclusive10_spec.rb
466
467
  - spec/moxml/c14n/namespace_edge_cases_spec.rb