Class: Canon::Xml::SaxBuilder
- Inherits:
-
Object
- Object
- Canon::Xml::SaxBuilder
- 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.
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)
Class Method Summary collapse
-
.parse(xml_string, preserve_whitespace: false, strip_doctype: false) ⇒ Nodes::RootNode
Parse XML string and return Canon::Xml::Node tree.
-
.strip_doctype_declaration(xml) ⇒ String
Strip DOCTYPE declaration without using complex regex This avoids ReDoS vulnerability from patterns like \s+ and [^>]*.
Instance Method Summary collapse
-
#cdata(string) ⇒ Object
Called for CDATA content.
-
#characters(string) ⇒ Object
Called for text content.
-
#comment(string) ⇒ Object
Called for comments.
-
#end_element(_name) ⇒ Object
Called when an element ends.
-
#error(string) ⇒ Object
SAX callbacks for libxml errors and warnings.
-
#initialize(preserve_whitespace: false) ⇒ SaxBuilder
constructor
Initialize the SAX builder.
-
#processing_instruction(name, content) ⇒ Object
Called for processing instructions.
-
#reorder_children(root) ⇒ Object
Reorder root children so document element comes first followed by PIs and comments (outside document element).
-
#result ⇒ Nodes::RootNode
Return the built tree.
-
#start_element(name, attrs = []) ⇒ Object
Called when an element starts.
- #warning(string) ⇒ Object
Constructor Details
#initialize(preserve_whitespace: false) ⇒ SaxBuilder
Initialize the SAX builder
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/canon/xml/sax_builder.rb', line 75 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
26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/canon/xml/sax_builder.rb', line 26 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 [^>]*
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/canon/xml/sax_builder.rb', line 45 def self.strip_doctype_declaration(xml) # Find DOCTYPE start (case-insensitive) doctype_start = xml.upcase.index("<!DOCTYPE") 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.
174 175 176 177 178 |
# File 'lib/canon/xml/sax_builder.rb', line 174 def cdata(string) return if string.nil? append_text(string, string) end |
#characters(string) ⇒ Object
Called for text content
161 162 163 164 165 |
# File 'lib/canon/xml/sax_builder.rb', line 161 def characters(string) return if string.nil? append_text(decode_character_references(string), string) end |
#comment(string) ⇒ Object
Called for comments
226 227 228 229 230 |
# File 'lib/canon/xml/sax_builder.rb', line 226 def comment(string) parent = @stack.last comment_node = Nodes::CommentNode.new(value: string) parent.add_child(comment_node) end |
#end_element(_name) ⇒ Object
Called when an element ends
153 154 155 156 |
# File 'lib/canon/xml/sax_builder.rb', line 153 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.
93 94 95 |
# File 'lib/canon/xml/sax_builder.rb', line 93 def error(string) @parse_errors << string.to_s.strip end |
#processing_instruction(name, content) ⇒ Object
Called for processing instructions
236 237 238 239 240 241 |
# File 'lib/canon/xml/sax_builder.rb', line 236 def processing_instruction(name, content) parent = @stack.last pi = Nodes::ProcessingInstructionNode.new(target: name, data: content || "") parent.add_child(pi) end |
#reorder_children(root) ⇒ Object
Reorder root children so document element comes first followed by PIs and comments (outside document element)
257 258 259 260 261 262 263 |
# File 'lib/canon/xml/sax_builder.rb', line 257 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 |
#result ⇒ Nodes::RootNode
Return the built tree
246 247 248 249 250 251 252 253 |
# File 'lib/canon/xml/sax_builder.rb', line 246 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
105 106 107 108 109 110 111 112 113 114 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 |
# File 'lib/canon/xml/sax_builder.rb', line 105 def start_element(name, attrs = []) parent = @stack.last # 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 new_scope = @namespace_stack.last.merge(ns_hash) @namespace_stack.push(new_scope) # Find namespace URI from current scope ns_uri = new_scope[prefix.to_s] # Create element node element = Nodes::ElementNode.new( name: local_name, namespace_uri: ns_uri, prefix: prefix, ) # Add namespace nodes from current scope add_namespace_nodes(element, new_scope) # Build and add attribute nodes (excluding xmlns declarations) add_attribute_nodes(element, regular_attrs) parent.add_child(element) @stack.push(element) end |
#warning(string) ⇒ Object
97 98 99 |
# File 'lib/canon/xml/sax_builder.rb', line 97 def warning(string) @parse_errors << string.to_s.strip end |