studio-engine 0.95.0 → 0.96.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,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Studio
6
+ # The rules for a knowledge document's RECORDING (the audio or video of a
7
+ # meeting whose transcript is the document): what counts as one, how big it
8
+ # may be, and how one is fetched from a URL. Studio::KnowledgeDoc#attach_recording!
9
+ # and #attach_recording_from_url! are the callers; the upload itself is
10
+ # Studio::S3::Multipart.
11
+ #
12
+ # Loaded on first use (`require "studio/knowledge_recording"`), not by
13
+ # `require "studio"`, so an app that never attaches a recording pays nothing.
14
+ #
15
+ # WHAT COUNTS AS A RECORDING. The file's own first bytes decide, never a
16
+ # name alone and never a header a remote server sent:
17
+ #
18
+ # container first bytes stored as
19
+ # MP4 / M4V / MOV / M4A ....ftyp video/mp4, video/quicktime, audio/mp4
20
+ # WebM 1A 45 DF A3 video/webm, audio/webm
21
+ # MP3 ID3, or an MPEG frame sync audio/mpeg
22
+ # WAV RIFF....WAVE audio/wav
23
+ # Ogg OggS audio/ogg, video/ogg
24
+ #
25
+ # The extension picks WITHIN a container (`.m4a` is audio/mp4, `.mov` is
26
+ # video/quicktime) and must agree with it: a file named `.mp4` whose bytes
27
+ # are WebM is refused, and so is any extension off the list. The stored
28
+ # content type always comes from TYPES, so the bucket never serves a
29
+ # recording as text/html or anything else a browser would run.
30
+ #
31
+ # THE BOUNDS. README, "Knowledge recordings", walks every phase of a fetch
32
+ # and of an upload with its time bound, its size bound and what is left
33
+ # behind if the process dies there. The constants are below; `fetch` and
34
+ # Meter say how each is enforced.
35
+ module KnowledgeRecording
36
+ class Error < StandardError; end
37
+ # The source was refused before, or instead of, being stored.
38
+ class Refused < Error; end
39
+ class NotARecording < Refused; end
40
+ class TooLarge < Refused; end
41
+ class FetchFailed < Error; end
42
+
43
+ # 4 GB. Recordings run 100 MB to 1 GB; this leaves room for a long
44
+ # meeting in high definition and still fits a dyno's ephemeral disk.
45
+ # It must stay at or under Studio::S3::Trash::MAX_COPY_BYTES (5 GiB): a
46
+ # replaced recording is trashed with one CopyObject, and a larger object
47
+ # could be stored but never replaced. The suite pins the two together.
48
+ MAX_BYTES = 4 * 1024 * 1024 * 1024
49
+ # Long enough for a presigned download URL, short enough that a redirect
50
+ # cannot hand us a megabyte of Location header to parse.
51
+ MAX_URL_BYTES = 8_192
52
+ MAX_REDIRECTS = 3
53
+ MAX_ADDRESSES = 4
54
+ # One hour for the whole fetch: every connect, every response's headers,
55
+ # every redirect and the body. Enforced on every read and write of the
56
+ # socket (Meter), not only as body chunks arrive. The one thing that can
57
+ # run past it is a DNS lookup already in flight (ImageCache's
58
+ # RESOLVE_DEADLINE, 6 s).
59
+ FETCH_DEADLINE = 3_600
60
+ # Seconds for one address to accept a connection and finish TLS.
61
+ CONNECT_DEADLINE = 20
62
+ # Seconds from sending a request to having its status line and headers,
63
+ # any 1xx responses included.
64
+ HEADER_DEADLINE = 30
65
+ # Bytes that may come off the socket without becoming body: the status
66
+ # line, headers and 1xx responses of one hop together, and after that the
67
+ # chunk-size lines and trailers between two pieces of body. Net::HTTP
68
+ # buffers these in memory with no limit of its own.
69
+ MAX_UNDELIVERED_BYTES = 64 * 1024
70
+ # The only port fetched from. A download link is https on 443; any other
71
+ # port would let the fetch be pointed at arbitrary services on public hosts.
72
+ PORT = 443
73
+ HEAD_BYTES = 16
74
+ MAX_FILENAME_CHARS = 80
75
+ # Refused before any bytes are read when the declared type is something
76
+ # else. A server may say nothing, or octet-stream; the bytes still decide.
77
+ GENERIC_CONTENT_TYPES = %w[application/octet-stream binary/octet-stream].freeze
78
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
79
+
80
+ # extension => the content type it is stored under.
81
+ TYPES = {
82
+ "mp4" => "video/mp4",
83
+ "m4v" => "video/mp4",
84
+ "mov" => "video/quicktime",
85
+ "m4a" => "audio/mp4",
86
+ "webm" => "video/webm",
87
+ "weba" => "audio/webm",
88
+ "mp3" => "audio/mpeg",
89
+ "wav" => "audio/wav",
90
+ "ogg" => "audio/ogg",
91
+ "oga" => "audio/ogg",
92
+ "ogv" => "video/ogg"
93
+ }.freeze
94
+
95
+ # container => the extensions that may name it. The first is the default
96
+ # for a source with no extension.
97
+ CONTAINERS = {
98
+ isobmff: %w[mp4 m4v mov m4a],
99
+ webm: %w[webm weba],
100
+ mp3: %w[mp3],
101
+ wav: %w[wav],
102
+ ogg: %w[ogg oga ogv]
103
+ }.freeze
104
+
105
+ # ISO base media brands that name something other than the default.
106
+ ISOBMFF_BRANDS = { "qt " => "mov", "M4A " => "m4a", "M4B " => "m4a" }.freeze
107
+ # Second byte of an MPEG-1/2/2.5 Layer III frame header after 0xFF.
108
+ MP3_SYNC_BYTES = [0xFB, 0xFA, 0xF3, 0xF2, 0xE3, 0xE2].freeze
109
+ EBML_MAGIC = "\x1A\x45\xDF\xA3".b.freeze
110
+
111
+ # What `identify!` answers: the content type to store, the extension the
112
+ # object key ends in, and the size read from the open file.
113
+ Identified = Struct.new(:content_type, :extension, :byte_size, keyword_init: true)
114
+
115
+ class << self
116
+ # The container the first bytes name, or nil. `head` is at most
117
+ # HEAD_BYTES long; nothing here scans.
118
+ def container_of(head)
119
+ head = head.to_s.b
120
+ return :isobmff if head.bytesize >= 12 && head.byteslice(4, 4) == "ftyp"
121
+ return :webm if head.start_with?(EBML_MAGIC)
122
+ return :wav if head.bytesize >= 12 && head.start_with?("RIFF") && head.byteslice(8, 4) == "WAVE"
123
+ return :ogg if head.start_with?("OggS")
124
+ return :mp3 if head.start_with?("ID3")
125
+ return :mp3 if head.bytesize >= 2 && head.getbyte(0) == 0xFF && MP3_SYNC_BYTES.include?(head.getbyte(1))
126
+
127
+ nil
128
+ end
129
+
130
+ # Reads HEAD_BYTES of the file at `path` and answers an Identified, or
131
+ # raises: NotARecording when the bytes are no container on the list or
132
+ # the extension disagrees with them, TooLarge past MAX_BYTES.
133
+ #
134
+ # `filename` is the name whose extension is judged (default: the
135
+ # path's). A name with no extension is judged by its bytes alone.
136
+ def identify!(path, filename: nil, max_bytes: MAX_BYTES)
137
+ path = path.to_s
138
+ raise Refused, "no such file: #{path}" unless File.file?(path)
139
+
140
+ head, size = File.open(path, "rb") { |file| [file.read(HEAD_BYTES), file.size] }
141
+ raise NotARecording, "#{File.basename(path)} is empty" if size.zero?
142
+ raise TooLarge, "#{File.basename(path)} is #{size} bytes, over the #{max_bytes}-byte cap" if size > max_bytes
143
+
144
+ container = container_of(head) ||
145
+ raise(NotARecording, "#{File.basename(path)} is not a recording: its first bytes are not MP4, MOV, M4A, " \
146
+ "WebM, MP3, WAV or Ogg")
147
+ extension = extension_for(container, head, filename || path)
148
+ Identified.new(content_type: TYPES.fetch(extension), extension: extension, byte_size: size)
149
+ end
150
+
151
+ # A file name for the object key: the source's base name, cut to
152
+ # MAX_FILENAME_CHARS, with the extension the bytes earned. The caller
153
+ # sanitizes it (Studio::KnowledgeDoc.sanitize_filename).
154
+ def filename_for(name, extension)
155
+ base = File.basename(name.to_s, ".*")[0, MAX_FILENAME_CHARS].to_s
156
+ base = "recording" if base.strip.empty?
157
+ "#{base}.#{extension}"
158
+ end
159
+
160
+ # `url` as it may be printed or logged: scheme and host, nothing else. A
161
+ # download URL carries its credential in the query or in the path, and
162
+ # every failure message reaches a log drain.
163
+ def redact(url)
164
+ require "uri"
165
+ uri = URI.parse(url.to_s)
166
+ return "(a URL with no host)" if uri.host.to_s.empty?
167
+
168
+ "#{uri.scheme}://#{uri.host[0, 253]}/…"
169
+ rescue URI::InvalidURIError
170
+ "(an unparsable URL)"
171
+ end
172
+
173
+ # A link a page may render: an http(s) URL with a host, at most
174
+ # MAX_URL_BYTES long. Answers the stripped String, or nil. This is the
175
+ # check on recording_source_url, which is DISPLAYED and never fetched,
176
+ # so it asks only that the link cannot be `javascript:` or `data:`.
177
+ def web_link(value)
178
+ require "uri"
179
+ text = value.to_s.strip
180
+ return nil if text.empty? || text.bytesize > MAX_URL_BYTES
181
+
182
+ uri = URI.parse(text)
183
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? ? text : nil
184
+ rescue URI::InvalidURIError
185
+ nil
186
+ end
187
+
188
+ # Downloads `url` to a temporary file and yields its path. The file is
189
+ # deleted when the block returns or raises (an interrupt included).
190
+ #
191
+ # THIS IS A SERVER-SIDE FETCH OF A CALLER-SUPPLIED URL, run with the
192
+ # bucket's credentials in the process. Every hop, the first and each
193
+ # redirect, goes through Studio::ImageCache.vet_source_url! (the engine's
194
+ # one SSRF guard: public addresses only, however the host is written and
195
+ # wherever its name resolves), must be https on port 443, may carry no
196
+ # user:password, and is connected to an address that hop was vetted
197
+ # against (ImageCache.pinned_http), so the name cannot resolve somewhere
198
+ # else between the check and the connection. A host that vets to NO
199
+ # address (no resolver) is refused rather than connected to by name.
200
+ #
201
+ # WHAT IS YIELDED IS A COMPLETE BODY OR NOTHING. A response that declares
202
+ # a Content-Length and sends any other number of bytes raises FetchFailed
203
+ # before the block runs; so does a chunked body that ends before its last
204
+ # chunk. A body with NO declared length and no chunking ends when the
205
+ # server closes the connection, and a cut there cannot be told from the
206
+ # end: that one case is unchecked.
207
+ #
208
+ # Nothing of the URL's path is used to name the file: a path can carry
209
+ # the credential.
210
+ #
211
+ # `resolver:` is the callable names resolve with. The default is the
212
+ # guard's own, or the system resolver where the guard's default is none
213
+ # (a Rails test environment): this fetch never runs unresolved.
214
+ def fetch(url, max_bytes: MAX_BYTES, resolver: default_resolver, deadline: FETCH_DEADLINE,
215
+ header_deadline: HEADER_DEADLINE)
216
+ require "tempfile"
217
+ stop_at = monotonic + deadline
218
+ current = url.to_s
219
+ answer = nil
220
+
221
+ Tempfile.create(["studio-knowledge-recording", ".part"]) do |sink|
222
+ sink.binmode
223
+ # Bounded: MAX_REDIRECTS + 1 requests at most.
224
+ (MAX_REDIRECTS + 1).times do
225
+ raise FetchFailed, "#{redact(url)} did not finish within #{deadline} seconds" if monotonic > stop_at
226
+
227
+ vetted = vet!(current, resolver)
228
+ answer = request(vetted, sink, max_bytes, stop_at, header_deadline)
229
+ break unless answer.is_a?(String)
230
+
231
+ current = join_location(vetted.uri, answer)
232
+ end
233
+ raise FetchFailed, "too many redirects (more than #{MAX_REDIRECTS}) from #{redact(url)}" if answer.is_a?(String)
234
+
235
+ sink.flush
236
+ return yield(sink.path)
237
+ end
238
+ end
239
+
240
+ def default_resolver
241
+ require "studio/image_cache"
242
+ Studio::ImageCache.resolver || Studio::ImageCache::SYSTEM_RESOLVER
243
+ end
244
+
245
+ # The connection for one vetted address. A seam: the suite replaces it;
246
+ # production is always the guard's pinned connection.
247
+ def connection(uri, address)
248
+ Studio::ImageCache.pinned_http(uri, address)
249
+ end
250
+
251
+ def monotonic
252
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
253
+ end
254
+
255
+ private
256
+
257
+ def extension_for(container, head, name)
258
+ allowed = CONTAINERS.fetch(container)
259
+ given = File.extname(name.to_s).delete_prefix(".").downcase
260
+ return default_extension(container, head) if given.empty?
261
+ return given if allowed.include?(given)
262
+
263
+ raise NotARecording, "the name ends in .#{given[0, 16]} but the bytes are #{container} " \
264
+ "(expected one of: #{allowed.map { |ext| ".#{ext}" }.join(', ')})"
265
+ end
266
+
267
+ def default_extension(container, head)
268
+ return CONTAINERS.fetch(container).first unless container == :isobmff
269
+
270
+ ISOBMFF_BRANDS.fetch(head.to_s.b.byteslice(8, 4).to_s, "mp4")
271
+ end
272
+
273
+ # One hop through the guard, plus what this fetch adds to it: https on
274
+ # port 443 only, no credentials in the URL, a length cap, and at least
275
+ # one vetted address to pin the connection to.
276
+ def vet!(url, resolver)
277
+ require "studio/image_cache"
278
+ raise Refused, "URL is #{url.bytesize} bytes, over the #{MAX_URL_BYTES}-byte cap" if url.bytesize > MAX_URL_BYTES
279
+ # Asked of the text first so a URL that is not https costs no DNS
280
+ # lookup. The parsed scheme is checked again below; that one decides.
281
+ unless url[0, 8].to_s.casecmp?("https://")
282
+ raise Refused, "refused #{redact(url)}: a recording is fetched over https only"
283
+ end
284
+
285
+ vetted =
286
+ begin
287
+ Studio::ImageCache.vet_source_url!(url, resolver: resolver)
288
+ rescue Studio::ImageCache::InvalidSourceURL => error
289
+ raise Refused, "refused #{redact(url)}: #{error.message.gsub(url.inspect, redact(url))}"
290
+ rescue URI::InvalidURIError
291
+ raise Refused, "refused an unparsable URL"
292
+ end
293
+
294
+ raise Refused, "refused #{redact(url)}: a recording is fetched over https only" unless vetted.uri.scheme == "https"
295
+ raise Refused, "refused #{redact(url)}: only port #{PORT} is fetched from" unless vetted.uri.port == PORT
296
+ raise Refused, "refused #{redact(url)}: the URL carries a user or password" if vetted.uri.userinfo
297
+ raise Refused, "refused #{redact(url)}: its host was vetted against no address" if vetted.addresses.empty?
298
+
299
+ vetted
300
+ end
301
+
302
+ def join_location(base, location)
303
+ raise Refused, "redirect Location is over the #{MAX_URL_BYTES}-byte cap" if location.bytesize > MAX_URL_BYTES
304
+
305
+ URI.join(base, location).to_s
306
+ rescue URI::InvalidURIError
307
+ raise Refused, "refused a redirect to an unparsable URL from #{redact(base.to_s)}"
308
+ end
309
+
310
+ # One GET to the first vetted address that accepts a connection, at most
311
+ # MAX_ADDRESSES of them. Answers :stored, or the Location of a redirect.
312
+ # Only a failure to CONNECT moves on to the next address; once a request
313
+ # has been sent, a failure is the fetch's failure.
314
+ def request(vetted, sink, max_bytes, stop_at, header_deadline)
315
+ candidates = vetted.addresses.first(MAX_ADDRESSES)
316
+
317
+ candidates.each_with_index do |address, index|
318
+ sink.rewind
319
+ sink.truncate(0)
320
+ return get(vetted.uri, address, sink, max_bytes, stop_at, header_deadline)
321
+ rescue Unreachable => error
322
+ next unless index == candidates.size - 1
323
+
324
+ raise FetchFailed, "could not connect to #{vetted.host}: #{error.message}"
325
+ end
326
+ end
327
+
328
+ # An address that would not take a connection in time.
329
+ Unreachable = Class.new(StandardError)
330
+ ConnectTimedOut = Class.new(StandardError)
331
+ private_constant :Unreachable, :ConnectTimedOut
332
+
333
+ # A connected, TLS-verified Net::HTTP for one address, or Unreachable.
334
+ #
335
+ # The Timeout here is the ONE place a timeout wrapper is used, and it
336
+ # covers only the connect and the TLS handshake: Net::HTTP bounds the TCP
337
+ # connect itself but gives each wait of the handshake its own
338
+ # open_timeout, so a peer that drips handshake bytes is otherwise bounded
339
+ # only by OpenSSL's size limits. It is safe here because nothing of ours
340
+ # exists yet to be left half-done: no request has been sent and the sink
341
+ # is empty, and Net::HTTP#connect closes the socket on any StandardError
342
+ # (ConnectTimedOut is one) before re-raising. After this, no timeout
343
+ # wrapper is used at all; time is bounded inside the reads (Meter).
344
+ def open!(uri, address, stop_at)
345
+ require "net/http"
346
+ require "timeout"
347
+ remaining = stop_at - monotonic
348
+ raise FetchFailed, "#{redact(uri.to_s)} did not finish in time" if remaining <= 0
349
+
350
+ http = connection(uri, address)
351
+ # Net::HTTP retries an idempotent request once on a read timeout or a
352
+ # reset, on a NEW socket, and calls the response block again: the body
353
+ # would be written to the sink twice and the retry would be unmetered.
354
+ http.max_retries = 0
355
+ Timeout.timeout([CONNECT_DEADLINE, remaining].min, ConnectTimedOut) { http.start }
356
+ http
357
+ rescue ConnectTimedOut, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
358
+ Net::OpenTimeout, SocketError => error
359
+ # The deadline can land just as the connection finishes; a socket
360
+ # that did open is closed, not left for the collector.
361
+ hang_up(http)
362
+ raise Unreachable, error.class.name
363
+ end
364
+
365
+ # THE RESPONSE BODY IS NEVER READ UNBOUNDED. Net::HTTP drains whatever a
366
+ # block leaves unread, into memory, when the block returns; so a redirect
367
+ # or an error is left by `throw`, which unwinds past the drain, and the
368
+ # connection is closed with the body unread.
369
+ #
370
+ # Every failure after the connection is made leaves here as FetchFailed
371
+ # (or a Refused), naming the host and never the rest of the URL.
372
+ def get(uri, address, sink, max_bytes, stop_at, header_deadline)
373
+ require "openssl"
374
+ http = open!(uri, address, stop_at)
375
+ meter = Meter.install!(http, [stop_at, monotonic + header_deadline].min)
376
+
377
+ catch(:answered) do
378
+ request = Net::HTTP::Get.new(uri.request_uri)
379
+ # identity: Net::HTTP would otherwise ask for gzip and inflate it,
380
+ # and the byte cap must count what arrives, not what it expands to.
381
+ request["Accept-Encoding"] = "identity"
382
+ request["User-Agent"] = "studio-engine knowledge recording fetch"
383
+ http.request(request) do |response|
384
+ status = response.code.to_i
385
+ if REDIRECT_STATUSES.include?(status) && !response["location"].to_s.empty?
386
+ throw :answered, response["location"].to_s
387
+ end
388
+ raise FetchFailed, "#{redact(uri.to_s)} answered #{status}" unless status == 200
389
+
390
+ declared = check_headers!(response, uri, max_bytes)
391
+ # The headers are in: from here the clock is the whole fetch's.
392
+ meter.delivered!
393
+ meter.stop_at = stop_at
394
+ store(response, sink, max_bytes, meter, uri, declared)
395
+ throw :answered, :stored
396
+ end
397
+ raise FetchFailed, "#{redact(uri.to_s)} gave no response"
398
+ end
399
+ rescue Unreachable, Error
400
+ raise
401
+ rescue Meter::Expired
402
+ raise FetchFailed, "#{redact(uri.to_s)} did not finish in time (#{HEADER_DEADLINE} seconds for a " \
403
+ "response's headers, #{FETCH_DEADLINE} for the whole fetch)"
404
+ rescue Meter::Overrun
405
+ raise FetchFailed, "#{redact(uri.to_s)} sent more than #{MAX_UNDELIVERED_BYTES} bytes of headers or framing"
406
+ rescue Net::ReadTimeout, Net::WriteTimeout, Net::OpenTimeout, Net::ProtocolError, Net::HTTPBadResponse,
407
+ Net::HTTPHeaderSyntaxError, EOFError, IOError, SystemCallError, SocketError, Timeout::Error,
408
+ OpenSSL::SSL::SSLError => error
409
+ detail = error.message.to_s[0, 160].gsub(/[^[:print:]]/, "?")
410
+ raise FetchFailed, "#{redact(uri.to_s)} failed mid-fetch: #{error.class}: #{detail}"
411
+ ensure
412
+ hang_up(http)
413
+ end
414
+
415
+ def hang_up(http)
416
+ http.finish if http&.started?
417
+ rescue IOError, SystemCallError
418
+ nil
419
+ end
420
+
421
+ # Answers the declared Content-Length (an Integer), or nil when the body
422
+ # is chunked or declares none.
423
+ def check_headers!(response, uri, max_bytes)
424
+ encoding = response["content-encoding"].to_s.strip.downcase
425
+ unless encoding.empty? || encoding == "identity"
426
+ raise Refused, "#{redact(uri.to_s)} answered with Content-Encoding #{encoding[0, 32].inspect}; only identity is read"
427
+ end
428
+
429
+ declared = response["content-type"].to_s.split(";").first.to_s.strip.downcase
430
+ unless declared.empty? || declared.start_with?("video/", "audio/") || GENERIC_CONTENT_TYPES.include?(declared)
431
+ raise NotARecording, "#{redact(uri.to_s)} answered #{declared[0, 64].inspect}, not audio or video"
432
+ end
433
+
434
+ return nil if response.chunked?
435
+
436
+ length = response["content-length"]
437
+ return nil if length.nil?
438
+ # Two Content-Length headers arrive joined by a comma; that, a sign or
439
+ # a blank is a response this fetch cannot measure, so it is refused.
440
+ raise Refused, "#{redact(uri.to_s)} sent a Content-Length that is not a number" unless length.match?(/\A\d{1,20}\z/)
441
+ raise TooLarge, "#{redact(uri.to_s)} declares #{length} bytes, over the #{max_bytes}-byte cap" if length.to_i > max_bytes
442
+
443
+ length.to_i
444
+ end
445
+
446
+ # Streams the body to `sink`. Each chunk is counted BEFORE it is written,
447
+ # so the file on disk never passes max_bytes; the first HEAD_BYTES are
448
+ # judged as soon as they arrive, so a page of HTML sent as octet-stream
449
+ # stops at its first chunk; and each chunk tells the meter that what came
450
+ # off the socket became body. When a length was declared, the body must
451
+ # be exactly that long: Net::HTTP itself reports no error for a body that
452
+ # ends early.
453
+ def store(response, sink, max_bytes, meter, uri, declared)
454
+ written = 0
455
+ head = +"".b
456
+ judged = false
457
+ response.read_body do |chunk|
458
+ meter.delivered!
459
+ written += chunk.bytesize
460
+ raise TooLarge, "#{redact(uri.to_s)} sent more than the #{max_bytes}-byte cap" if written > max_bytes
461
+
462
+ unless judged
463
+ head << chunk.byteslice(0, HEAD_BYTES - head.bytesize)
464
+ if head.bytesize >= HEAD_BYTES
465
+ judged = true
466
+ container_of(head) || raise(NotARecording, "#{redact(uri.to_s)} did not send a recording")
467
+ end
468
+ end
469
+ sink.write(chunk)
470
+ end
471
+ if declared && written != declared
472
+ raise FetchFailed, "#{redact(uri.to_s)} sent #{written} bytes of #{declared} declared; the download was cut short"
473
+ end
474
+ raise NotARecording, "#{redact(uri.to_s)} sent #{written} bytes, too few to be a recording" unless judged
475
+ end
476
+ end
477
+
478
+ # Sits between Net::HTTP's read buffer and the socket of ONE connection,
479
+ # and bounds what Net::HTTP itself does not: how long the connection may
480
+ # be read at all, and how many bytes may come off it without becoming body.
481
+ #
482
+ # WHY IT EXISTS. Net::HTTP reads the status line, headers, 1xx responses,
483
+ # chunk-size lines and trailers itself, into memory, with no size limit and
484
+ # only a per-read timeout. A server that sends header lines forever, one
485
+ # endless line, an endless chunk-size line, a header every half second or
486
+ # `100 Continue` forever was never stopped by a deadline or a byte cap
487
+ # that only the body's chunks could see (review round 1, measured at
488
+ # gigabytes of memory in seconds).
489
+ #
490
+ # HOW. Net::BufferedIO calls four things on its io: read_nonblock, to_io
491
+ # (to wait on), write_nonblock and close. This wraps the io it was given
492
+ # and overrides the first two:
493
+ # read_nonblock refuses once the deadline has passed, and counts the
494
+ # bytes read since `delivered!` was last called; past
495
+ # MAX_UNDELIVERED_BYTES it raises Overrun. `fetch` calls
496
+ # delivered! as each piece of body is handed to it.
497
+ # to_io answers a waiter whose waits are cut to the time left,
498
+ # so a silent socket is given up at the deadline, not a
499
+ # read timeout later.
500
+ # No thread is interrupted and nothing is raised asynchronously: every
501
+ # stop is an ordinary exception raised in the reading thread, between
502
+ # reads.
503
+ #
504
+ # It reaches into Net::HTTP for the buffer (the @socket and @io instance
505
+ # variables; net-http offers no hook). `install!` REFUSES THE FETCH when
506
+ # either is not what it expects, so a net-http this was not written for
507
+ # cannot run unmetered. Written against net-http 0.9.1 and net-protocol
508
+ # 0.2.2; the suite runs real Net::HTTP through it.
509
+ class Meter < SimpleDelegator
510
+ class Overrun < StandardError; end
511
+ class Expired < StandardError; end
512
+
513
+ Waiter = Struct.new(:io, :meter) do
514
+ def wait_readable(timeout = nil) = meter.wait(io, :wait_readable, timeout)
515
+ def wait_writable(timeout = nil) = meter.wait(io, :wait_writable, timeout)
516
+ def inspect = "#<metered socket>"
517
+ end
518
+
519
+ attr_writer :stop_at
520
+
521
+ def self.install!(http, stop_at)
522
+ buffered = http.instance_variable_get(:@socket)
523
+ raw = buffered.instance_variable_get(:@io) if buffered.is_a?(Net::BufferedIO)
524
+ unless raw.respond_to?(:read_nonblock) && raw.respond_to?(:to_io)
525
+ raise FetchFailed, "this net-http cannot be metered, so the fetch was not made (Studio::KnowledgeRecording::Meter)"
526
+ end
527
+
528
+ meter = new(raw, stop_at)
529
+ buffered.instance_variable_set(:@io, meter)
530
+ meter
531
+ end
532
+
533
+ def initialize(io, stop_at)
534
+ super(io)
535
+ @stop_at = stop_at
536
+ @undelivered = 0
537
+ end
538
+
539
+ # What has come off the socket so far has been handed on as body.
540
+ def delivered!
541
+ @undelivered = 0
542
+ end
543
+
544
+ def read_nonblock(length, buffer = nil, exception: true)
545
+ time_left(nil)
546
+ result = __getobj__.read_nonblock(length, buffer, exception: exception)
547
+ if result.is_a?(String)
548
+ @undelivered += result.bytesize
549
+ raise Overrun, "more than #{MAX_UNDELIVERED_BYTES} bytes" if @undelivered > MAX_UNDELIVERED_BYTES
550
+ end
551
+ result
552
+ end
553
+
554
+ def to_io
555
+ Waiter.new(__getobj__.to_io, self)
556
+ end
557
+
558
+ # One wait on the socket, cut to the time left. A wait that ran out
559
+ # because it was CUT is the deadline (Expired); one that ran out on its
560
+ # own is the caller's read timeout (nil, as IO#wait_readable answers).
561
+ def wait(io, direction, timeout)
562
+ allowed = time_left(timeout)
563
+ ready = io.public_send(direction, allowed)
564
+ raise Expired, "deadline passed" if ready.nil? && (timeout.nil? || allowed < timeout)
565
+
566
+ ready
567
+ end
568
+
569
+ # `timeout` cut to the seconds left before the deadline. Raises Expired
570
+ # when none are left.
571
+ def time_left(timeout)
572
+ remaining = @stop_at - KnowledgeRecording.monotonic
573
+ raise Expired, "deadline passed" if remaining <= 0
574
+
575
+ timeout.nil? ? remaining : [timeout, remaining].min
576
+ end
577
+ end
578
+ end
579
+ end