mailschema 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +4 -7
- data/README.md +29 -88
- data/artifacts/context.jsonld +50 -0
- data/artifacts/contract.schema.json +206 -0
- data/artifacts/core.schema.json +210 -0
- data/artifacts/implementation.schema.json +173 -0
- data/artifacts/profile.json +27 -0
- data/lib/mailschema/artifacts.rb +32 -75
- data/lib/mailschema/authentication.rb +77 -0
- data/lib/mailschema/contract.rb +184 -281
- data/lib/mailschema/description.rb +56 -0
- data/lib/mailschema/formats.rb +77 -0
- data/lib/mailschema/header.rb +75 -0
- data/lib/mailschema/implementation.rb +27 -0
- data/lib/mailschema/jcs.rb +11 -10
- data/lib/mailschema/json.rb +190 -0
- data/lib/mailschema/message.rb +127 -12
- data/lib/mailschema/origin.rb +18 -0
- data/lib/mailschema/patterns.rb +173 -0
- data/lib/mailschema/subschemas.rb +70 -0
- data/lib/mailschema/version.rb +1 -1
- data/lib/mailschema.rb +16 -26
- data/sig/mailschema.rbs +76 -68
- metadata +29 -54
- data/contexts/map-0.2.jsonld +0 -91
- data/lib/mailschema/capability.rb +0 -41
- data/lib/mailschema/document.rb +0 -79
- data/lib/mailschema/documents.rb +0 -127
- data/lib/mailschema/forms.rb +0 -41
- data/lib/mailschema/http.rb +0 -25
- data/lib/mailschema/limits.rb +0 -56
- data/lib/mailschema/pointer.rb +0 -46
- data/lib/mailschema/references.rb +0 -62
- data/lib/mailschema/schema_walk.rb +0 -29
- data/schemas/contribution.schema.json +0 -874
- data/schemas/forms-0.1.schema.json +0 -329
- data/schemas/map-0.2.schema.json +0 -1033
- data/schemas/type-contract-0.2.schema.json +0 -179
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailschema
|
|
4
|
+
# The formats MAP schemas may assert, defined as the reference implementation asserts them
|
|
5
|
+
# (ajv-formats' full definitions), so a value has a format here exactly when it has it there.
|
|
6
|
+
# Its uri and email expressions are ASCII-only and case-insensitive; Ruby folds case beyond
|
|
7
|
+
# ASCII, so other text is refused before they run.
|
|
8
|
+
module Formats
|
|
9
|
+
DATE = /\A(\d\d\d\d)-(\d\d)-(\d\d)\z/
|
|
10
|
+
DAYS = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
11
|
+
# The time of a date-time, whose zone ajv-formats requires.
|
|
12
|
+
TIME = /\A(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)\z/i
|
|
13
|
+
# A date-time splits at t or at ECMAScript's \s.
|
|
14
|
+
SEPARATOR = /[tT\t\n\v\f\r\u{2028}\u{2029}\u{FEFF}\p{Zs}]/
|
|
15
|
+
EMAIL = Regexp.new(<<~'PATTERN'.delete("\n"), Regexp::IGNORECASE)
|
|
16
|
+
\A[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+
|
|
17
|
+
[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z
|
|
18
|
+
PATTERN
|
|
19
|
+
URI = Regexp.new(<<~'PATTERN'.delete("\n"), Regexp::IGNORECASE)
|
|
20
|
+
\A(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}
|
|
21
|
+
|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a
|
|
22
|
+
-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4}
|
|
23
|
+
)?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[
|
|
24
|
+
01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a
|
|
25
|
+
-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3
|
|
26
|
+
}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@
|
|
27
|
+
]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*
|
|
28
|
+
)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-
|
|
29
|
+
._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?\z
|
|
30
|
+
PATTERN
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def date?(value)
|
|
34
|
+
match = DATE.match(value) or return false
|
|
35
|
+
year, month, day = match.captures.map(&:to_i)
|
|
36
|
+
month.between?(1, 12) && day.between?(1, month == 2 && leap?(year) ? 29 : DAYS[month])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def date_time?(value)
|
|
40
|
+
parts = value.split(SEPARATOR, -1)
|
|
41
|
+
parts.size == 2 && date?(parts[0]) && time?(parts[1])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def email?(value) = value.ascii_only? && EMAIL.match?(value)
|
|
45
|
+
|
|
46
|
+
def uri?(value) = value.ascii_only? && value.match?(%r{[/:]}) && URI.match?(value)
|
|
47
|
+
|
|
48
|
+
# The checks json_schemer runs: these four, on strings only, and no other format.
|
|
49
|
+
def checks
|
|
50
|
+
own = { "date" => method(:date?), "date-time" => method(:date_time?), "email" => method(:email?),
|
|
51
|
+
"uri" => method(:uri?) }
|
|
52
|
+
JSONSchemer::Draft202012::FORMATS.transform_values { false }.merge(
|
|
53
|
+
own.transform_values { |check| ->(value, _format) { !value.is_a?(String) || check.call(value) } }
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def leap?(year) = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
|
|
60
|
+
|
|
61
|
+
# A time with a zone, allowing a leap second only at 23:59 UTC.
|
|
62
|
+
def time?(value)
|
|
63
|
+
match = TIME.match(value) or return false
|
|
64
|
+
hour, minute, zone_hour, zone_minute = match.values_at(1, 2, 6, 7).map(&:to_i)
|
|
65
|
+
second = match[3].to_f
|
|
66
|
+
return false if zone_hour > 23 || zone_minute > 59
|
|
67
|
+
return true if hour <= 23 && minute <= 59 && second < 60
|
|
68
|
+
|
|
69
|
+
sign = match[5] == "-" ? -1 : 1
|
|
70
|
+
utc_minute = minute - (zone_minute * sign)
|
|
71
|
+
utc_hour = hour - (zone_hour * sign) - (utc_minute.negative? ? 1 : 0)
|
|
72
|
+
[23, -1].include?(utc_hour) && [59, -1].include?(utc_minute) && second < 61
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_constant :Formats
|
|
77
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailschema
|
|
4
|
+
# Header fields read from an entity's original bytes. A parsed message collapses repeated
|
|
5
|
+
# fields and keeps the last of repeated parameters, so MAP's uniqueness rules are checked
|
|
6
|
+
# here, where different parsers cannot read one header differently.
|
|
7
|
+
module Header
|
|
8
|
+
FIELD = /\A([!-9;-~]+):[ \t]*(.*)\z/m
|
|
9
|
+
PARAMETER = /\A\s*([!\#$%&'*+.^_`|~0-9A-Za-z-]+)\s*=/
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# The unfolded fields of a raw entity's header block as [lowercase name, value] pairs, or
|
|
13
|
+
# nil when it is malformed. Lines end with CRLF. `lax` also accepts LF, and skips the
|
|
14
|
+
# line break a MIME part's raw source keeps from its boundary line.
|
|
15
|
+
def fields(raw, lax: false)
|
|
16
|
+
raw = raw.b
|
|
17
|
+
block, separator, = raw.partition(lax ? /\r?\n\r?\n/ : "\r\n\r\n")
|
|
18
|
+
return if separator.empty?
|
|
19
|
+
|
|
20
|
+
lines = block.split(lax ? /\r?\n/ : "\r\n", -1)
|
|
21
|
+
unfold(lax ? lines.drop_while(&:empty?) : lines)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether every parameter of a Content-Type value has its own name. RFC 2231 sections and
|
|
25
|
+
# charset markers belong to the name they extend, so profile and profile* are one name.
|
|
26
|
+
def unique_parameters?(value)
|
|
27
|
+
segments = parameters(value) or return false
|
|
28
|
+
names = segments.drop(1).map do |segment|
|
|
29
|
+
name = PARAMETER.match(segment)&.[](1) or return false
|
|
30
|
+
name.downcase.sub(/\*.*\z/, "")
|
|
31
|
+
end
|
|
32
|
+
names.uniq.size == names.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# The fields of header lines, each folded line joined to its field, or nil when a line is
|
|
38
|
+
# neither or holds another line break.
|
|
39
|
+
def unfold(lines)
|
|
40
|
+
lines.each_with_object([]) do |line, fields|
|
|
41
|
+
return nil if line.match?(/[\r\n]/)
|
|
42
|
+
|
|
43
|
+
if line.start_with?(" ", "\t")
|
|
44
|
+
return nil if fields.empty?
|
|
45
|
+
|
|
46
|
+
fields.last[1] << " " << line.strip
|
|
47
|
+
else
|
|
48
|
+
name, value = FIELD.match(line)&.captures
|
|
49
|
+
return nil unless name
|
|
50
|
+
|
|
51
|
+
fields << [name.downcase, value]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The value split at each semicolon outside a quoted string, or nil when a quoted string
|
|
57
|
+
# or an escape is left open.
|
|
58
|
+
def parameters(value)
|
|
59
|
+
segments = [+""]
|
|
60
|
+
quoted = escaped = false
|
|
61
|
+
value.each_char do |char|
|
|
62
|
+
next segments << +"" if char == ";" && !quoted
|
|
63
|
+
|
|
64
|
+
if escaped then escaped = false
|
|
65
|
+
elsif quoted && char == "\\" then escaped = true
|
|
66
|
+
elsif char == '"' then quoted = !quoted
|
|
67
|
+
end
|
|
68
|
+
segments.last << char
|
|
69
|
+
end
|
|
70
|
+
segments unless quoted || escaped
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_constant :Header
|
|
75
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An implementation record: a Registry declaration or report of a service's support for exact
|
|
4
|
+
# contract versions.
|
|
5
|
+
module Mailschema
|
|
6
|
+
# Why the value is not an implementation record, or no reasons.
|
|
7
|
+
def self.implementation_errors(value)
|
|
8
|
+
errors = Schemas.errors(Schemas::IMPLEMENTATION, value)
|
|
9
|
+
return errors if errors.any?
|
|
10
|
+
|
|
11
|
+
origin = Origin.of(value["service"])
|
|
12
|
+
unless origin && [origin, "#{origin}/"].include?(value["service"])
|
|
13
|
+
errors << "/service: must be an HTTPS origin, not an operation path"
|
|
14
|
+
end
|
|
15
|
+
errors << "/operations: identifiers must be unique" if value["operations"].uniq.size != value["operations"].size
|
|
16
|
+
errors
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The record in the bytes or text, or InvalidDocument with every reason it is not one.
|
|
20
|
+
def self.parse_implementation(input)
|
|
21
|
+
value = parse(input, CONTRACT_MAX_BYTES)
|
|
22
|
+
errors = implementation_errors(value)
|
|
23
|
+
raise InvalidDocument.new("The value is not an implementation record.", errors) if errors.any?
|
|
24
|
+
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/mailschema/jcs.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mailschema
|
|
4
|
-
# The JSON Canonicalization Scheme of RFC 8785: members sorted by their UTF-16
|
|
5
|
-
#
|
|
4
|
+
# The JSON Canonicalization Scheme of RFC 8785: members sorted by their UTF-16 code units,
|
|
5
|
+
# ECMAScript's minimal string escapes and its number format.
|
|
6
6
|
module JCS
|
|
7
7
|
ESCAPES = {
|
|
8
8
|
'"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f",
|
|
@@ -23,13 +23,13 @@ module Mailschema
|
|
|
23
23
|
when true then "true"
|
|
24
24
|
when false then "false"
|
|
25
25
|
when nil then "null"
|
|
26
|
-
else raise
|
|
26
|
+
else raise TypeError, "#{value.class} is not a JSON value."
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def object(value)
|
|
31
31
|
members = value.map do |name, member|
|
|
32
|
-
raise
|
|
32
|
+
raise TypeError, "Object member names must be strings." unless name.is_a?(String)
|
|
33
33
|
|
|
34
34
|
[utf8(name).encode(Encoding::UTF_16BE).unpack("n*"), name, member]
|
|
35
35
|
end
|
|
@@ -37,6 +37,7 @@ module Mailschema
|
|
|
37
37
|
"{#{pairs.join(",")}}"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# The string as JSON.stringify writes it.
|
|
40
41
|
def quote(value)
|
|
41
42
|
escaped = utf8(value).gsub(/["\\\x00-\x1f]/) { |char| ESCAPES.fetch(char) { format("\\u%04x", char.ord) } }
|
|
42
43
|
%("#{escaped}")
|
|
@@ -44,16 +45,16 @@ module Mailschema
|
|
|
44
45
|
|
|
45
46
|
def utf8(value)
|
|
46
47
|
string = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
47
|
-
raise
|
|
48
|
+
raise TypeError, "Strings must be valid UTF-8." unless string.valid_encoding?
|
|
48
49
|
|
|
49
50
|
string
|
|
50
51
|
rescue EncodingError
|
|
51
|
-
raise
|
|
52
|
+
raise TypeError, "Strings must be valid UTF-8."
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# ECMA-262 Number::toString, from the shortest round-trip digits Float#to_s gives.
|
|
55
56
|
def number(value)
|
|
56
|
-
raise
|
|
57
|
+
raise TypeError, "Numbers must be finite." unless value.finite?
|
|
57
58
|
return "0" if value.zero?
|
|
58
59
|
return "-#{number(-value)}" if value.negative?
|
|
59
60
|
|
|
@@ -71,8 +72,8 @@ module Mailschema
|
|
|
71
72
|
end
|
|
72
73
|
end
|
|
73
74
|
|
|
74
|
-
# The significant digits of a positive double, and where its decimal point falls:
|
|
75
|
-
#
|
|
75
|
+
# The significant digits of a positive double, and where its decimal point falls: the
|
|
76
|
+
# value is 0.digits x 10^point.
|
|
76
77
|
def shortest(value)
|
|
77
78
|
mantissa, exponent = value.to_s.split("e")
|
|
78
79
|
whole, fraction = mantissa.split(".")
|
|
@@ -81,7 +82,7 @@ module Mailschema
|
|
|
81
82
|
[digits[leading..].sub(/0+\z/, ""), whole.length + exponent.to_i - leading]
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
private_class_method :object, :
|
|
85
|
+
private_class_method :object, :utf8, :number, :shortest
|
|
85
86
|
end
|
|
86
87
|
private_constant :JCS
|
|
87
88
|
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MAP JSON: I-JSON parsed from its original text, and the RFC 8785 digests that identify
|
|
4
|
+
# contracts and descriptions.
|
|
5
|
+
module Mailschema
|
|
6
|
+
# A document that is not MAP JSON, or not the document it claims to be.
|
|
7
|
+
class InvalidDocument < StandardError
|
|
8
|
+
attr_reader :errors
|
|
9
|
+
|
|
10
|
+
def initialize(message, errors = [message])
|
|
11
|
+
super(message)
|
|
12
|
+
@errors = errors
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
MAX_DEPTH = 32
|
|
17
|
+
|
|
18
|
+
# Parse MAP JSON from its original text or bytes: UTF-8 I-JSON (RFC 7493) of at most
|
|
19
|
+
# `max_bytes`, with no byte order mark, duplicate member names, lone surrogates,
|
|
20
|
+
# noncharacters or U+0000, no container deeper than 32 counting the root as 1, and every
|
|
21
|
+
# number a finite token of at most 64 characters within +/-(2^53-1) that does not underflow
|
|
22
|
+
# to zero. These rules apply to the original tokens, before a general parser could erase
|
|
23
|
+
# them. A number with no fractional part is an Integer: I-JSON numbers are doubles, so 1 and
|
|
24
|
+
# 1.0 are one number.
|
|
25
|
+
def self.parse(input, max_bytes)
|
|
26
|
+
text = utf8(input)
|
|
27
|
+
raise InvalidDocument, "A byte order mark is not allowed." if text.start_with?("\u{FEFF}")
|
|
28
|
+
raise InvalidDocument, "The JSON exceeds #{max_bytes} bytes." if text.bytesize > max_bytes
|
|
29
|
+
|
|
30
|
+
Reader.new(text).document
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The RFC 8785 canonical form of a JSON value.
|
|
34
|
+
def self.canonicalize(value) = JCS.canonicalize(value)
|
|
35
|
+
|
|
36
|
+
# `sha-256:` and the SHA-256 of the value's RFC 8785 form, as MAP identifies contracts.
|
|
37
|
+
def self.digest(value) = "sha-256:#{Digest::SHA256.hexdigest(canonicalize(value))}"
|
|
38
|
+
|
|
39
|
+
NOT_UTF8 = "The JSON is not valid UTF-8."
|
|
40
|
+
private_constant :NOT_UTF8
|
|
41
|
+
|
|
42
|
+
# The text, from bytes or a string in any encoding, refusing anything but well-formed UTF-8.
|
|
43
|
+
def self.utf8(input)
|
|
44
|
+
raise TypeError, "MAP JSON is text or bytes." unless input.is_a?(String)
|
|
45
|
+
|
|
46
|
+
text = input.encoding == Encoding::BINARY ? input.dup.force_encoding(Encoding::UTF_8) : input.encode(Encoding::UTF_8)
|
|
47
|
+
return text if text.valid_encoding?
|
|
48
|
+
|
|
49
|
+
raise InvalidDocument, NOT_UTF8
|
|
50
|
+
rescue EncodingError
|
|
51
|
+
raise InvalidDocument, NOT_UTF8
|
|
52
|
+
end
|
|
53
|
+
private_class_method :utf8
|
|
54
|
+
|
|
55
|
+
# json.ts's reader: the grammar of RFC 8259 over the text, failing at the first token that
|
|
56
|
+
# breaks a MAP rule.
|
|
57
|
+
class Reader
|
|
58
|
+
MAX_NUMBER_TOKEN = 64
|
|
59
|
+
EXACT = (2**53) - 1
|
|
60
|
+
# Half the smallest subnormal: a nonzero token at or below it rounds to zero.
|
|
61
|
+
TINY = Rational(1, 2**1075)
|
|
62
|
+
NUMBER = /-?(0|[1-9][0-9]*)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?/
|
|
63
|
+
STRING = /"((?>[^"\\]+|\\.)*)"/m
|
|
64
|
+
WELL_FORMED = %r{\A(?>[^\\\x00-\x1f]+|\\["\\/bfnrt]|\\u\h{4})*\z}
|
|
65
|
+
UNESCAPE = { '"' => '"', "\\" => "\\", "/" => "/", "b" => "\b", "f" => "\f", "n" => "\n", "r" => "\r",
|
|
66
|
+
"t" => "\t" }.freeze
|
|
67
|
+
LITERALS = { "true" => true, "false" => false, "null" => nil }.freeze
|
|
68
|
+
|
|
69
|
+
def initialize(text)
|
|
70
|
+
@scanner = StringScanner.new(text)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def document
|
|
74
|
+
result = value(0)
|
|
75
|
+
space
|
|
76
|
+
refuse("trailing content") unless @scanner.eos?
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The offset counts UTF-16 code units, as json.ts reports it.
|
|
83
|
+
def refuse(reason, beyond = 0)
|
|
84
|
+
offset = (@scanner.string.byteslice(0, @scanner.pos).encode(Encoding::UTF_16LE).bytesize / 2) + beyond
|
|
85
|
+
raise InvalidDocument, "Invalid MAP JSON at offset #{offset}: #{reason}."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def space = @scanner.skip(/[ \t\n\r]*/)
|
|
89
|
+
|
|
90
|
+
# `depth` counts the containers enclosing the value.
|
|
91
|
+
def value(depth)
|
|
92
|
+
space
|
|
93
|
+
case @scanner.peek(1)
|
|
94
|
+
when "{", "["
|
|
95
|
+
refuse("nesting deeper than #{MAX_DEPTH}") if depth >= MAX_DEPTH
|
|
96
|
+
@scanner.getch == "{" ? object(depth + 1) : array(depth + 1)
|
|
97
|
+
when '"' then string
|
|
98
|
+
else
|
|
99
|
+
literal = @scanner.scan(/true|false|null/)
|
|
100
|
+
literal ? LITERALS.fetch(literal) : number
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def object(depth)
|
|
105
|
+
result = {}
|
|
106
|
+
space
|
|
107
|
+
return result if @scanner.skip(/\}/)
|
|
108
|
+
|
|
109
|
+
loop do
|
|
110
|
+
space
|
|
111
|
+
refuse("expected a member name") unless @scanner.peek(1) == '"'
|
|
112
|
+
name = string
|
|
113
|
+
refuse("duplicate member #{JCS.quote(name)}") if result.key?(name)
|
|
114
|
+
space
|
|
115
|
+
refuse('expected ":"') unless @scanner.skip(/:/)
|
|
116
|
+
result[name] = value(depth)
|
|
117
|
+
space
|
|
118
|
+
next if @scanner.skip(/,/)
|
|
119
|
+
return result if @scanner.skip(/\}/)
|
|
120
|
+
|
|
121
|
+
refuse('expected "," or "}"')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def array(depth)
|
|
126
|
+
result = []
|
|
127
|
+
space
|
|
128
|
+
return result if @scanner.skip(/\]/)
|
|
129
|
+
|
|
130
|
+
loop do
|
|
131
|
+
result << value(depth)
|
|
132
|
+
space
|
|
133
|
+
next if @scanner.skip(/,/)
|
|
134
|
+
return result if @scanner.skip(/\]/)
|
|
135
|
+
|
|
136
|
+
refuse('expected "," or "]"')
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def string
|
|
141
|
+
unless @scanner.scan(STRING)
|
|
142
|
+
# json.ts steps over an escape's two characters, past the end when the text ends in one.
|
|
143
|
+
escape = @scanner.rest[1..][/\\*\z/].length.odd?
|
|
144
|
+
@scanner.terminate
|
|
145
|
+
refuse("unterminated string", escape ? 1 : 0)
|
|
146
|
+
end
|
|
147
|
+
refuse("malformed string") unless WELL_FORMED.match?(@scanner[1])
|
|
148
|
+
text = unescape(@scanner[1]) or refuse("lone surrogate")
|
|
149
|
+
refuse("U+0000 or a noncharacter") if text.each_codepoint.any? { |point| forbidden?(point) }
|
|
150
|
+
text
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# JSON escapes name UTF-16 code units, so a string with escapes is rebuilt in UTF-16,
|
|
154
|
+
# where a lone surrogate is invalid.
|
|
155
|
+
def unescape(body)
|
|
156
|
+
return body unless body.include?("\\")
|
|
157
|
+
|
|
158
|
+
units = body.scan(/\\u(\h{4})|\\(.)|([^\\]+)/m).flat_map do |code, escape, text|
|
|
159
|
+
if code then [code.hex]
|
|
160
|
+
elsif escape then [UNESCAPE.fetch(escape).ord]
|
|
161
|
+
else text.encode(Encoding::UTF_16LE).unpack("v*")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
text = units.pack("v*").force_encoding(Encoding::UTF_16LE)
|
|
165
|
+
text.encode(Encoding::UTF_8) if text.valid_encoding?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def forbidden?(point) = point.zero? || point.between?(0xfdd0, 0xfdef) || point & 0xfffe == 0xfffe
|
|
169
|
+
|
|
170
|
+
def number
|
|
171
|
+
token = @scanner.scan(NUMBER) or refuse("unexpected token")
|
|
172
|
+
refuse("a number token longer than #{MAX_NUMBER_TOKEN} characters") if token.length > MAX_NUMBER_TOKEN
|
|
173
|
+
digits = "#{@scanner[1]}#{@scanner[2]}".sub(/\A0+/, "")
|
|
174
|
+
return 0 if digits.empty?
|
|
175
|
+
|
|
176
|
+
# Float rounds correctly but warns when a token overflows or underflows, so the decimal
|
|
177
|
+
# exponent settles both first. The value lies in [10^(magnitude-1), 10^magnitude).
|
|
178
|
+
scale = @scanner[3].to_i - @scanner[2].to_s.length
|
|
179
|
+
magnitude = digits.length + scale
|
|
180
|
+
refuse("a number outside the I-JSON range") if magnitude > 17
|
|
181
|
+
if magnitude < -323 || (magnitude == -323 && Rational(digits.to_i, 10**-scale) <= TINY)
|
|
182
|
+
refuse("a number that underflows to zero")
|
|
183
|
+
end
|
|
184
|
+
number = Float(token)
|
|
185
|
+
refuse("a number outside the I-JSON range") if number.abs > EXACT
|
|
186
|
+
number.to_i == number ? number.to_i : number
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_constant :Reader
|
|
190
|
+
end
|
data/lib/mailschema/message.rb
CHANGED
|
@@ -1,17 +1,132 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The Structured Email binding: how a message carries a description.
|
|
4
3
|
module Mailschema
|
|
5
|
-
# The
|
|
6
|
-
# profile
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
# The MAP part of an email (Core, "Email representation"): the one designated description of
|
|
5
|
+
# this profile, in a multipart/related entity whose root is the readable content it
|
|
6
|
+
# describes. A description inside an attachment or an attached message is never read.
|
|
7
|
+
module Message
|
|
8
|
+
# What a message carries: status :none; :invalid, with the reason the part cannot be used;
|
|
9
|
+
# or :valid, with the description.
|
|
10
|
+
Result = Data.define(:status, :reason, :description)
|
|
11
|
+
|
|
12
|
+
NONE = Result.new(status: :none, reason: nil, description: nil)
|
|
13
|
+
READABLE = %w[text/plain text/html].freeze
|
|
14
|
+
TRANSFER_ENCODINGS = %w[base64 quoted-printable].freeze
|
|
15
|
+
SINGLE_FIELDS = %w[content-type content-purpose content-transfer-encoding].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# The MAP description of a received Mail::Message, read from its original bytes (for
|
|
19
|
+
# example with Mail.new(raw)). It never raises for untrusted input. Reasons:
|
|
20
|
+
# :multiple_parts, :ambiguous_headers, :transfer_encoding, :media_type,
|
|
21
|
+
# :not_partial_representation, :invalid_description and :unreadable.
|
|
22
|
+
def extract(mail)
|
|
23
|
+
designated = entities(mail).select { |part, _parent| designated?(part) }
|
|
24
|
+
return NONE if designated.empty?
|
|
25
|
+
return invalid(:multiple_parts) if designated.size > 1
|
|
26
|
+
|
|
27
|
+
part, parent = designated.first
|
|
28
|
+
reason = problem(part, parent)
|
|
29
|
+
return invalid(reason) if reason
|
|
30
|
+
|
|
31
|
+
Result.new(status: :valid, reason: nil, description: Mailschema.parse_description(part.body.decoded.b))
|
|
32
|
+
rescue InvalidDocument
|
|
33
|
+
invalid(:invalid_description)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
invalid(:unreadable)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The MIME part that carries a description: compact JSON in quoted-printable, since one
|
|
39
|
+
# line of JSON is usually longer than SMTP's 998 octets and 8bit cannot carry it. It
|
|
40
|
+
# raises InvalidDocument unless that JSON is a valid description.
|
|
41
|
+
def part(description)
|
|
42
|
+
json = JSON.generate(description)
|
|
43
|
+
Mailschema.parse_description(json)
|
|
44
|
+
Mail::Part.new.tap do |part|
|
|
45
|
+
part.content_type = "#{DESCRIPTION_MEDIA_TYPE}; charset=UTF-8"
|
|
46
|
+
part.content_transfer_encoding = "quoted-printable"
|
|
47
|
+
part.header["Content-Purpose"] = "Machine-readable"
|
|
48
|
+
# Mail takes a body assigned under a transfer encoding as already encoded.
|
|
49
|
+
part.body = Mail::Encodings::QuotedPrintable.encode(json)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def invalid(reason) = Result.new(status: :invalid, reason: reason, description: nil)
|
|
56
|
+
|
|
57
|
+
# Every part with its parent, at any depth. An attachment or an attached message is a
|
|
58
|
+
# leaf, so nothing inside one is read.
|
|
59
|
+
def entities(entity)
|
|
60
|
+
entity.parts.flat_map do |part|
|
|
61
|
+
attached?(part) || part.mime_type == "message/rfc822" ? [] : [[part, entity], *entities(part)]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def attached?(part) = part.content_disposition.to_s.downcase.start_with?("attachment")
|
|
66
|
+
|
|
67
|
+
# Every Content-Purpose the part declares, however many times it repeats it.
|
|
68
|
+
def machine_readable?(part)
|
|
69
|
+
part.header.fields.any? do |field|
|
|
70
|
+
field.name.casecmp?("Content-Purpose") && field.value.to_s.casecmp?("Machine-readable")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A designated part labelled with this profile, judged from its original header lines,
|
|
75
|
+
# before a parser picks one of repeated fields or parameters.
|
|
76
|
+
def designated?(part)
|
|
77
|
+
return false unless machine_readable?(part)
|
|
78
|
+
|
|
79
|
+
types = content_types(part)
|
|
80
|
+
types.any? { |value| value.downcase.include?("application/ld+json") } &&
|
|
81
|
+
types.any? { |value| value.include?(PROFILE) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def content_types(entity)
|
|
85
|
+
Array(Header.fields(entity.raw_source.to_s, lax: true)).filter_map do |name, value|
|
|
86
|
+
value if name == "content-type"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def problem(part, parent)
|
|
91
|
+
return :ambiguous_headers unless unambiguous?(part)
|
|
92
|
+
return :transfer_encoding unless TRANSFER_ENCODINGS.include?(part.content_transfer_encoding.to_s.downcase)
|
|
93
|
+
return :media_type unless description_part?(part)
|
|
94
|
+
|
|
95
|
+
:not_partial_representation unless partial_representation?(parent)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Each field that decides how the part is read appears once, with each parameter once.
|
|
99
|
+
def unambiguous?(part)
|
|
100
|
+
fields = Header.fields(part.raw_source.to_s, lax: true) or return false
|
|
101
|
+
counts = fields.map(&:first).tally
|
|
102
|
+
SINGLE_FIELDS.all? { |name| counts[name] == 1 } && Header.unique_parameters?(fields.assoc("content-type").last)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def description_part?(part)
|
|
106
|
+
part.mime_type == "application/ld+json" && part.content_type_parameters&.[]("profile") == PROFILE
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A multipart/related entity with one unambiguous Content-Type, whose root (its start
|
|
110
|
+
# parameter's part, or else its first) is readable.
|
|
111
|
+
def partial_representation?(entity)
|
|
112
|
+
return false unless entity.mime_type == "multipart/related"
|
|
113
|
+
|
|
114
|
+
types = content_types(entity)
|
|
115
|
+
return false unless types.one? && Header.unique_parameters?(types.first)
|
|
116
|
+
|
|
117
|
+
start = entity.content_type_parameters&.[]("start")
|
|
118
|
+
roots = start.to_s.empty? ? entity.parts.first(1) : entity.parts.select { |part| content_id(part) == start }
|
|
119
|
+
roots.one? && readable?(roots.first)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def content_id(part) = part.header["Content-ID"]&.value.to_s.strip
|
|
123
|
+
|
|
124
|
+
def readable?(part)
|
|
125
|
+
return false if attached?(part)
|
|
126
|
+
return part.parts.any? { |alternative| readable?(alternative) } if part.mime_type == "multipart/alternative"
|
|
127
|
+
|
|
128
|
+
READABLE.include?(part.mime_type) && !part.body.decoded.b.strip.empty?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
16
131
|
end
|
|
17
132
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailschema
|
|
4
|
+
# The RFC 6454 origin of an absolute URL, serialized as the WHATWG URL parser does: its
|
|
5
|
+
# scheme, its lowercased host, and its port unless that is the scheme's default.
|
|
6
|
+
module Origin
|
|
7
|
+
def self.of(url)
|
|
8
|
+
uri = URI.parse(url)
|
|
9
|
+
return if uri.scheme.nil? || uri.host.to_s.empty?
|
|
10
|
+
|
|
11
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
12
|
+
"#{uri.scheme.downcase}://#{uri.host.downcase}#{port}"
|
|
13
|
+
rescue URI::InvalidURIError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
private_constant :Origin
|
|
18
|
+
end
|