tungsten-runtime 0.1.2

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,353 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # Parameter and body serialization, and the request's headers.
7
+ module Serialize
8
+ # A value that cannot be encoded becomes VALIDATION_FAILED.
9
+ SerializationError = Data.define(:parameter, :expected, :value)
10
+
11
+ # A body ready for the wire: `bytes` (a binary String) or `parts`
12
+ # (multipart), `content_type`, the redacted `display` and the bytes the
13
+ # content hash is computed over (`hashed`, nil without a body).
14
+ Encoded = Data.define(:empty, :bytes, :parts, :content_type, :display, :hashed)
15
+ EMPTY = Encoded.new(empty: true, bytes: nil, parts: nil, content_type: nil, display: nil, hashed: nil)
16
+
17
+ # One multipart part: `kind` :text, :json or :file.
18
+ Part = Data.define(:name, :kind, :text, :data, :filename, :content_type)
19
+
20
+ module_function
21
+
22
+ # String(value) for a scalar, JSON text for anything else.
23
+ def scalar(value)
24
+ case value
25
+ when String then value
26
+ when Integer, Float then Json.number_text(value)
27
+ when true, false then value.to_s
28
+ else Json.text(value)
29
+ end
30
+ end
31
+
32
+ def escape(text, keep)
33
+ text.to_s.b.each_byte.map do |b|
34
+ c = b.chr
35
+ c.match?(/[A-Za-z0-9]/) || keep.include?(c) ? c : format("%%%02X", b)
36
+ end.join
37
+ end
38
+
39
+ # encodeURIComponent.
40
+ def encode_component(text) = escape(text, "-_.!~*'()")
41
+
42
+ # One component of a form body (URLSearchParams): spaces as `+`.
43
+ def encode_form_component(text)
44
+ text.to_s.b.each_byte.map do |b|
45
+ c = b.chr
46
+ if c.match?(/[A-Za-z0-9]/) || "*-._".include?(c) then c
47
+ elsif c == " " then "+"
48
+ else format("%%%02X", b)
49
+ end
50
+ end.join
51
+ end
52
+
53
+ # RFC 3986 percent-encoding.
54
+ def percent_encode(text) = escape(text, "-._~")
55
+
56
+ def encoded_items(items) = items.reject(&:nil?).map { |v| encode_component(scalar(v)) }
57
+
58
+ def encoded_pairs(object)
59
+ object.reject { |_, v| v.nil? }.map { |k, v| [encode_component(k), encode_component(scalar(v))] }
60
+ end
61
+
62
+ # One path parameter value, percent-encoded.
63
+ def path_param(p, value)
64
+ name = encode_component(p.wire)
65
+ style = %w[label matrix].include?(p.style) ? p.style : "simple"
66
+ case value
67
+ when Array
68
+ items = encoded_items(value)
69
+ return ".#{items.join(p.explode ? '.' : ',')}" if style == "label"
70
+ return items.map { |i| ";#{name}=#{i}" }.join if style == "matrix" && p.explode
71
+ return ";#{name}=#{items.join(',')}" if style == "matrix"
72
+
73
+ items.join(",")
74
+ when Hash
75
+ pairs = encoded_pairs(value)
76
+ exploded = pairs.map { |k, v| "#{k}=#{v}" }
77
+ flat = pairs.flatten
78
+ return ".#{exploded.join('.')}" if style == "label" && p.explode
79
+ return ".#{flat.join(',')}" if style == "label"
80
+ return exploded.map { |e| ";#{e}" }.join if style == "matrix" && p.explode
81
+ return ";#{name}=#{flat.join(',')}" if style == "matrix"
82
+
83
+ p.explode ? exploded.join(",") : flat.join(",")
84
+ else
85
+ val = encode_component(scalar(value))
86
+ case style
87
+ when "label" then ".#{val}"
88
+ when "matrix" then ";#{name}=#{val}"
89
+ else val
90
+ end
91
+ end
92
+ end
93
+
94
+ def deep_object_pairs(prefix, value, out, depth)
95
+ return if depth > 16 || value.nil?
96
+
97
+ case value
98
+ when Array then value.each { |item| deep_object_pairs(prefix, item, out, depth + 1) }
99
+ when Hash
100
+ value.each do |k, v|
101
+ deep_object_pairs("#{prefix}[#{encode_component(k)}]", v, out, depth + 1) unless v.nil?
102
+ end
103
+ else out << "#{prefix}=#{encode_component(scalar(value))}"
104
+ end
105
+ end
106
+
107
+ # The query parts (name=value, encoded) of one parameter.
108
+ def query_param(p, value)
109
+ return [] if value.nil?
110
+
111
+ name = encode_component(p.wire)
112
+ delimiter = { "space_delimited" => "%20", "pipe_delimited" => "|" }.fetch(p.style, ",")
113
+ if value.is_a?(Hash) && p.style == "deep_object"
114
+ out = []
115
+ deep_object_pairs(name, value, out, 0)
116
+ return out
117
+ end
118
+ case value
119
+ when Array
120
+ items = encoded_items(value)
121
+ return [] if items.empty?
122
+ return items.map { |i| "#{name}=#{i}" } if p.explode || p.style == "deep_object"
123
+
124
+ ["#{name}=#{items.join(delimiter)}"]
125
+ when Hash
126
+ pairs = encoded_pairs(value)
127
+ return pairs.map { |k, v| "#{k}=#{v}" } if p.explode
128
+
129
+ ["#{name}=#{pairs.flatten.join(delimiter)}"]
130
+ else ["#{name}=#{encode_component(scalar(value))}"]
131
+ end
132
+ end
133
+
134
+ # A header parameter (simple style, not encoded).
135
+ def header_param(p, value)
136
+ case value
137
+ when Array then value.reject(&:nil?).map { |v| scalar(v) }.join(",")
138
+ when Hash
139
+ value.reject { |_, v| v.nil? }.flat_map { |k, v| p.explode ? ["#{k}=#{scalar(v)}"] : [k, scalar(v)] }.join(",")
140
+ else scalar(value)
141
+ end
142
+ end
143
+
144
+ # name=value of a cookie parameter.
145
+ def cookie_param(p, value)
146
+ case value
147
+ when Array then "#{p.wire}=#{encoded_items(value).join(',')}"
148
+ when Hash then "#{p.wire}=#{encoded_pairs(value).flatten.join(',')}"
149
+ else "#{p.wire}=#{encode_component(scalar(value))}"
150
+ end
151
+ end
152
+
153
+ # The body before encoding: merged fields picked from the arguments
154
+ # (under their wire names) or the body argument; wrapped in the rpc
155
+ # envelope when the operation has one. UNSET without a body.
156
+ def body_value(op, args, param_names)
157
+ value = UNSET
158
+ if (b = op.body)
159
+ if b.arg
160
+ value = args.fetch(b.arg, UNSET)
161
+ else
162
+ picked = {}
163
+ b.fields.each { |name, wire| picked[wire] = args[name] if args.key?(name) }
164
+ value = picked if !picked.empty? || b.required || op.rpc
165
+ end
166
+ end
167
+ rpc = op.rpc
168
+ return value unless rpc
169
+
170
+ params =
171
+ if op.body.nil? then args.reject { |k, _| param_names.include?(k) }
172
+ elsif !UNSET.equal?(value) then value
173
+ else {}
174
+ end
175
+ envelope = {}
176
+ (rpc["constants"] || {}).each { |k, v| envelope[k] = v }
177
+ envelope[rpc["field"]] = rpc["value"]
178
+ envelope[rpc["params_field"]] = params
179
+ envelope
180
+ end
181
+
182
+ def binary_display(n, media) = media.to_s.empty? ? "<#{n} bytes>" : "<#{n} bytes of #{media}>"
183
+
184
+ def form_pairs(value)
185
+ return SerializationError.new(parameter: "body", expected: "an object of form fields", value: value) unless value.is_a?(Hash)
186
+
187
+ out = []
188
+ value.each do |k, item|
189
+ next if item.nil?
190
+
191
+ if item.is_a?(Array)
192
+ item.each { |x| out << [k, scalar(x)] unless x.nil? }
193
+ else
194
+ out << [k, scalar(item)]
195
+ end
196
+ end
197
+ out
198
+ end
199
+
200
+ # Encode a body per its descriptor; a SerializationError when the value
201
+ # does not fit. `redact` renders the shown body.
202
+ def encode_body(desc, value, redact)
203
+ return EMPTY if UNSET.equal?(value)
204
+
205
+ encoding = desc ? desc.encoding : "json"
206
+ media = desc ? desc.media_type : "application/json"
207
+ case encoding
208
+ when "form"
209
+ pairs = form_pairs(value)
210
+ return pairs if pairs.is_a?(SerializationError)
211
+
212
+ text = pairs.map { |k, v| "#{encode_form_component(k)}=#{encode_form_component(v)}" }.join("&")
213
+ Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: redact.call(pairs.to_h),
214
+ hashed: text.b)
215
+ when "multipart" then multipart(value, redact)
216
+ when "bytes"
217
+ bin = Json.binary_of(value)
218
+ data =
219
+ if value.is_a?(String) then value.b
220
+ elsif !UNSET.equal?(bin) then bin.data
221
+ else
222
+ return SerializationError.new(parameter: "body", expected: "binary data (a Binary value)", value: value)
223
+ end
224
+ Encoded.new(empty: false, bytes: data, parts: nil, content_type: media,
225
+ display: binary_display(data.bytesize, media), hashed: data)
226
+ when "text"
227
+ unless value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
228
+ return SerializationError.new(parameter: "body", expected: "a string", value: value)
229
+ end
230
+
231
+ text = scalar(value)
232
+ Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: text, hashed: text.b)
233
+ else
234
+ text = Json.text(value)
235
+ raw = Json.try_parse(text)
236
+ if UNSET.equal?(raw)
237
+ return SerializationError.new(parameter: "body", expected: "a JSON-serializable value", value: value)
238
+ end
239
+
240
+ Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: redact.call(raw),
241
+ hashed: Json.canonical(raw).b)
242
+ end
243
+ end
244
+
245
+ def multipart(value, redact)
246
+ unless value.is_a?(Hash)
247
+ return SerializationError.new(parameter: "body", expected: "an object of multipart fields", value: value)
248
+ end
249
+
250
+ parts = []
251
+ display = {}
252
+ value.each do |k, item|
253
+ next if item.nil?
254
+
255
+ list = item.is_a?(Array) ? item.reject(&:nil?) : [item]
256
+ shown = []
257
+ list.each do |x|
258
+ bin = Json.binary_of(x)
259
+ if !UNSET.equal?(bin)
260
+ content_type = bin.content_type || "application/octet-stream"
261
+ unless valid_header_value?(content_type)
262
+ return SerializationError.new(parameter: "body", expected: "multipart file content types without header control characters",
263
+ value: value)
264
+ end
265
+
266
+ # Keep the checked header value stable until the body is sent.
267
+ content_type = content_type.dup.freeze
268
+ shown << binary_display(bin.bytesize, bin.content_type)
269
+ parts << Part.new(name: k, kind: :file, text: nil, data: bin.data, filename: bin.filename || k,
270
+ content_type: content_type)
271
+ elsif x.is_a?(Hash)
272
+ parts << Part.new(name: k, kind: :json, text: Json.text(x), data: nil, filename: nil, content_type: nil)
273
+ shown << x
274
+ else
275
+ parts << Part.new(name: k, kind: :text, text: scalar(x), data: nil, filename: nil, content_type: nil)
276
+ shown << scalar(x)
277
+ end
278
+ end
279
+ display[k] = item.is_a?(Array) ? shown : shown.first
280
+ end
281
+ Encoded.new(empty: false, bytes: nil, parts: parts, content_type: nil, display: redact.call(display),
282
+ hashed: Json.canonical(value).b)
283
+ end
284
+
285
+ # The bytes of a multipart body and its content type.
286
+ def multipart_body(parts)
287
+ boundary = SecureRandom.hex(16)
288
+ out = +"".b
289
+ quote = ->(s) { s.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').gsub("\r", "%0D").gsub("\n", "%0A") }
290
+ parts.each do |p|
291
+ out << "--#{boundary}\r\n".b
292
+ case p.kind
293
+ when :file
294
+ out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"; filename="#{quote.call(p.filename)}"\r\n).b
295
+ out << "Content-Type: #{p.content_type}\r\n\r\n".b << p.data.b
296
+ when :json
297
+ out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"\r\n).b
298
+ out << "Content-Type: application/json\r\n\r\n".b << p.text.b
299
+ else
300
+ out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"\r\n\r\n).b << p.text.b
301
+ end
302
+ out << "\r\n".b
303
+ end
304
+ out << "--#{boundary}--\r\n".b
305
+ [out, "multipart/form-data; boundary=#{boundary}"]
306
+ end
307
+
308
+ # An RFC 9110 token.
309
+ def valid_header_name?(name) = name.is_a?(String) && name.match?(/\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/)
310
+
311
+ # Visible ASCII, tab, space and Latin-1 characters.
312
+ def valid_header_value?(value)
313
+ value.is_a?(String) && value.each_char.all? { |c| c == "\t" || (c.ord >= 0x20 && c.ord <= 0x7e) || (c.ord >= 0x80 && c.ord <= 0xff) }
314
+ end
315
+
316
+ # The Latin-1 bytes of a valid header value.
317
+ def header_bytes(value) = value.each_char.map { |c| c.ord.chr }.join.b
318
+ end
319
+
320
+ # A case-insensitive header collection that remembers which values are
321
+ # secrets, in insertion order.
322
+ class HeaderBag
323
+ Entry = Struct.new(:name, :value, :secret)
324
+
325
+ attr_reader :entries
326
+
327
+ def initialize(entries = [])
328
+ @entries = entries.map(&:dup)
329
+ end
330
+
331
+ def set(name, value, secret)
332
+ existing = @entries.find { |e| e.name.casecmp?(name) }
333
+ if existing
334
+ existing.name = name
335
+ existing.value = value
336
+ existing.secret = secret
337
+ else
338
+ @entries << Entry.new(name, value, secret)
339
+ end
340
+ end
341
+
342
+ def get(name) = @entries.find { |e| e.name.casecmp?(name) }&.value
343
+
344
+ def delete(name) = @entries.reject! { |e| e.name.casecmp?(name) }
345
+
346
+ def to_h(redact) = @entries.to_h { |e| [e.name, redact && e.secret ? REDACTED : e.value] }
347
+
348
+ def secrets = @entries.select(&:secret).map(&:value)
349
+
350
+ def dup = HeaderBag.new(@entries)
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # A server-sent events parser (WHATWG HTML 9.2): lines end with CRLF, LF
7
+ # or CR; a line is a comment (":"), a field (event, data, id, retry; the
8
+ # value loses one leading space) or the blank line that dispatches the
9
+ # event. Data lines are joined with LF; an event without data lines is
10
+ # not dispatched; an event still open when the stream ends is
11
+ # discarded. An event may hold at most `max` UTF-8 bytes (its field
12
+ # lines, each with a one-byte terminator, plus the line being read);
13
+ # past it the parser stops (`exceeded?`).
14
+ class SseParser
15
+ # One dispatched event; `id` and `retry_ms` persist across events
16
+ # (nil before any).
17
+ Event = Data.define(:event, :data, :id, :retry_ms)
18
+
19
+ def initialize(max)
20
+ @max = max
21
+ @buffer = +""
22
+ @started = false
23
+ reset_event
24
+ @id = nil
25
+ @retry_ms = nil
26
+ @exceeded = false
27
+ end
28
+
29
+ def exceeded? = @exceeded
30
+
31
+ # Feed decoded text; the events it completes. A CR at the end is held
32
+ # back until the next text shows whether an LF follows.
33
+ def push(chunk)
34
+ return [] if @exceeded
35
+
36
+ text = @buffer + chunk
37
+ @buffer = +""
38
+ if !@started && !text.empty?
39
+ @started = true
40
+ text = text.delete_prefix("\uFEFF")
41
+ end
42
+ out = []
43
+ bytes = text.b
44
+ start = 0
45
+ while (i = bytes.byteindex(/[\r\n]/n, start))
46
+ cr = bytes.getbyte(i) == 13
47
+ if cr && i + 1 == bytes.bytesize
48
+ @buffer = utf8(bytes.byteslice(start..))
49
+ return out
50
+ end
51
+ line(utf8(bytes.byteslice(start, i - start)), out)
52
+ start = cr && bytes.getbyte(i + 1) == 10 ? i + 2 : i + 1
53
+ return out if @exceeded
54
+ end
55
+ @buffer = utf8(bytes.byteslice(start..) || "")
56
+ if @event_bytes + @buffer.bytesize > @max
57
+ @exceeded = true
58
+ @buffer = +""
59
+ end
60
+ out
61
+ end
62
+
63
+ # The stream ended: a held-back CR ends its line, the rest is
64
+ # discarded.
65
+ def finish
66
+ out = []
67
+ line(@buffer.delete_suffix("\r"), out) if !@exceeded && @buffer.end_with?("\r")
68
+ @buffer = +""
69
+ reset_event
70
+ out
71
+ end
72
+
73
+ private
74
+
75
+ def utf8(bytes) = bytes.dup.force_encoding(Encoding::UTF_8)
76
+
77
+ def reset_event
78
+ @event = ""
79
+ @data = +""
80
+ @has_data = false
81
+ @event_bytes = 0
82
+ end
83
+
84
+ def line(text, out)
85
+ if text.empty?
86
+ if @has_data
87
+ out << Event.new(event: @event.empty? ? "message" : @event, data: @data.delete_suffix("\n"), id: @id, retry_ms: @retry_ms)
88
+ end
89
+ reset_event
90
+ return
91
+ end
92
+ return if text.start_with?(":")
93
+
94
+ @event_bytes += text.bytesize + 1
95
+ if @event_bytes > @max
96
+ @exceeded = true
97
+ return
98
+ end
99
+ name, value = text.split(":", 2)
100
+ value = value.to_s.delete_prefix(" ")
101
+ case name
102
+ when "event" then @event = value
103
+ when "data"
104
+ @data << value << "\n"
105
+ @has_data = true
106
+ when "id" then @id = value unless value.include?("\u0000")
107
+ when "retry"
108
+ @retry_ms = value.to_i if value.match?(/\A\d+\z/) && value.to_i <= Util::MAX_SAFE_INTEGER
109
+ end
110
+ end
111
+ end
112
+
113
+ # Joins UTF-8 sequences split across chunks; invalid bytes become U+FFFD.
114
+ class Utf8Decoder
115
+ def initialize
116
+ @pending = "".b
117
+ end
118
+
119
+ def decode(data)
120
+ buf = @pending + data.b
121
+ cut = buf.bytesize
122
+ [3, buf.bytesize].min.times do |back|
123
+ i = back + 1
124
+ b = buf.getbyte(buf.bytesize - i)
125
+ next if (b & 0xC0) == 0x80
126
+
127
+ if b.between?(0xC2, 0xF4)
128
+ need = if b >= 0xF0 then 4
129
+ elsif b >= 0xE0 then 3
130
+ else 2
131
+ end
132
+ cut = buf.bytesize - i if need > i
133
+ end
134
+ break
135
+ end
136
+ @pending = buf.byteslice(cut..) || "".b
137
+ buf.byteslice(0, cut).force_encoding(Encoding::UTF_8).scrub("\uFFFD")
138
+ end
139
+
140
+ def finish
141
+ return "" if @pending.empty?
142
+
143
+ @pending = "".b
144
+ "\uFFFD"
145
+ end
146
+ end
147
+ end
148
+ end