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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -7
  3. data/README.md +29 -88
  4. data/artifacts/context.jsonld +50 -0
  5. data/artifacts/contract.schema.json +206 -0
  6. data/artifacts/core.schema.json +210 -0
  7. data/artifacts/implementation.schema.json +173 -0
  8. data/artifacts/profile.json +27 -0
  9. data/lib/mailschema/artifacts.rb +32 -75
  10. data/lib/mailschema/authentication.rb +77 -0
  11. data/lib/mailschema/contract.rb +184 -281
  12. data/lib/mailschema/description.rb +56 -0
  13. data/lib/mailschema/formats.rb +77 -0
  14. data/lib/mailschema/header.rb +75 -0
  15. data/lib/mailschema/implementation.rb +27 -0
  16. data/lib/mailschema/jcs.rb +11 -10
  17. data/lib/mailschema/json.rb +190 -0
  18. data/lib/mailschema/message.rb +127 -12
  19. data/lib/mailschema/origin.rb +18 -0
  20. data/lib/mailschema/patterns.rb +173 -0
  21. data/lib/mailschema/subschemas.rb +70 -0
  22. data/lib/mailschema/version.rb +1 -1
  23. data/lib/mailschema.rb +16 -26
  24. data/sig/mailschema.rbs +76 -68
  25. metadata +29 -54
  26. data/contexts/map-0.2.jsonld +0 -91
  27. data/lib/mailschema/capability.rb +0 -41
  28. data/lib/mailschema/document.rb +0 -79
  29. data/lib/mailschema/documents.rb +0 -127
  30. data/lib/mailschema/forms.rb +0 -41
  31. data/lib/mailschema/http.rb +0 -25
  32. data/lib/mailschema/limits.rb +0 -56
  33. data/lib/mailschema/pointer.rb +0 -46
  34. data/lib/mailschema/references.rb +0 -62
  35. data/lib/mailschema/schema_walk.rb +0 -29
  36. data/schemas/contribution.schema.json +0 -874
  37. data/schemas/forms-0.1.schema.json +0 -329
  38. data/schemas/map-0.2.schema.json +0 -1033
  39. 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
@@ -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
- # code units, the minimal string escapes of ECMAScript and its number format.
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 ArgumentError, "#{value.class} is not a JSON value"
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 ArgumentError, "object member names must be strings" unless name.is_a?(String)
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 ArgumentError, "strings must be valid UTF-8" unless string.valid_encoding?
48
+ raise TypeError, "Strings must be valid UTF-8." unless string.valid_encoding?
48
49
 
49
50
  string
50
51
  rescue EncodingError
51
- raise ArgumentError, "strings must be valid UTF-8"
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 ArgumentError, "numbers must be finite" unless value.finite?
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
- # the value is 0.digits × 10^point.
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, :quote, :utf8, :number, :shortest
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
@@ -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 Content-Type of the part that carries a description: JSON-LD labelled with the
6
- # profile through the media type's profile parameter.
7
- DESCRIPTION_MEDIA_TYPE = %(application/ld+json; profile="#{PROFILE}").freeze
8
-
9
- # Whether a designated part carries a description of this profile: its media type is
10
- # application/ld+json, compared case-insensitively, and its profile parameter lists
11
- # the profile among its space-separated URIs. A client processes the one such part
12
- # outside any attached message and ignores every other structured part.
13
- def self.description_part?(media_type, profile_parameter)
14
- media_type.to_s.casecmp?("application/ld+json") &&
15
- profile_parameter.to_s.split(/[ \t]+/).include?(PROFILE)
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