iodef 0.1.0 → 0.2.1

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,231 @@
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
+ PI_TARGET = /\A#{REXML::XMLTokens::NCNAME_STR}\z/u.freeze
12
+
13
+ def initialize(limits)
14
+ @limits = limits
15
+ end
16
+
17
+ def parse(source)
18
+ raise ParseError, "XML input must be a String" unless source.is_a?(String)
19
+ raise LimitError, "input exceeds max_input_size" if source.bytesize > @limits[:max_input_size]
20
+
21
+ source = source.dup.force_encoding(Encoding::UTF_8)
22
+ check_characters(source)
23
+ source.gsub!(/\r\n?/, "\n")
24
+ parser = REXML::Parsers::PullParser.new(source)
25
+ stack = []
26
+ root = nil
27
+ nodes = 0
28
+ loop do
29
+ event = parser.pull
30
+ nodes += 1
31
+ raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
32
+
33
+ case event.event_type
34
+ when :start_element
35
+ raise LimitError, "XML exceeds max_depth" if stack.size >= @limits[:max_depth]
36
+ raise ParseError, "XML must have exactly one root element" if stack.empty? && root
37
+
38
+ attributes = event[1]
39
+ raise LimitError, "XML attributes exceed max_collection_size" if attributes.size > @limits[:max_collection_size]
40
+ raise ParseError, "unescaped XML attribute markup" if attributes.values.any? { |value| value.include?("<") }
41
+ nodes += attributes.size
42
+ raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
43
+
44
+ namespaces = stack.empty? ? { "xml" => XML_NS } : stack.last.namespaces
45
+ if attributes.keys.any? { |name| name == "xmlns" || name.start_with?("xmlns:") }
46
+ nodes += namespaces.size
47
+ raise LimitError, "XML namespace contexts exceed max_nodes" if nodes > @limits[:max_nodes]
48
+
49
+ namespaces = namespaces.dup
50
+ end
51
+ attributes = attributes.transform_values { |value| decode_text(value.gsub(/[\t\r\n]/, " ")) }
52
+ attributes.each do |name, value|
53
+ next unless name == "xmlns" || name.start_with?("xmlns:")
54
+
55
+ prefix = name == "xmlns" ? "" : name.delete_prefix("xmlns:")
56
+ if prefix == "xmlns" || value == XMLNS_NS || (prefix == "xml") != (value == XML_NS) || (!prefix.empty? && value.empty?)
57
+ raise ParseError, "invalid XML namespace declaration"
58
+ end
59
+ namespaces[prefix] = value
60
+ end
61
+ expanded = {}
62
+ attributes.each_key do |name|
63
+ next if name == "xmlns" || name.start_with?("xmlns:")
64
+
65
+ pair = expand(name, namespaces, attribute: true)
66
+ raise ParseError, "duplicate expanded XML attribute" if expanded.key?(pair)
67
+
68
+ expanded[pair] = true
69
+ end
70
+ uri, local = expand(event[0], namespaces)
71
+ lang = attributes.fetch("xml:lang", stack.last&.lang)
72
+ node = Node.new(event[0], uri, local, attributes, namespaces, [], lang)
73
+ stack.empty? ? root = node : append(stack.last, node)
74
+ stack << node
75
+ when :end_element
76
+ raise ParseError, "unbalanced XML elements" if stack.empty? || stack.last.name != event[0]
77
+
78
+ stack.pop
79
+ when :text, :cdata
80
+ raise ParseError, "CDATA outside the root element" if event.event_type == :cdata && stack.empty?
81
+ raise ParseError, "invalid XML text" if event.event_type == :text && event[0].include?("]]>")
82
+
83
+ content = event.event_type == :text ? decode_text(event[0]) : event[0]
84
+ if stack.empty?
85
+ raise ParseError, "text outside the root element" unless content.strip.empty?
86
+ else
87
+ append(stack.last, content)
88
+ end
89
+ when :xmldecl
90
+ raise ParseError, "unsupported XML declaration" unless nodes == 1 && root.nil? && (event[0].nil? || event[0] == "1.0") &&
91
+ (event[1].nil? || %w[UTF-8 US-ASCII].include?(event[1].upcase))
92
+ when :comment
93
+ append(stack.last, Markup.new(:comment, event[0])) unless stack.empty?
94
+ when :processing_instruction
95
+ raise ParseError, "invalid XML processing instruction target" unless PI_TARGET.match?(event[0])
96
+ raise ParseError, "reserved XML processing instruction" if event[0].downcase == "xml"
97
+
98
+ append(stack.last, Markup.new(:instruction, [event[0], event[1]].join(" "))) unless stack.empty?
99
+ when :end_document
100
+ break
101
+ else
102
+ raise ParseError, "DTD and entity declarations are not supported"
103
+ end
104
+ end
105
+ raise ParseError, "incomplete XML document" unless root && stack.empty?
106
+
107
+ root
108
+ rescue REXML::ParseException, EncodingError, ArgumentError, RangeError
109
+ raise ParseError, "invalid XML input"
110
+ end
111
+
112
+ def expand(name, namespaces, attribute: false)
113
+ names = name.split(":", -1)
114
+ raise ParseError, "invalid XML qualified name" if names.size > 2 || names.any?(&:empty?)
115
+
116
+ return [attribute ? "" : namespaces.fetch("", ""), name] if names.size == 1
117
+
118
+ uri = namespaces[names.first]
119
+ raise ParseError, "undeclared XML namespace prefix" unless uri
120
+
121
+ [uri, names.last]
122
+ end
123
+
124
+ def serialize(node)
125
+ writer = Writer.new(@limits)
126
+ write_node(writer, node)
127
+ writer.value
128
+ end
129
+
130
+ def write_node(writer, node, parent_namespaces = {}, detached: true)
131
+ attrs = node.attributes.reject { |key, _| key == "xmlns" || key.start_with?("xmlns:") }
132
+ if detached
133
+ attrs["xmlns"] = "" unless node.namespaces.key?("")
134
+ attrs["xml:lang"] = node.lang || "" unless attrs.key?("xml:lang")
135
+ end
136
+ node.namespaces.each do |prefix, uri|
137
+ next if prefix == "xml" || parent_namespaces[prefix] == uri
138
+
139
+ attrs[prefix.empty? ? "xmlns" : "xmlns:#{prefix}"] = uri
140
+ end
141
+ writer.open(node.name, attrs)
142
+ node.parts.each do |part|
143
+ case part
144
+ when Node then write_node(writer, part, node.namespaces, detached: false)
145
+ when Markup
146
+ writer.append(part.kind == :comment ? "<!--#{part.value}-->" : "<?#{part.value}?>")
147
+ else writer.text(part)
148
+ end
149
+ end
150
+ writer.close(node.name)
151
+ end
152
+
153
+ private
154
+
155
+ def check_characters(value)
156
+ unless value.valid_encoding? && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
157
+ raise ParseError, "XML must contain valid UTF-8 XML 1.0 characters"
158
+ end
159
+ end
160
+
161
+ def decode_text(value)
162
+ result = value.gsub(/&([^&;]*);|&/) do
163
+ reference = Regexp.last_match(1)
164
+ raise ParseError, "invalid XML entity reference" unless reference
165
+
166
+ if ENTITIES.key?(reference)
167
+ ENTITIES.fetch(reference)
168
+ elsif reference.match?(/\A#(?:[0-9]+|x[0-9A-Fa-f]+)\z/)
169
+ number = reference.start_with?("#x") ? reference[2..].to_i(16) : reference[1..].to_i
170
+ raise ParseError, "invalid XML character reference" if number > 0x10ffff
171
+
172
+ [number].pack("U")
173
+ else
174
+ raise ParseError, "unsupported XML entity reference"
175
+ end
176
+ end
177
+ check_characters(result)
178
+ result
179
+ end
180
+
181
+ def append(node, part)
182
+ raise LimitError, "XML content exceeds max_collection_size" if node.parts.size >= @limits[:max_collection_size]
183
+
184
+ node.parts << part
185
+ end
186
+
187
+ class Writer
188
+ attr_reader :value
189
+
190
+ def initialize(limits)
191
+ @maximum = limits[:max_input_size]
192
+ @value = +""
193
+ end
194
+
195
+ def append(value)
196
+ raise LimitError, "output exceeds max_input_size" if @value.bytesize + value.bytesize > @maximum
197
+
198
+ @value << value
199
+ end
200
+
201
+ def text(value, attribute: false)
202
+ if value.is_a?(Float)
203
+ value = format("%.17g", value)
204
+ value += ".0" unless value.match?(/[.eE]/)
205
+ else
206
+ value = value.to_s
207
+ end
208
+ unless Types.string?(value) && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
209
+ raise ConversionError, "value cannot be represented in XML 1.0"
210
+ end
211
+ escapes = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#13;", '"' => "&quot;", "\t" => "&#9;", "\n" => "&#10;" }
212
+ append(value.gsub(attribute ? /[&<>\r"\t\n]/ : /[&<>\r]/) { |char| escapes.fetch(char) })
213
+ end
214
+
215
+ def open(name, attributes = {})
216
+ append("<#{name}")
217
+ attributes.each do |key, value|
218
+ append(" #{key}=\"")
219
+ text(value, attribute: true)
220
+ # Attribute whitespace is emitted as references so XML normalization cannot alter it.
221
+ append('"')
222
+ end
223
+ append(">")
224
+ end
225
+
226
+ def close(name)
227
+ append("</#{name}>")
228
+ end
229
+ end
230
+ end
231
+ 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.1
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: []