jcard_jcal 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.
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JCardJCal
4
+ # Shared conversion of component trees and their property values.
5
+ class Converter
6
+ def initialize(format:, registry: nil, strict: true, max_depth: 32, max_properties: 10_000,
7
+ max_line_length: 1_048_576, max_binary_size: 1_048_576, max_input_size: 16_777_216)
8
+ @format = format
9
+ @registry = registry || (format == :vcard ? JCard::Registry.default : JCal::Registry.default)
10
+ @strict = strict
11
+ @limits = { depth: max_depth, properties: max_properties, line_length: max_line_length,
12
+ binary_size: max_binary_size, input_size: max_input_size }
13
+ raise ArgumentError, "limits must be positive integers" unless @limits.values.all? { |n| n.is_a?(Integer) && n.positive? }
14
+
15
+ @property_count = 0
16
+ end
17
+
18
+ def parse_json(source)
19
+ check_source(source)
20
+ data = JSON.parse(source, max_nesting: (@limits[:depth] * 3) + 10, allow_duplicate_key: false)
21
+ expected = @format == :vcard ? "vcard" : "vcalendar"
22
+ raise ParseError, "expected #{expected} component" unless data.is_a?(Array) && data.first == expected
23
+
24
+ read_component(data, "", 1)
25
+ rescue JSON::ParserError => e
26
+ raise ParseError, e.message
27
+ end
28
+
29
+ def parse_text(source)
30
+ check_source(source)
31
+ roots = []
32
+ stack = []
33
+ ContentLine::Reader.new(source, max_line_length: @limits[:line_length]).each do |line|
34
+ case line.name
35
+ when "begin"
36
+ begin_component(line, stack)
37
+ when "end"
38
+ end_component(line, stack, roots)
39
+ else
40
+ raise ParseError.new("property outside a component", line_number: line.line_number) if stack.empty?
41
+
42
+ count_property!(line_number: line.line_number)
43
+ prop = read_property(line)
44
+ stack.last[:properties] << prop
45
+ stack.last[:warnings].concat(prop.warnings)
46
+ end
47
+ end
48
+ raise ParseError, "missing END content line" unless stack.empty?
49
+ raise ParseError, "no component found" if roots.empty?
50
+ return roots if @format == :vcard
51
+
52
+ raise ParseError, "expected exactly one VCALENDAR" unless roots.size == 1
53
+
54
+ roots.first
55
+ end
56
+
57
+ def self.write(component, format:, registry: nil, max_binary_size: 1_048_576)
58
+ new(format: format, registry: registry, max_binary_size: max_binary_size).write_component(component)
59
+ end
60
+
61
+ def write_component(component)
62
+ writer = ContentLine::Writer
63
+ result = writer.line(name: "begin", params: {}, value: component.name.upcase)
64
+ properties = component.properties
65
+ properties = properties.partition { |property| property.name == "version" }.flatten if @format == :vcard
66
+ properties.each { |property| result << write_property(property) }
67
+ component.components.each { |child| result << write_component(child) }
68
+ result << writer.line(name: "end", params: {}, value: component.name.upcase)
69
+ end
70
+
71
+ private
72
+
73
+ def check_source(source)
74
+ raise ParseError, "input must be a string" unless source.is_a?(String)
75
+ raise ParseError, "input size limit exceeded" if source.bytesize > @limits[:input_size]
76
+ end
77
+
78
+ def count_property!(**location)
79
+ @property_count += 1
80
+ raise ParseError.new("property count limit exceeded", **location) if @property_count > @limits[:properties]
81
+ end
82
+
83
+ def read_component(data, pointer, depth)
84
+ raise ParseError.new("component depth limit exceeded", pointer: pointer) if depth > @limits[:depth]
85
+
86
+ size = @format == :vcard ? 2 : 3
87
+ valid = data.is_a?(Array) && data.size == size && data[0].is_a?(String) && /\A[a-z0-9-]+\z/.match?(data[0]) && data[1].is_a?(Array)
88
+ valid &&= data[2].is_a?(Array) if @format == :ical
89
+ raise ParseError.new("invalid component structure", pointer: pointer) unless valid
90
+
91
+ props = data[1].each_with_index.map do |raw, index|
92
+ path = "#{pointer}/1/#{index}"
93
+ count_property!(pointer: path)
94
+ property_class.new(raw, **property_options, pointer: path)
95
+ end
96
+ children = @format == :ical ? data[2].each_with_index.map { |child, index| read_component(child, "#{pointer}/2/#{index}", depth + 1) } : []
97
+ build_component(data[0], props, children, props.flat_map(&:warnings) + children.flat_map(&:warnings))
98
+ end
99
+
100
+ def begin_component(line, stack)
101
+ validate_boundary(line)
102
+ name = line.value.downcase
103
+ expected = @format == :vcard ? "vcard" : "vcalendar"
104
+ raise ParseError.new("expected #{expected} component", line_number: line.line_number) if stack.empty? && name != expected
105
+ if (@format == :vcard && !stack.empty?) || stack.size >= @limits[:depth]
106
+ raise ParseError.new("component depth limit exceeded", line_number: line.line_number)
107
+ end
108
+
109
+ stack << { name: name, properties: [], children: [], warnings: [] }
110
+ end
111
+
112
+ def end_component(line, stack, roots)
113
+ validate_boundary(line)
114
+ raise ParseError.new("BEGIN/END mismatch", line_number: line.line_number) if stack.empty? || stack.last[:name] != line.value.downcase
115
+
116
+ frame = stack.pop
117
+ component = build_component(frame[:name], frame[:properties], frame[:children], frame[:warnings])
118
+ if stack.empty?
119
+ roots << component
120
+ else
121
+ stack.last[:children] << component
122
+ stack.last[:warnings].concat(component.warnings)
123
+ end
124
+ end
125
+
126
+ def validate_boundary(line)
127
+ return if line.group.nil? && line.params.empty? && /\A[a-zA-Z0-9-]+\z/.match?(line.value)
128
+
129
+ raise ParseError.new("invalid component boundary", line_number: line.line_number)
130
+ end
131
+
132
+ def build_component(name, properties, children, warnings)
133
+ klass = if @format == :vcard
134
+ JCard::Card
135
+ elsif name == "vcalendar"
136
+ JCal::Calendar
137
+ else
138
+ JCal::Component
139
+ end
140
+ result = klass.new(name, properties, children, warnings: warnings, registry: @registry, max_binary_size: @limits[:binary_size])
141
+ begin
142
+ result.validate!
143
+ rescue ValidationError => e
144
+ raise if @strict || e.is_a?(LimitError)
145
+
146
+ warnings << e unless warnings.any? { |warning| warning.message == e.message }
147
+ end
148
+ result
149
+ end
150
+
151
+ def property_class = @format == :vcard ? JCard::Property : JCal::Property
152
+ def property_options = { registry: @registry, strict: @strict, max_binary_size: @limits[:binary_size] }
153
+
154
+ def read_property(line)
155
+ params = line.params.dup
156
+ if @format == :ical && (line.group || params["encoding"].is_a?(Array))
157
+ raise ValidationError.new("iCalendar disallows groups and multiple ENCODING values", line_number: line.line_number)
158
+ end
159
+
160
+ explicit_type = params.delete("value")
161
+ raise ParseError.new("VALUE must have one value", line_number: line.line_number) if explicit_type.is_a?(Array)
162
+ if (@format == :vcard && params.key?("group")) || explicit_type&.casecmp?("unknown")
163
+ raise ValidationError.new("reserved GROUP or UNKNOWN declaration", line_number: line.line_number)
164
+ end
165
+
166
+ params["group"] = line.group if line.group
167
+ params["sort-as"] = params["sort-as"].split(",", -1) if @format == :vcard && params["sort-as"].is_a?(String) && params["sort-as"].include?(",")
168
+ definition = @registry[line.name]
169
+ type = explicit_type ? explicit_type.downcase : definition["type"]
170
+ begin
171
+ value = line.value
172
+ if @format == :ical && type != "binary" && params["encoding"]&.casecmp?("BASE64")
173
+ ValueTypes.from_json(value, "binary", format: :ical, max_binary_size: @limits[:binary_size])
174
+ value = value.unpack1("m0").force_encoding(Encoding::UTF_8)
175
+ params.delete("encoding")
176
+ end
177
+ values = parse_values(value, type, definition)
178
+ property_class.new([line.name, params, type, *values], **property_options, strict: true, line_number: line.line_number)
179
+ rescue ValueError, ValidationError => e
180
+ located = e.class.new(e.message, line_number: line.line_number)
181
+ raise located if @strict || e.is_a?(LimitError)
182
+
183
+ property = property_class.new([line.name, params, "unknown", line.value], **property_options, line_number: line.line_number)
184
+ property.warnings << located
185
+ property
186
+ end
187
+ end
188
+
189
+ def parse_values(value, type, definition)
190
+ if definition["structured"] && type != "unknown"
191
+ parts = ContentLine::TextCodec.split(value, ";").map do |part|
192
+ raw_items = definition["component_multiple"] ? ContentLine::TextCodec.split(part, ",") : [part]
193
+ items = raw_items.map { |item| parse_scalar(item, type) }
194
+ items.size == 1 ? items.first : items
195
+ end
196
+ parts.size == 1 ? parts : [parts]
197
+ else
198
+ values = definition["multiple"] && type != "unknown" ? ContentLine::TextCodec.split(value, ",") : [value]
199
+ values.map { |item| parse_scalar(item, type) }
200
+ end
201
+ end
202
+
203
+ def parse_scalar(value, type)
204
+ ValueTypes.from_text(value, type, format: @format, max_binary_size: @limits[:binary_size])
205
+ end
206
+
207
+ def write_property(property)
208
+ property.validate!
209
+ definition = @registry[property.name]
210
+ params = property.params
211
+ group = @format == :vcard ? params.delete("group") : nil
212
+ params["sort-as"] = params["sort-as"].join(",") if @format == :vcard && params["sort-as"].is_a?(Array)
213
+ params["encoding"] = "BASE64" if @format == :ical && property.type == "binary"
214
+ params["value"] = property.type.upcase if property.type != "unknown" && (property.type != definition["type"] || definition["value_required"])
215
+ values = property.values.map do |value|
216
+ if value.is_a?(Array) && (definition["structured"] || property.type == "text")
217
+ value.map { |part| Array(part).map { |item| write_scalar(item, property.type) }.join(",") }.join(";")
218
+ else
219
+ write_scalar(value, property.type)
220
+ end
221
+ end
222
+ ContentLine::Writer.line(name: property.name, params: params, value: values.join(","), group: group)
223
+ end
224
+
225
+ def write_scalar(value, type)
226
+ ValueTypes.to_text(value, type, format: @format, max_binary_size: @limits[:binary_size])
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JCardJCal
4
+ # Location-aware failure in a content line or JSON value.
5
+ class Error < StandardError
6
+ attr_reader :line_number, :pointer
7
+
8
+ def initialize(message, line_number: nil, pointer: nil)
9
+ @line_number = line_number
10
+ @pointer = pointer
11
+ location = line_number ? "line #{line_number}" : pointer
12
+ super(location ? "#{location}: #{message}" : message)
13
+ end
14
+ end
15
+
16
+ class ParseError < Error; end
17
+ class ValidationError < Error; end
18
+ class ValueError < ValidationError; end
19
+ class LimitError < ValueError; end
20
+ class ConversionError < Error; end
21
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JCardJCal
4
+ # A jCard/jCal property, retaining unrecognized parameters and values.
5
+ class Property
6
+ attr_reader :warnings
7
+
8
+ def initialize(data, registry: nil, strict: true, pointer: "", line_number: nil, max_binary_size: 1_048_576)
9
+ @registry = registry || default_registry
10
+ @pointer = pointer
11
+ @line_number = line_number
12
+ @max_binary_size = max_binary_size
13
+ @warnings = []
14
+ @data = data
15
+ unless data.is_a?(Array) && data.size >= 4 && data[0].is_a?(String) && data[1].is_a?(Hash) && data[2].is_a?(String)
16
+ raise ParseError.new("property must contain a name, parameters, type and value", pointer: pointer, line_number: line_number)
17
+ end
18
+
19
+ validate!
20
+ @data = copy(data)
21
+ rescue ValidationError => e
22
+ raise if strict || e.is_a?(LimitError)
23
+
24
+ @warnings << e
25
+ @data = copy(data)
26
+ end
27
+
28
+ def name = @data[0]
29
+ def params = copy(@data[1])
30
+ def type = @data[2]
31
+ def values = copy(@data[3..])
32
+ def group = @data[1]["group"]
33
+ def to_a = copy(@data)
34
+
35
+ def validate!
36
+ fail_at("property must contain a name, parameters, type and value") unless @data.is_a?(Array) && @data.size >= 4
37
+ @data[3..].each_with_index { |value, index| validate_binary_size(value, "/#{index + 3}") } if @data[2].casecmp?("binary")
38
+ fail_at("invalid property name", "/0") unless token?(@data[0]) && !%w[begin end].include?(@data[0])
39
+ validate_params
40
+ fail_at("invalid value type", "/2") unless token?(@data[2])
41
+ definition = @registry[@data[0]]
42
+ allowed = definition["types"]
43
+ if @data[2] != "unknown" && (definition["type"] != "unknown" || definition["value_required"]) && allowed && !allowed.include?(@data[2])
44
+ fail_at("value type is not allowed for this property", "/2")
45
+ end
46
+ fail_at("property requires a single value", "/3") if definition["source"] && !definition["multiple"] && @data.size != 4
47
+ @data[3..].each_with_index { |value, index| validate_value(value, "/#{index + 3}") }
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ def copy(value)
54
+ JSON.parse(JSON.generate(value))
55
+ rescue JSON::GeneratorError => e
56
+ raise ParseError.new(e.message, pointer: @pointer, line_number: @line_number)
57
+ end
58
+
59
+ def token?(value) = value.is_a?(String) && /\A[a-z0-9-]+\z/.match?(value)
60
+
61
+ def validate_params
62
+ fail_at("parameters must be an object", "/1") unless @data[1].is_a?(Hash)
63
+ @data[1].each do |key, value|
64
+ path = "/1/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
65
+ fail_at("invalid parameter name", path) unless token?(key) && key != "value"
66
+ valid = value.is_a?(String) || (value.is_a?(Array) && !value.empty? && value.all?(String))
67
+ fail_at("parameter must be a string or nonempty array of strings", path) unless valid
68
+ validate_parameter_value(value, path)
69
+ fail_at("invalid group parameter", path) if format == :vcard && key == "group" && (!value.is_a?(String) || !/\A[a-zA-Z0-9-]+\z/.match?(value))
70
+ end
71
+ end
72
+
73
+ def validate_parameter_value(value, path)
74
+ Array(value).each { |item| ContentLine::ParamCodec.encode(item) }
75
+ rescue ValueError => e
76
+ fail_at(e.message, path, e.class)
77
+ end
78
+
79
+ def validate_value(value, path)
80
+ definition = @registry[name]
81
+ structured = definition["structured"] || (!definition["source"] && type == "text" && value.is_a?(Array))
82
+ return validate_scalar(value, path) unless structured && type != "unknown"
83
+
84
+ parts = value.is_a?(Array) ? value : [value]
85
+ counts = definition["components"]
86
+ fail_at("invalid number of structured components", path) if parts.empty? || (counts && !counts.include?(parts.size))
87
+ parts.each_with_index do |part, index|
88
+ if part.is_a?(Array)
89
+ fail_at("component lists are not allowed", "#{path}/#{index}") unless (definition["component_multiple"] || !definition["source"]) && !part.empty?
90
+ part.each_with_index { |item, subindex| validate_scalar(item, "#{path}/#{index}/#{subindex}") }
91
+ else
92
+ validate_scalar(part, "#{path}/#{index}")
93
+ end
94
+ end
95
+ end
96
+
97
+ def validate_scalar(value, path)
98
+ ValueTypes.from_json(value, type, format: format, max_binary_size: @max_binary_size)
99
+ rescue ValueError => e
100
+ fail_at(e.message, path, e.class)
101
+ end
102
+
103
+ def validate_binary_size(value, path)
104
+ ValueTypes.validate_binary_size(value, @max_binary_size)
105
+ rescue LimitError => e
106
+ fail_at(e.message, path, LimitError)
107
+ end
108
+
109
+ def fail_at(message, suffix = "", error_class = ValidationError)
110
+ raise error_class.new(message, pointer: "#{@pointer}#{suffix}", line_number: @line_number)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module JCardJCal
6
+ # Per-conversion registry: duplicate a frozen default to add application properties.
7
+ class Registry
8
+ UNKNOWN = { "type" => "unknown", "types" => ["unknown"].freeze, "multiple" => false,
9
+ "structured" => false, "source" => nil }.freeze
10
+
11
+ def initialize(entries = {})
12
+ @entries = {}
13
+ entries.each { |name, metadata| register(name, **metadata.transform_keys(&:to_sym)) }
14
+ end
15
+
16
+ def initialize_copy(other)
17
+ super
18
+ @entries = @entries.dup
19
+ end
20
+
21
+ def [](name)
22
+ @entries.fetch(name.to_s.downcase, UNKNOWN)
23
+ end
24
+
25
+ def register(name, type:, types: nil, multiple: false, structured: false, **metadata)
26
+ raise FrozenError, "duplicate the default registry before registering properties" if frozen?
27
+
28
+ name = name.to_s.downcase
29
+ type = type.to_s.downcase
30
+ types = (types || [type]).map { |item| item.to_s.downcase }
31
+ unless /\A[a-z0-9-]+\z/.match?(name) && /\A[a-z0-9-]+\z/.match?(type) &&
32
+ !types.empty? && types.all? { |item| /\A[a-z0-9-]+\z/.match?(item) }
33
+ raise ArgumentError, "invalid property name or value type"
34
+ end
35
+
36
+ @entries[name] = immutable(metadata.transform_keys(&:to_s).merge(
37
+ "type" => type, "types" => types, "multiple" => multiple, "structured" => structured,
38
+ "source" => metadata[:source]
39
+ ))
40
+ self
41
+ end
42
+
43
+ def freeze
44
+ @entries.freeze
45
+ super
46
+ end
47
+
48
+ def self.load(filename)
49
+ new(YAML.safe_load_file(File.expand_path("../../data/#{filename}", __dir__))).freeze
50
+ end
51
+
52
+ private
53
+
54
+ def immutable(value)
55
+ case value
56
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
57
+ when Array then value.map { |item| immutable(item) }.freeze
58
+ when String then value.dup.freeze
59
+ else value.freeze
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ module JCard # rubocop:disable Style/OneClassPerFile
66
+ class Registry < JCardJCal::Registry
67
+ DEFAULT = load("vcard_properties.yml")
68
+
69
+ def self.default
70
+ DEFAULT
71
+ end
72
+ end
73
+ end
74
+
75
+ module JCal # rubocop:disable Style/OneClassPerFile
76
+ class Registry < JCardJCal::Registry
77
+ DEFAULT = load("ical_properties.yml")
78
+
79
+ def self.default
80
+ DEFAULT
81
+ end
82
+ end
83
+ end