Class: Canon::Xml::SaxBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/canon/xml/sax_builder.rb

Overview

Builds Canon::Xml::Node tree from SAX events.

Engine-neutral: the event protocol is Nokogiri-shaped (qname + attribute pairs with xmlns declarations inline); Canon::Xml::Sax selects the driver. Much faster than DOM parsing + conversion — no intermediate engine DOM tree, no traversal conversion pass.

Construction goes through TreeBuilder like every other feed: this class owns only what is SAX-specific — qname parsing, xmlns separation, character-reference decoding, adjacency combining, the namespace stack, and document-level reordering.

Usage:

root = SaxBuilder.parse(xml_string, preserve_whitespace: false)
# root is a Canon::Xml::Nodes::RootNode

For C14N, use strip_doctype: true to avoid DTD default attribute expansion:

root = SaxBuilder.parse(xml_string, strip_doctype: true)

Constant Summary collapse

NO_NAMESPACE_DECLS =

Shared empties for the no-declaration common case: both consumers only iterate them.

[].freeze
EMPTY_NS_HASH =
{}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(preserve_whitespace: false) ⇒ SaxBuilder

Initialize the SAX builder

Parameters:

  • preserve_whitespace (Boolean) (defaults to: false)

    Whether to preserve whitespace-only text nodes



85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/canon/xml/sax_builder.rb', line 85

def initialize(preserve_whitespace: false)
  @preserve_whitespace = preserve_whitespace
  @root = Nodes::RootNode.new
  @stack = [@root]
  # Track in-scope namespaces at each level
  # Each entry is a hash of prefix => uri
  @namespace_stack = [build_initial_namespaces]
  # Captured libxml errors during SAX parsing.  Surfaced on the
  # resulting RootNode so the diff report can warn the user
  # when a FATAL parse error has caused content loss
  # (see lutaml/canon#130).
  @parse_errors = []
end

Class Method Details

.parse(xml_string, preserve_whitespace: false, strip_doctype: false) ⇒ Nodes::RootNode

Parse XML string and return Canon::Xml::Node tree

Parameters:

  • xml_string (String)

    XML content to parse

  • preserve_whitespace (Boolean) (defaults to: false)

    Whether to preserve whitespace-only text nodes

  • strip_doctype (Boolean) (defaults to: false)

    Strip DOCTYPE before parsing (for C14N to avoid DTD default attrs)

Returns:



35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/canon/xml/sax_builder.rb', line 35

def self.parse(xml_string, preserve_whitespace: false,
strip_doctype: false)
  # Strip DOCTYPE to prevent the SAX engine from expanding DTD default attributes
  # This is needed for C14N which should NOT include default attributes from DTD
  # Use string methods instead of complex regex to avoid ReDoS vulnerability
  if strip_doctype
    xml_string = strip_doctype_declaration(xml_string)
  end

  builder = new(preserve_whitespace: preserve_whitespace)
  Canon::Xml::Sax.parse(xml_string, builder)
  builder.result
end

.strip_doctype_declaration(xml) ⇒ String

Strip DOCTYPE declaration without using complex regex This avoids ReDoS vulnerability from patterns like \s+ and [^>]*

Parameters:

  • xml (String)

    XML string potentially containing DOCTYPE

Returns:

  • (String)

    XML string with DOCTYPE removed



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/canon/xml/sax_builder.rb', line 54

def self.strip_doctype_declaration(xml)
  # Find DOCTYPE start (case-insensitive). A literal + /i index
  # scans in place — upcase would copy the whole document per call.
  doctype_start = xml.index(/<!DOCTYPE/i)
  return xml unless doctype_start

  # Find the end of DOCTYPE - it ends with >
  # Handle both simple DOCTYPE and those with internal subset [...]
  pos = doctype_start + 9 # length of "<!DOCTYPE"
  in_bracket = false

  while pos < xml.length
    char = xml[pos]
    if char == "[" && !in_bracket
      in_bracket = true
    elsif char == "]" && in_bracket
      in_bracket = false
    elsif char == ">" && !in_bracket
      # Found the end of DOCTYPE
      return xml[0...doctype_start] + xml[(pos + 1)..]
    end
    pos += 1
  end

  # If we didn't find a proper end, just return original
  xml
end

Instance Method Details

#cdata(string) ⇒ Object

Called for CDATA content. CDATA is literal character data: character references inside it are NOT decoded (a literal A stays as written), unlike regular text where they are resolved. Whitespace and adjacency rules match characters so the two forms of character data build identical trees.

Parameters:

  • string (String)

    CDATA content



217
218
219
220
221
# File 'lib/canon/xml/sax_builder.rb', line 217

def cdata(string)
  return if string.nil?

  append_text(string, string)
end

#characters(string) ⇒ Object

Called for text content

Parameters:

  • string (String)

    Text content



204
205
206
207
208
# File 'lib/canon/xml/sax_builder.rb', line 204

def characters(string)
  return if string.nil?

  append_text(decode_character_references(string), string)
end

#comment(string) ⇒ Object

Called for comments

Parameters:

  • string (String)

    Comment content



