iodef 0.1.0 → 0.2.0

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.
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ # A pull reader bounds the tree before allocating descendants or expanding entities.
5
+ class XMLTree
6
+ XML_NS = "http://www.w3.org/XML/1998/namespace"
7
+ XMLNS_NS = "http://www.w3.org/2000/xmlns/"
8
+ Node = Data.define(:name, :uri, :local, :attributes, :namespaces, :parts, :lang)
9
+ Markup = Data.define(:kind, :value)
10
+ ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
11
+
12
+ def initialize(limits)
13
+ @limits = limits
14
+ end
15
+
16
+ def parse(source)
17
+ raise ParseError, "XML input must be a String" unless source.is_a?(String)
18
+ raise LimitError, "input exceeds max_input_size" if source.bytesize > @limits[:max_input_size]
19
+
20
+ source = source.dup.force_encoding(Encoding::UTF_8)
21
+ check_characters(source)
22
+ source.gsub!(/\r\n?/, "\n")
23
+ parser = REXML::Parsers::PullParser.new(source)
24
+ stack = []
25
+ root = nil
26
+ nodes = 0
27
+ loop do
28
+ event = parser.pull
29
+ nodes += 1
30
+ raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
31
+
32
+ case event.event_type
33
+ when :start_element
34
+ raise LimitError, "XML exceeds max_depth" if stack.size >= @limits[:max_depth]
35
+ raise ParseError, "XML must have exactly one root element" if stack.empty? && root
36
+
37
+ attributes = event[1]
38
+ raise LimitError, "XML attributes exceed max_collection_size" if attributes.size > @limits[:max_collection_size]
39
+ raise ParseError, "unescaped XML attribute markup" if attributes.values.any? { |value| value.include?("<") }
40
+ nodes += attributes.size
41
+ raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
42
+
43
+ namespaces = stack.empty? ? { "xml" => XML_NS } : stack.last.namespaces
44
+ if attributes.keys.any? { |name| name == "xmlns" || name.start_with?("xmlns:") }
45
+ nodes += namespaces.size
46
+ raise LimitError, "XML namespace contexts exceed max_nodes" if nodes > @limits[:max_nodes]
47
+
48
+ namespaces = namespaces.dup
49
+ end
50
+ attributes = attributes.transform_values { |value| decode_text(value.gsub(/[\t\r\n]/, " ")) }
51
+ attributes.each do |name, value|
52
+ next unless name == "xmlns" || name.start_with?("xmlns:")
53
+
54
+ prefix = name == "xmlns" ? "" : name.delete_prefix("xmlns:")
55
+ if prefix == "xmlns" || value == XMLNS_NS || (prefix == "xml") != (value == XML_NS) || (!prefix.empty? && value.empty?)
56
+ raise ParseError, "invalid XML namespace declaration"
57
+ end
58
+ namespaces[prefix] = value
59
+ end
60
+ expanded = {}
61
+ attributes.each_key do |name|
62
+ next if name == "xmlns" || name.start_with?("xmlns:")
63
+
64
+ pair = expand(name, namespaces, attribute: true)
65
+ raise ParseError, "duplicate expanded XML attribute" if expanded.key?(pair)
66
+
67
+ expanded[pair] = true
68
+ end
69
+ uri, local = expand(event[0], namespaces)
70
+ lang = attributes.fetch("xml:lang", stack.last&.lang)
71
+ node = Node.new(event[0], uri, local, attributes, namespaces, [], lang)
72
+ stack.empty? ? root = node : append(stack.last, node)
73
+ stack << node
74
+ when :end_element
75
+ raise ParseError, "unbalanced XML elements" if stack.empty? || stack.last.name != event[0]
76
+
77
+ stack.pop
78
+ when :text, :cdata
79
+ raise ParseError, "CDATA outside the root element" if event.event_type == :cdata && stack.empty?
80
+ raise ParseError, "invalid XML text" if event.event_type == :text && event[0].include?("]]>")
81
+
82
+ content = event.event_type == :text ? decode_text(event[0]) : event[0]
83
+ if stack.empty?
84
+ raise ParseError, "text outside the root element" unless content.strip.empty?
85
+ else
86
+ append(stack.last, content)
87
+ end
88
+ when :xmldecl
89
+ raise ParseError, "unsupported XML declaration" unless nodes == 1 && root.nil? && (event[0].nil? || event[0] == "1.0") &&
90
+ (event[1].nil? || %w[UTF-8 US-ASCII].include?(event[1].upcase))
91
+ when :comment
92
+ append(stack.last, Markup.new(:comment, event[0])) unless stack.empty?
93
+ when :processing_instruction
94
+ raise ParseError, "reserved XML processing instruction" if event[0].downcase == "xml"
95
+
96
+ append(stack.last, Markup.new(:instruction, [event[0], event[1]].join(" "))) unless stack.empty?
97
+ when :end_document
98
+ break
99
+ else
100
+ raise ParseError, "DTD and entity declarations are not supported"
101
+ end
102
+ end
103
+ raise ParseError, "incomplete XML document" unless root && stack.empty?
104
+
105
+ root
106
+ rescue REXML::ParseException, EncodingError, ArgumentError, RangeError
107
+ raise ParseError, "invalid XML input"
108
+ end
109
+
110
+ def expand(name, namespaces, attribute: false)
111
+ names = name.split(":", -1)
112
+ raise ParseError, "invalid XML qualified name" if names.size > 2 || names.any?(&:empty?)
113
+
114
+ return [attribute ? "" : namespaces.fetch("", ""), name] if names.size == 1
115
+
116
+ uri = namespaces[names.first]
117
+ raise ParseError, "undeclared XML namespace prefix" unless uri
118
+
119
+ [uri, names.last]
120
+ end
121
+
122
+ def serialize(node)
123
+ writer = Writer.new(@limits)
124
+ write_node(writer, node)
125
+ writer.value
126
+ end
127
+
128
+ def write_node(writer, node, parent_namespaces = {}, detached: true)
129
+ attrs = node.attributes.reject { |key, _| key == "xmlns" || key.start_with?("xmlns:") }
130
+ if detached
131
+ attrs["xmlns"] = "" unless node.namespaces.key?("")
132
+ attrs["xml:lang"] = node.lang || "" unless attrs.key?("xml:lang")
133
+ end
134
+ node.namespaces.each do |prefix, uri|
135
+ next if prefix == "xml" || parent_namespaces[prefix] == uri
136
+
137
+ attrs[prefix.empty? ? "xmlns" : "xmlns:#{prefix}"] = uri
138
+ end
139
+ writer.open(node.name, attrs)
140
+ node.parts.each do |part|
141
+ case part
142
+ when Node then write_node(writer, part, node.namespaces, detached: false)
143
+ when Markup
144
+ writer.append(part.kind == :comment ? "<!--#{part.value}-->" : "<?#{part.value}?>")
145
+ else writer.text(part)
146
+ end
147
+ end
148
+ writer.close(node.name)
149
+ end
150
+
151
+ private
152
+
153
+ def check_characters(value)
154
+ unless value.valid_encoding? && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
155
+ raise ParseError, "XML must contain valid UTF-8 XML 1.0 characters"
156
+ end
157
+ end
158
+
159
+ def decode_text(value)
160
+ result = value.gsub(/&([^&;]*);|&/) do
161
+ reference = Regexp.last_match(1)
162
+ raise ParseError, "invalid XML entity reference" unless reference
163
+
164
+ if ENTITIES.key?(reference)
165
+ ENTITIES.fetch(reference)
166
+ elsif reference.match?(/\A#(?:[0-9]+|x[0-9A-Fa-f]+)\z/)
167
+ number = reference.start_with?("#x") ? reference[2..].to_i(16) : reference[1..].to_i
168
+ raise ParseError, "invalid XML character reference" if number > 0x10ffff
169
+
170
+ [number].pack("U")
171
+ else
172
+ raise ParseError, "unsupported XML entity reference"
173
+ end
174
+ end
175
+ check_characters(result)
176
+ result
177
+ end
178
+
179
+ def append(node, part)
180
+ raise LimitError, "XML content exceeds max_collection_size" if node.parts.size >= @limits[:max_collection_size]
181
+
182
+ node.parts << part
183
+ end
184
+
185
+ class Writer
186
+ attr_reader :value
187
+
188
+ def initialize(limits)
189
+ @maximum = limits[:max_input_size]
190
+ @value = +""
191
+ end
192
+
193
+ def append(value)
194
+ raise LimitError, "output exceeds max_input_size" if @value.bytesize + value.bytesize > @maximum
195
+
196
+ @value << value
197
+ end
198
+
199
+ def text(value, attribute: false)
200
+ if value.is_a?(Float)
201
+ value = format("%.17g", value)
202
+ value += ".0" unless value.match?(/[.eE]/)
203
+ else
204
+ value = value.to_s
205
+ end
206
+ unless Types.string?(value) && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
207
+ raise ConversionError, "value cannot be represented in XML 1.0"
208
+ end
209
+ escapes = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#13;", '"' => "&quot;", "\t" => "&#9;", "\n" => "&#10;" }
210
+ append(value.gsub(attribute ? /[&<>\r"\t\n]/ : /[&<>\r]/) { |char| escapes.fetch(char) })
211
+ end
212
+
213
+ def open(name, attributes = {})
214
+ append("<#{name}")
215
+ attributes.each do |key, value|
216
+ append(" #{key}=\"")
217
+ text(value, attribute: true)
218
+ # Attribute whitespace is emitted as references so XML normalization cannot alter it.
219
+ append('"')
220
+ end
221
+ append(">")
222
+ end
223
+
224
+ def close(name)
225
+ append("</#{name}>")
226
+ end
227
+ end
228
+ end
229
+ end
data/lib/iodef/xml.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../iodef"
4
+ require "rexml/parsers/pullparser"
5
+ require_relative "xml/tree"
6
+ require_relative "xml/codec"
7
+
8
+ module Iodef
9
+ class << self
10
+ def parse_xml(source, strict: true, validate: true, **limits)
11
+ options = self.limits(limits)
12
+ root = XMLTree.new(options).parse(source)
13
+ data = XMLCodec.new(options).decode(root)
14
+ Document.new(data, strict: strict && validate, **options)
15
+ end
16
+ end
17
+
18
+ class Document
19
+ def to_xml
20
+ validate!
21
+ XMLCodec.new(limits).encode(to_h)
22
+ end
23
+ end
24
+ end
data/sig/iodef.rbs CHANGED
@@ -8,6 +8,7 @@ module Iodef
8
8
 
9
9
  def self.parse: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
10
10
  def self.parse_cbor: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
11
+ def self.parse_xml: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
11
12
  def self.validate: (untyped source, **Integer limits) -> Array[Violation]
12
13
  def self.generate_json: (untyped value, ?untyped options) -> String
13
14
  def self.pointer: (String parent, untyped key) -> String
@@ -62,6 +63,7 @@ module Iodef
62
63
  def incidents: () -> Array[Incident]
63
64
  def add_incident: (purpose: String, **untyped attributes) ?{ (Incident) -> untyped } -> Incident
64
65
  def to_cbor: () -> String
66
+ def to_xml: () -> String
65
67
  end
66
68
  class Incident < Model
67
69
  def contacts: () -> Array[Contact]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iodef
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -29,8 +29,28 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '4'
32
- description: RFC 8727 JSON incident reports, typed IODEF v2 models, validation, and
33
- optional CBOR conversion.
32
+ - !ruby/object:Gem::Dependency
33
+ name: rexml
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 3.4.4
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 3.4.4
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ description: RFC 8727 JSON incident reports, typed IODEF v2 models, validation, CBOR,
53
+ and RFC 7970 XML conversion.
34
54
  email:
35
55
  - t.yudai92@gmail.com
36
56
  executables: []
@@ -45,6 +65,7 @@ files:
45
65
  - data/mapkeys.yml
46
66
  - data/types.yml
47
67
  - lib/iodef.rb
68
+ - lib/iodef/binary_cbor.rb
48
69
  - lib/iodef/cbor.rb
49
70
  - lib/iodef/cbor_guard.rb
50
71
  - lib/iodef/errors.rb
@@ -52,6 +73,9 @@ files:
52
73
  - lib/iodef/types.rb
53
74
  - lib/iodef/validator.rb
54
75
  - lib/iodef/version.rb
76
+ - lib/iodef/xml.rb
77
+ - lib/iodef/xml/codec.rb
78
+ - lib/iodef/xml/tree.rb
55
79
  - sig/iodef.rbs
56
80
  homepage: https://github.com/ydah/iodef
57
81
  licenses:
@@ -75,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
99
  - !ruby/object:Gem::Version
76
100
  version: '0'
77
101
  requirements: []
78
- rubygems_version: 4.0.16
102
+ rubygems_version: 3.6.9
79
103
  specification_version: 4
80
104
  summary: Read, validate, and build IODEF incident reports
81
105
  test_files: []