iodef 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/SECURITY.md +7 -0
- data/data/enums.yml +40 -0
- data/data/mapkeys.yml +5 -0
- data/data/types.yml +282 -0
- data/lib/iodef/cbor.rb +131 -0
- data/lib/iodef/cbor_guard.rb +123 -0
- data/lib/iodef/errors.rb +21 -0
- data/lib/iodef/model.rb +177 -0
- data/lib/iodef/types.rb +168 -0
- data/lib/iodef/validator.rb +237 -0
- data/lib/iodef/version.rb +5 -0
- data/lib/iodef.rb +40 -0
- data/sig/iodef.rbs +278 -0
- metadata +81 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iodef
|
|
4
|
+
# Bounds allocations and rejects duplicate keys before the native decoder builds objects.
|
|
5
|
+
class CBORGuard
|
|
6
|
+
def initialize(bytes, limits)
|
|
7
|
+
@bytes = bytes
|
|
8
|
+
@limits = limits
|
|
9
|
+
@offset = 0
|
|
10
|
+
@nodes = 0
|
|
11
|
+
@preserved_tags = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate!
|
|
15
|
+
item(0)
|
|
16
|
+
raise ParseError, "trailing CBOR input" unless @offset == @bytes.bytesize
|
|
17
|
+
|
|
18
|
+
# Preserve URI spelling and avoid backend-specific bignum conversion.
|
|
19
|
+
@preserved_tags.reverse_each.with_object(@bytes.dup) do |(offset, size, tag), bytes|
|
|
20
|
+
bytes[offset, size] = [0xd8, tag].pack("C*")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def byte
|
|
27
|
+
result = @bytes.getbyte(@offset)
|
|
28
|
+
raise ParseError, "truncated CBOR input" unless result
|
|
29
|
+
|
|
30
|
+
@offset += 1
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def take(length)
|
|
35
|
+
raise ParseError, "truncated CBOR input" if length > @bytes.bytesize - @offset
|
|
36
|
+
|
|
37
|
+
start = @offset
|
|
38
|
+
@offset += length
|
|
39
|
+
@bytes.byteslice(start, length)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def argument(info)
|
|
43
|
+
return info if info < 24
|
|
44
|
+
return nil if info == 31
|
|
45
|
+
|
|
46
|
+
width = { 24 => 1, 25 => 2, 26 => 4, 27 => 8 }[info]
|
|
47
|
+
raise ParseError, "reserved CBOR additional information" unless width
|
|
48
|
+
|
|
49
|
+
take(width).unpack1({ 1 => "C", 2 => "n", 4 => "N", 8 => "Q>" }.fetch(width))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def item(depth)
|
|
53
|
+
@nodes += 1
|
|
54
|
+
raise LimitError, "CBOR exceeds max_nodes" if @nodes > @limits[:max_nodes]
|
|
55
|
+
raise LimitError, "CBOR exceeds max_depth" if depth > @limits[:max_depth]
|
|
56
|
+
|
|
57
|
+
start = @offset
|
|
58
|
+
initial = byte
|
|
59
|
+
major = initial >> 5
|
|
60
|
+
info = initial & 31
|
|
61
|
+
length = argument(info)
|
|
62
|
+
case major
|
|
63
|
+
when 0, 1
|
|
64
|
+
raise ParseError, "indefinite CBOR integer" unless length
|
|
65
|
+
when 2, 3 then string(major, length, depth)
|
|
66
|
+
when 4, 5 then collection(major, length, depth)
|
|
67
|
+
when 6
|
|
68
|
+
raise ParseError, "indefinite CBOR tag" unless length
|
|
69
|
+
raise ConversionError, "unsupported CBOR tag" unless [0, 2, 3, 22, 23, 32].include?(length)
|
|
70
|
+
|
|
71
|
+
@preserved_tags << [start, @offset - start, { 2 => 253, 3 => 254, 32 => 255 }.fetch(length)] if [2, 3, 32].include?(length)
|
|
72
|
+
item(depth + 1)
|
|
73
|
+
when 7
|
|
74
|
+
raise ParseError, "unexpected CBOR break" if info == 31
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def string(major, length, depth)
|
|
79
|
+
if length
|
|
80
|
+
take(length)
|
|
81
|
+
raise LimitError, "binary exceeds max_binary_size" if major == 2 && length > @limits[:max_binary_size]
|
|
82
|
+
|
|
83
|
+
length
|
|
84
|
+
else
|
|
85
|
+
total = 0
|
|
86
|
+
until @bytes.getbyte(@offset) == 255
|
|
87
|
+
initial = @bytes.getbyte(@offset)
|
|
88
|
+
raise ParseError, "invalid indefinite CBOR string chunk" unless initial && (initial >> 5) == major && (initial & 31) < 28
|
|
89
|
+
|
|
90
|
+
total += item(depth + 1)
|
|
91
|
+
raise LimitError, "binary exceeds max_binary_size" if major == 2 && total > @limits[:max_binary_size]
|
|
92
|
+
end
|
|
93
|
+
byte
|
|
94
|
+
total
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def collection(major, length, depth)
|
|
99
|
+
raise LimitError, "CBOR exceeds max_collection_size" if length && length > @limits[:max_collection_size]
|
|
100
|
+
|
|
101
|
+
count = 0
|
|
102
|
+
keys = {}
|
|
103
|
+
until length ? count == length : @bytes.getbyte(@offset) == 255
|
|
104
|
+
count += 1
|
|
105
|
+
raise LimitError, "CBOR exceeds max_collection_size" if count > @limits[:max_collection_size]
|
|
106
|
+
|
|
107
|
+
if major == 5
|
|
108
|
+
start = @offset
|
|
109
|
+
initial = @bytes.getbyte(start)
|
|
110
|
+
raise ConversionError, "CBOR map keys must be text or integers" unless initial && [0, 1, 3].include?(initial >> 5)
|
|
111
|
+
|
|
112
|
+
item(depth + 1)
|
|
113
|
+
key = CBOR.decode(@bytes.byteslice(start, @offset - start))
|
|
114
|
+
raise ParseError, "duplicate CBOR map key" if keys.key?(key)
|
|
115
|
+
|
|
116
|
+
keys[key] = true
|
|
117
|
+
end
|
|
118
|
+
item(depth + 1)
|
|
119
|
+
end
|
|
120
|
+
byte unless length
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/iodef/errors.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iodef
|
|
4
|
+
Violation = Data.define(:pointer, :rule, :message)
|
|
5
|
+
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ParseError < Error; end
|
|
8
|
+
class ConversionError < Error; end
|
|
9
|
+
|
|
10
|
+
class ValidationError < Error
|
|
11
|
+
attr_reader :violations, :pointer
|
|
12
|
+
|
|
13
|
+
def initialize(message = "invalid IODEF document", pointer: "", violations: [])
|
|
14
|
+
@violations = violations.freeze
|
|
15
|
+
@pointer = violations.first&.pointer || pointer
|
|
16
|
+
super("#{message} at #{@pointer.empty? ? "/" : @pointer}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class LimitError < ValidationError; end
|
|
21
|
+
end
|
data/lib/iodef/model.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iodef
|
|
4
|
+
# Mutable typed views share their document; exported hashes are independent copies.
|
|
5
|
+
class Model
|
|
6
|
+
attr_reader :limits, :warnings
|
|
7
|
+
|
|
8
|
+
def self.schema_type
|
|
9
|
+
name.split("::").last
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(data = {}, strict: true, **limits)
|
|
13
|
+
@limits = Iodef.limits(limits)
|
|
14
|
+
@data = Iodef.copy(data, @limits)
|
|
15
|
+
raise ValidationError, "model must be an object" unless @data.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
@warnings = Validator.new(**@limits).validate(@data, type: self.class.schema_type)
|
|
18
|
+
validate! if strict && !@warnings.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.view(data, limits)
|
|
22
|
+
allocate.tap do |model|
|
|
23
|
+
model.instance_variable_set(:@data, data)
|
|
24
|
+
model.instance_variable_set(:@limits, limits)
|
|
25
|
+
model.instance_variable_set(:@warnings, [].freeze)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def [](key)
|
|
30
|
+
@data[key.to_s]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def []=(key, value)
|
|
34
|
+
@data[key.to_s] = Iodef.copy(value, limits)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(key)
|
|
38
|
+
@data.delete(key.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
Iodef.copy(@data, limits)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate!
|
|
46
|
+
@warnings = Validator.new(**limits).validate(@data, type: self.class.schema_type)
|
|
47
|
+
raise ValidationError.new(violations: @warnings) unless @warnings.empty?
|
|
48
|
+
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_json(options = nil)
|
|
53
|
+
validate!
|
|
54
|
+
json = Iodef.generate_json(@data, options)
|
|
55
|
+
raise LimitError, "output exceeds max_input_size" if json.bytesize > limits[:max_input_size]
|
|
56
|
+
|
|
57
|
+
json
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def description
|
|
61
|
+
MultilingualText.new(@data.fetch("Description", []), lang: @data["lang"])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def description=(value)
|
|
65
|
+
self["Description"] = value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.accessors
|
|
69
|
+
SCHEMA.fetch(schema_type).fetch("fields").each do |field, descriptor|
|
|
70
|
+
method = field.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
|
|
71
|
+
next if method == "description"
|
|
72
|
+
|
|
73
|
+
define_method(method) { read_field(field, descriptor.delete_suffix("!")) }
|
|
74
|
+
define_method("#{method}=") { |value| self[field] = value }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def read_field(field, descriptor)
|
|
81
|
+
value = @data[field]
|
|
82
|
+
return value unless value
|
|
83
|
+
|
|
84
|
+
if descriptor.end_with?("[]") && value.is_a?(Array)
|
|
85
|
+
value.map { |item| wrap(item, descriptor.delete_suffix("[]")) }
|
|
86
|
+
else
|
|
87
|
+
wrap(value, descriptor)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wrap(value, type)
|
|
92
|
+
klass = TYPED[type]
|
|
93
|
+
klass && value.is_a?(Hash) ? klass.view(value, limits) : value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class Document < Model
|
|
98
|
+
def self.build(lang: nil, **limits)
|
|
99
|
+
data = { "version" => "2.0", "Incident" => [] }
|
|
100
|
+
data["lang"] = lang if lang
|
|
101
|
+
document = new(data, strict: false, **limits)
|
|
102
|
+
yield document
|
|
103
|
+
document.validate!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def incidents
|
|
107
|
+
read_field("Incident", "Incident[]") || []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_incident(purpose:, **attributes)
|
|
111
|
+
value = { "purpose" => purpose }.merge(attributes.transform_keys(&:to_s))
|
|
112
|
+
incident = Incident.view(Iodef.copy(value, limits), limits)
|
|
113
|
+
yield incident if block_given?
|
|
114
|
+
@data["Incident"] ||= []
|
|
115
|
+
@data["Incident"] << incident.instance_variable_get(:@data)
|
|
116
|
+
incident
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class Incident < Model
|
|
121
|
+
def contacts
|
|
122
|
+
read_field("Contact", "Contact[]") || []
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def assessments
|
|
126
|
+
read_field("Assessment", "Assessment[]") || []
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def indicators
|
|
130
|
+
read_field("Indicator", "Indicator[]") || []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_contact(role:, type:, **attributes)
|
|
134
|
+
value = { "role" => role, "type" => type }.merge(attributes.transform_keys(&:to_s))
|
|
135
|
+
contact = Contact.view(Iodef.copy(value, limits), limits)
|
|
136
|
+
yield contact if block_given?
|
|
137
|
+
@data["Contact"] ||= []
|
|
138
|
+
@data["Contact"] << contact.instance_variable_get(:@data)
|
|
139
|
+
contact
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class Contact < Model; end
|
|
144
|
+
class Assessment < Model; end
|
|
145
|
+
class EventData < Model; end
|
|
146
|
+
class Indicator < Model; end
|
|
147
|
+
|
|
148
|
+
TYPED = [Document, Incident, Contact, Assessment, EventData, Indicator].to_h { |klass| [klass.schema_type, klass] }.freeze
|
|
149
|
+
TYPED.each_value(&:accessors)
|
|
150
|
+
|
|
151
|
+
class MultilingualText
|
|
152
|
+
include Enumerable
|
|
153
|
+
|
|
154
|
+
def initialize(values, lang: nil)
|
|
155
|
+
@values = values.is_a?(Array) ? values : [values]
|
|
156
|
+
@lang = lang
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def each(&)
|
|
160
|
+
@values.each(&)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Exact matches win; a language prefix and then an unlabelled value are fallbacks.
|
|
164
|
+
def text(language = nil)
|
|
165
|
+
values = @values.map { |value| value.is_a?(String) ? { "value" => value, "lang" => @lang } : value }
|
|
166
|
+
language = language&.downcase
|
|
167
|
+
selected = values.find { |value| value["lang"]&.downcase == language } if language
|
|
168
|
+
selected ||= values.find { |value| value["lang"].to_s.downcase.split("-").first == language.split("-").first } if language
|
|
169
|
+
selected ||= values.find { |value| value["lang"].nil? || value["lang"].empty? }
|
|
170
|
+
(selected || values.first)&.fetch("value", nil)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_a
|
|
174
|
+
Iodef.copy(@values)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/iodef/types.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iodef
|
|
4
|
+
DEFAULT_LIMITS = { max_input_size: 16 * 1024 * 1024, max_depth: 32, max_collection_size: 10_000,
|
|
5
|
+
max_nodes: 200_000, max_binary_size: 1024 * 1024, max_violations: 1000 }.freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def limits(options)
|
|
9
|
+
unknown = options.keys - DEFAULT_LIMITS.keys
|
|
10
|
+
raise ArgumentError, "unknown limits: #{unknown.join(", ")}" unless unknown.empty?
|
|
11
|
+
|
|
12
|
+
DEFAULT_LIMITS.merge(options).tap do |result|
|
|
13
|
+
result.each_value { |value| raise ArgumentError, "limits must be positive integers" unless value.is_a?(Integer) && value.positive? }
|
|
14
|
+
end.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pointer(parent, key)
|
|
18
|
+
"#{parent}/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def freeze_tree(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then value.each do |key, item|
|
|
24
|
+
freeze_tree(key)
|
|
25
|
+
freeze_tree(item)
|
|
26
|
+
end
|
|
27
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
28
|
+
end
|
|
29
|
+
value.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def copy(value, options = DEFAULT_LIMITS, depth: 0, path: "", ancestors: [], budget: [0, 0])
|
|
33
|
+
budget[0] += 1
|
|
34
|
+
raise LimitError.new("value exceeds max_nodes", pointer: path) if budget[0] > options[:max_nodes]
|
|
35
|
+
|
|
36
|
+
value = value.to_h if value.is_a?(Model)
|
|
37
|
+
case value
|
|
38
|
+
when Hash, Array
|
|
39
|
+
raise LimitError.new("value exceeds max_depth", pointer: path) if depth >= options[:max_depth]
|
|
40
|
+
raise LimitError.new("collection exceeds max_collection_size", pointer: path) if value.size > options[:max_collection_size]
|
|
41
|
+
raise ValidationError.new("cyclic input", pointer: path) if ancestors.include?(value.object_id)
|
|
42
|
+
|
|
43
|
+
nested = { depth: depth + 1, ancestors: ancestors + [value.object_id], budget: budget }
|
|
44
|
+
if value.is_a?(Array)
|
|
45
|
+
value.each_with_index.map { |item, index| copy(item, options, path: pointer(path, index), **nested) }
|
|
46
|
+
else
|
|
47
|
+
value.each_with_object({}) do |(key, item), result|
|
|
48
|
+
unless (key.is_a?(String) || key.is_a?(Symbol)) && Types.string?(key.to_s)
|
|
49
|
+
raise ValidationError.new("object keys must be UTF-8 strings", pointer: path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
key = key.to_s
|
|
53
|
+
budget[1] += key.bytesize
|
|
54
|
+
raise LimitError.new("value exceeds max_input_size", pointer: path) if budget[1] > options[:max_input_size]
|
|
55
|
+
|
|
56
|
+
location = pointer(path, key)
|
|
57
|
+
raise ValidationError.new("duplicate object key", pointer: location) if result.key?(key)
|
|
58
|
+
|
|
59
|
+
result[key.dup.force_encoding(Encoding::UTF_8)] = copy(item, options, path: location, **nested)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
when String
|
|
63
|
+
budget[1] += value.bytesize
|
|
64
|
+
raise LimitError.new("value exceeds max_input_size", pointer: path) if budget[1] > options[:max_input_size]
|
|
65
|
+
raise ValidationError.new("invalid UTF-8 string", pointer: path) unless Types.string?(value)
|
|
66
|
+
|
|
67
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
68
|
+
when Float
|
|
69
|
+
raise ValidationError.new("non-finite number", pointer: path) unless value.finite?
|
|
70
|
+
|
|
71
|
+
value
|
|
72
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
73
|
+
else raise ValidationError.new("non-JSON value", pointer: path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def generate_json(value, options = nil)
|
|
78
|
+
value = precise_floats(value) if RUBY_ENGINE == "truffleruby"
|
|
79
|
+
JSON.generate(value, options)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# TruffleRuby's Float#to_s may discard digits needed for a lossless JSON round trip.
|
|
83
|
+
def precise_floats(value)
|
|
84
|
+
case value
|
|
85
|
+
when Hash then value.transform_values { |item| precise_floats(item) }
|
|
86
|
+
when Array then value.map { |item| precise_floats(item) }
|
|
87
|
+
when Float
|
|
88
|
+
number = format("%.17g", value)
|
|
89
|
+
number += ".0" unless number.match?(/[.eE]/)
|
|
90
|
+
JSON::Fragment.new(number)
|
|
91
|
+
else value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private :precise_floats
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
schema = YAML.safe_load_file(File.expand_path("../../data/types.yml", __dir__))
|
|
98
|
+
SCHEMA = freeze_tree(schema.fetch("types").transform_values do |definition|
|
|
99
|
+
fields = definition.fetch("groups", []).each_with_object({}) { |group, result| result.merge!(schema.fetch("groups").fetch(group)) }
|
|
100
|
+
definition.merge("fields" => fields.merge(definition.fetch("fields")))
|
|
101
|
+
end)
|
|
102
|
+
ENUMS = freeze_tree(YAML.safe_load_file(File.expand_path("../../data/enums.yml", __dir__)))
|
|
103
|
+
map = YAML.safe_load_file(File.expand_path("../../data/mapkeys.yml", __dir__))
|
|
104
|
+
MAPKEYS = freeze_tree(map.fetch("names").split.each_with_index.to_h { |name, index| [name, index + map.fetch("first")] })
|
|
105
|
+
KEYNAMES = MAPKEYS.invert.freeze
|
|
106
|
+
|
|
107
|
+
module Types
|
|
108
|
+
module_function
|
|
109
|
+
|
|
110
|
+
def string?(value)
|
|
111
|
+
value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def datetime?(value)
|
|
115
|
+
return false unless string?(value)
|
|
116
|
+
|
|
117
|
+
match = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/.match(value)
|
|
118
|
+
return false unless match
|
|
119
|
+
|
|
120
|
+
year, month, day, hour, minute, second = match.captures.first(6).map(&:to_i)
|
|
121
|
+
zone = match[7]
|
|
122
|
+
Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60 &&
|
|
123
|
+
(zone.match?(/\A[zZ]\z/) || (zone[1, 2].to_i < 24 && zone[4, 2].to_i < 60))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def uri?(value)
|
|
127
|
+
string?(value) && value.ascii_only? && !value.match?(/%(?![0-9a-fA-F]{2})/) && !URI.parse(value).scheme.nil?
|
|
128
|
+
rescue URI::InvalidURIError
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def binary?(value)
|
|
133
|
+
string?(value) && value.match?(%r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}) &&
|
|
134
|
+
[value.unpack1("m0")].pack("m0") == value
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def portlist?(value)
|
|
140
|
+
return false unless string?(value) && value.match?(/\A\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*\z/)
|
|
141
|
+
|
|
142
|
+
value.split(",").all? do |range|
|
|
143
|
+
ports = range.split("-").map(&:to_i)
|
|
144
|
+
ports.all? { |port| port <= 65_535 } && ports.first <= ports.last
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def valid?(type, value)
|
|
149
|
+
return ENUMS.fetch(type).include?(value) if ENUMS.key?(type)
|
|
150
|
+
|
|
151
|
+
case type
|
|
152
|
+
when "string" then string?(value)
|
|
153
|
+
when "integer" then value.is_a?(Integer)
|
|
154
|
+
when "number" then value.is_a?(Numeric) && (!value.is_a?(Float) || value.finite?)
|
|
155
|
+
when "positive" then valid?("number", value) && value.positive?
|
|
156
|
+
when "id" then string?(value) && value.match?(/\A[A-Za-z_][A-Za-z0-9_.-]*\z/)
|
|
157
|
+
when "language" then string?(value) && (value.empty? || value.match?(/\A[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*\z/))
|
|
158
|
+
when "timezone" then string?(value) && value.match?(/\A(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d)\z/)
|
|
159
|
+
when "version" then %w[2.0 2.00].include?(value)
|
|
160
|
+
when "datetime" then datetime?(value)
|
|
161
|
+
when "uri" then uri?(value)
|
|
162
|
+
when "binary" then binary?(value)
|
|
163
|
+
when "portlist" then portlist?(value)
|
|
164
|
+
else false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|