261
262
263
# File 'lib/canon/xml/sax_builder.rb', line 261

def comment(string)
  @stack.last.add_child(TreeBuilder::DEFAULT.comment(string))
end

#end_element(_name) ⇒ Object

Called when an element ends

Parameters:

  • _name (String)

    Element name (unused)



196
197
198
199
# File 'lib/canon/xml/sax_builder.rb', line 196

def end_element(_name)
  @stack.pop
  @namespace_stack.pop
end

#error(string) ⇒ Object

SAX callbacks for libxml errors and warnings. Without these overrides the default handlers swallow the events; with them, libxml's "Attribute xml:lang redefined" and similar messages land in @parse_errors and ride through to ComparisonResult.



103
104
105
# File 'lib/canon/xml/sax_builder.rb', line 103

def error(string)
  @parse_errors << string.to_s.strip
end

#processing_instruction(name, content) ⇒ Object

Called for processing instructions

Parameters:

  • name (String)

    PI target

  • content (String)

    PI content



269
270
271
272
273
# File 'lib/canon/xml/sax_builder.rb', line 269

def processing_instruction(name, content)
  @stack.last.add_child(
    TreeBuilder::DEFAULT.processing_instruction(name, content || ""),
  )
end

#reorder_children(root) ⇒ Object

Reorder root children so document element comes first followed by PIs and comments (outside document element)



289
290
291
292
293
294
295
# File 'lib/canon/xml/sax_builder.rb', line 289

def reorder_children(root)
  doc_element = root.children.find { |c| c.node_type == :element }
  return unless doc_element

  other_children = root.children.reject { |c| c.node_type == :element }
  root.children = [doc_element] + other_children
end

#resultNodes::RootNode

Return the built tree

Returns:



278
279
280
281
282
283
284
285
# File 'lib/canon/xml/sax_builder.rb', line 278

def result
  # Reorder children so that the document element comes first,
  # followed by PIs and comments outside the document element
  # (C14N requires this ordering)
  reorder_children(@root)
  @root.parse_errors = @parse_errors if @parse_errors.any?
  @root
end

#start_element(name, attrs = []) ⇒ Object

Called when an element starts

Parameters:

  • name (String)

    Element name (may include prefix like "ns:element")

  • attrs (Array) (defaults to: [])

    Array of [name, value] pairs



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/canon/xml/sax_builder.rb', line 115

def start_element(name, attrs = [])
  parent = @stack.last

  # Fast path: attribute-less elements (the bulk of most
  # documents) declare no namespaces — no separation, no hash,
  # no qname pair array; the inherited scope object is pushed
  # as-is (shared scope → cached namespace-node array).
  if attrs.empty?
    if (colon = name.index(":"))
      prefix = name[0...colon]
      local_name = name[(colon + 1)..]
    else
      prefix = nil
      local_name = name
    end
    new_scope = @namespace_stack.last
    @namespace_stack.push(new_scope)
    element = TreeBuilder::DEFAULT.element(
      name: local_name,
      prefix: prefix,
      namespace_uri: new_scope[prefix.to_s],
      namespace_scope: new_scope,
    )
    parent.add_child(element)
    @stack.push(element)
    return
  end

  # Parse namespace from name (prefix:localname or just localname)
  prefix, local_name = parse_qname(name)

  # Separate namespace declarations from regular attributes
  ns_decls, regular_attrs = separate_namespaces(attrs)

  # Check for relative namespace URIs (before building hash)
  # Convert to hash for iteration
  ns_hash = build_ns_hash(ns_decls)
  ns_hash.each_value do |uri|
    next if uri.nil? || uri.empty?

    if relative_uri?(uri)
      raise Canon::Error,
            "Relative namespace URI not allowed: #{uri}"
    end
  end

  # Push new namespace scope with declarations (own shadows
  # inherited — the same merge the TreeBuilder scope kernel
  # applies). Elements that declare nothing push the inherited
  # scope object itself, so scopes — and their cached
  # namespace-node arrays in TreeBuilder — are shared down runs
  # of undeclaring elements instead of re-merged per element.
  inherited_scope = @namespace_stack.last
  new_scope = ns_hash.empty? ? inherited_scope : inherited_scope.merge(ns_hash)
  @namespace_stack.push(new_scope)

  # Flat stride-4 attribute array (TreeBuilder#element contract):
  # one array per element instead of one sub-array per attribute.
  flat_attributes = []
  regular_attrs.each do |attr_name, attr_value|
    attr_prefix, attr_local = parse_qname(attr_name)
    attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
    flat_attributes << attr_local <<
      decode_character_references(attr_value || "") << attr_ns_uri << attr_prefix
  end

  element = TreeBuilder::DEFAULT.element(
    name: local_name,
    prefix: prefix,
    namespace_uri: new_scope[prefix.to_s],
    namespace_scope: new_scope,
    attributes: flat_attributes,
  )

  parent.add_child(element)
  @stack.push(element)
end

#warning(string) ⇒ Object



107
108
109
# File 'lib/canon/xml/sax_builder.rb', line 107

def warning(string)
  @parse_errors << string.to_s.strip
end