pinesandbox 0.3.19 → 0.3.20

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,776 @@
1
+ require "faraday"
2
+ require "json"
3
+ require "pathname"
4
+ require "securerandom"
5
+ require "stringio"
6
+ require "time"
7
+ require "uri"
8
+ require_relative "adapters/coordinator_adapter"
9
+ require_relative "clock"
10
+ require_relative "errors"
11
+ require_relative "response_handler"
12
+ require_relative "streamed_read"
13
+ require_relative "version"
14
+ require_relative "models/artifact_file"
15
+
16
+ module PineSandbox
17
+ # @api private — what a path in a Computer's artifacts may be, checked the
18
+ # same way by every SDK, pine-artifact and Pine before any request (a copy
19
+ # of contract/artifact-paths.json, which the test suite runs).
20
+ module ArtifactPath
21
+ MAX_BYTES = 900
22
+ # The C0 and C1 control characters.
23
+ CONTROL = /[\u0000-\u001F\u007F-\u009F]/
24
+
25
+ module_function
26
+
27
+ # Returns `path` as UTF-8 when it is 1 to MAX_BYTES bytes of valid UTF-8
28
+ # with no control character and no empty, "." or ".." segment (so no
29
+ # leading, trailing or doubled "/"); raises ArgumentError otherwise.
30
+ # Anything else is allowed. `name` is the argument, for the message.
31
+ def check!(path, name: "artifact path")
32
+ raise ArgumentError, "#{name} must be a String" unless path.is_a?(String)
33
+ raise ArgumentError, "#{name} must be 1 to #{MAX_BYTES} bytes" unless path.bytesize.between?(1, MAX_BYTES)
34
+
35
+ utf8 = path.dup.force_encoding(Encoding::UTF_8)
36
+ raise ArgumentError, "#{name} must be valid UTF-8" unless utf8.valid_encoding?
37
+ raise ArgumentError, "#{name} must not contain a control character" if utf8.match?(CONTROL)
38
+ if utf8.split("/", -1).any? { |segment| segment.empty? || segment == "." || segment == ".." }
39
+ raise ArgumentError, "#{name} must not have an empty, \".\" or \"..\" segment"
40
+ end
41
+
42
+ utf8
43
+ end
44
+ end
45
+
46
+ # @api private — the content type an upload gets when it names none, the
47
+ # same in every SDK whatever the host's MIME database holds (a copy of
48
+ # contract/artifact-content-types.json, which the test suite checks this
49
+ # table against).
50
+ module ArtifactContentType
51
+ DEFAULT = "application/octet-stream"
52
+ # By extension, in lower case.
53
+ TYPES = {
54
+ "css" => "text/css",
55
+ "csv" => "text/csv",
56
+ "doc" => "application/msword",
57
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
58
+ "gif" => "image/gif",
59
+ "gz" => "application/gzip",
60
+ "htm" => "text/html",
61
+ "html" => "text/html",
62
+ "jpeg" => "image/jpeg",
63
+ "jpg" => "image/jpeg",
64
+ "js" => "text/javascript",
65
+ "json" => "application/json",
66
+ "md" => "text/markdown",
67
+ "mp3" => "audio/mpeg",
68
+ "mp4" => "video/mp4",
69
+ "pdf" => "application/pdf",
70
+ "png" => "image/png",
71
+ "ppt" => "application/vnd.ms-powerpoint",
72
+ "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
73
+ "svg" => "image/svg+xml",
74
+ "tar" => "application/x-tar",
75
+ "tsv" => "text/tab-separated-values",
76
+ "txt" => "text/plain",
77
+ "wav" => "audio/wav",
78
+ "webm" => "video/webm",
79
+ "webp" => "image/webp",
80
+ "xls" => "application/vnd.ms-excel",
81
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
82
+ "xml" => "application/xml",
83
+ "yaml" => "application/yaml",
84
+ "yml" => "application/yaml",
85
+ "zip" => "application/zip"
86
+ }.freeze
87
+
88
+ module_function
89
+
90
+ # The type for the extension of `path`'s last segment: what follows its
91
+ # last ".", unless that "." starts the segment, in lower case. DEFAULT
92
+ # for any other path.
93
+ def for_path(path)
94
+ segment = path.rpartition("/").last
95
+ dot = segment.rindex(".")
96
+ return DEFAULT unless dot&.positive?
97
+
98
+ TYPES.fetch(segment[(dot + 1)..].downcase, DEFAULT)
99
+ end
100
+ end
101
+
102
+ # client.artifacts(computer_id) — a Computer's artifacts: the files it keeps
103
+ # beyond its sessions, reachable with the project key whether the Computer
104
+ # is running or stopped.
105
+ #
106
+ # artifacts = client.artifacts(computer_id)
107
+ # page = artifacts.list(prefix: "research/") # page.files, page.folders, page.next_page_token
108
+ # artifacts.upload("inputs/brief.txt", Pathname("brief.txt"), note: "the brief")
109
+ # artifacts.download("reports/q3.pdf", "q3.pdf") # => bytes written
110
+ # artifacts.open("reports/q3.csv") { |chunk| io.write(chunk) }
111
+ # artifacts.copy(other_computer_id, "templates/form.pdf", "inputs/form.pdf")
112
+ # artifacts.delete("inputs/brief.txt")
113
+ #
114
+ # A path is relative to the Computer's artifacts, such as "reports/q3.pdf":
115
+ # 1 to 900 bytes of UTF-8 with no control character and no empty, "." or
116
+ # ".." segment, or the call raises ArgumentError before any request.
117
+ # The handle gets one short-lived storage token from Pine, then sends every
118
+ # request to storage itself: no file passes through Pine. It keeps the
119
+ # token and gets a new one before it lapses, or once when storage refuses
120
+ # it. A missing file is ArtifactNotFoundError; any other refusal from
121
+ # storage is the status tier's ApiError, with storage's message, and an
122
+ # answer the storage protocol doesn't allow, such as a success whose body
123
+ # isn't a JSON object, is PineSandbox::Error. An error's #retryable? follows
124
+ # the storage rule every SDK shares (contract/storage-retry.json): true for
125
+ # a request timeout, a 429 and any 5xx but 501. An error's #op names the
126
+ # route with "{object}" for the file's name; the path is only in typed
127
+ # fields such as ArtifactNotFoundError#path. Public links are the running
128
+ # Computer's: Computer#artifact_link.
129
+ class Artifacts
130
+ include PineSandbox::ResponseHandler
131
+
132
+ # A token is replaced this many seconds before it expires.
133
+ TOKEN_RENEW_BEFORE = 300
134
+
135
+ # The storage token and where the artifacts are. The access token never
136
+ # appears in #inspect.
137
+ Token = Struct.new(:access_token, :expires_at, :endpoint, :bucket, :prefix, :writable,
138
+ :used_bytes, :limit_bytes, :copy_from, keyword_init: true) do
139
+ def self.from_payload(payload)
140
+ new(access_token: payload["access_token"], expires_at: Time.iso8601(payload["expires_at"]),
141
+ endpoint: payload["endpoint"].delete_suffix("/"), bucket: payload["bucket"],
142
+ prefix: payload["prefix"], writable: payload["writable"], used_bytes: payload["used_bytes"],
143
+ limit_bytes: payload["limit_bytes"], copy_from: payload["copy_from"] || {})
144
+ end
145
+
146
+ def inspect = "#<PineSandbox::Artifacts::Token bucket=#{bucket.inspect} prefix=#{prefix.inspect} [access_token redacted]>"
147
+ alias_method :to_s, :inspect
148
+
149
+ def pretty_print(printer) = printer.text(inspect)
150
+ end
151
+ private_constant :Token
152
+
153
+ # An upload's bytes, readable from any offset: a String, a local file (a
154
+ # Pathname), or an IO whose size is known, read from its current position.
155
+ class Upload
156
+ attr_reader :size
157
+
158
+ def initialize(source)
159
+ @start = 0
160
+ case source
161
+ when String
162
+ @io = StringIO.new(source)
163
+ @size = source.bytesize
164
+ when Pathname
165
+ @path = source
166
+ @size = File.size(source)
167
+ else
168
+ unless %i[read size seek pos].all? { |name| source.respond_to?(name) }
169
+ raise ArgumentError, "source must be a String of bytes, a Pathname, or an IO whose size is known"
170
+ end
171
+
172
+ @io = source
173
+ @start = source.pos
174
+ @size = source.size - @start
175
+ end
176
+ end
177
+
178
+ # Yields the bytes from `offset` to the end, as an IO.
179
+ def from(offset)
180
+ io = @path ? File.open(@path, "rb") : @io
181
+ io.seek(@start + offset)
182
+ yield io
183
+ ensure
184
+ io&.close if @path
185
+ end
186
+ end
187
+ private_constant :Upload
188
+
189
+ BlockError = StreamedRead::BlockError
190
+ private_constant :BlockError
191
+
192
+ attr_reader :computer_id
193
+
194
+ # @api private — Client#artifacts builds the handle. `token_source` is
195
+ # the project-key Portal client (#artifact_token).
196
+ def initialize(computer_id:, token_source:, clock: PineSandbox::SYSTEM_CLOCK)
197
+ @computer_id = computer_id
198
+ @token_source = token_source
199
+ @clock = clock
200
+ @tokens = {}
201
+ @connections = {}
202
+ @mutex = Mutex.new
203
+ end
204
+
205
+ # One page of the files under `prefix` ("" for all of them, "research/"
206
+ # for one folder). Unless `recursive`, a file in a deeper folder is
207
+ # summarized by its folder in the page's #folders. Pass a page's
208
+ # #next_page_token as `page_token` for the next one; `page_size` caps how
209
+ # many files and folders a page holds.
210
+ #
211
+ # @return [PineSandbox::Models::ArtifactPage]
212
+ def list(prefix: "", recursive: false, page_token: nil, page_size: nil)
213
+ unless page_size.nil? || (page_size.is_a?(Integer) && page_size.positive?)
214
+ raise ArgumentError, "page_size must be a positive integer"
215
+ end
216
+
217
+ with_token do |token|
218
+ params = { "prefix" => "#{token.prefix}#{prefix}" }
219
+ params["delimiter"] = "/" unless recursive
220
+ params["pageToken"] = page_token if page_token
221
+ params["maxResults"] = page_size if page_size
222
+ route = objects_route(token)
223
+ op = "GET /#{route}"
224
+ response = resending { storage_request(token, :get, route, op: op, params: params) }
225
+ raise_storage_error!(response, op: op) unless response.success?
226
+
227
+ body = parse_answer(response, op: op)
228
+ Models::ArtifactPage.new(
229
+ files: Array(body["items"]).map { |object| Models::ArtifactFile.from_object(object, prefix: token.prefix) },
230
+ folders: Array(body["prefixes"]).map { |folder| folder.delete_prefix(token.prefix) },
231
+ next_page_token: body["nextPageToken"]
232
+ )
233
+ end
234
+ end
235
+
236
+ # Saves `source` as the file at `path`, replacing any file there, and
237
+ # returns the saved file. `source` is the bytes (a String), a local file
238
+ # (a Pathname), or an IO whose size is known, such as a File, StringIO or
239
+ # Tempfile, read from its current position. `content_type` defaults to
240
+ # the one every SDK gives the path's extension, else
241
+ # application/octet-stream; `note` is kept with the file and listed with
242
+ # it.
243
+ #
244
+ # The bytes go to storage in one resumable transfer, whose start is sent
245
+ # again when its answer is lost. After a dropped connection or a storage
246
+ # answer worth retrying mid-transfer (a request timeout, a 429 or a 5xx but
247
+ # 501), the SDK waits (RetryPolicy's stream backoff), asks storage how much
248
+ # it kept, and sends the rest; it gives up after
249
+ # RetryPolicy::STREAM_RECONNECT_BUDGET tries in a row that moved nothing.
250
+ # Raises ArtifactStorageFullError, sending nothing, while the Computer's
251
+ # artifacts are over their limit; after one, the next upload or copy asks
252
+ # Pine again, so room made by deleting files is used.
253
+ #
254
+ # @return [PineSandbox::Models::ArtifactFile]
255
+ def upload(path, source, content_type: nil, note: nil)
256
+ path = ArtifactPath.check!(path)
257
+ upload = Upload.new(source)
258
+ content_type ||= ArtifactContentType.for_path(path)
259
+ token, session = with_token(write: true) do |current|
260
+ [current, start_upload(current, "#{current.prefix}#{path}", upload.size, content_type, note)]
261
+ end
262
+ Models::ArtifactFile.from_object(send_upload(session, upload, content_type), prefix: token.prefix)
263
+ end
264
+
265
+ # Saves the file at `path` to the local file `destination` and returns
266
+ # the number of bytes written. The bytes land in a temporary file beside
267
+ # `destination` that is renamed over it once complete, so `destination`
268
+ # is never left half-written. It resumes as #open does.
269
+ def download(path, destination)
270
+ path = ArtifactPath.check!(path)
271
+ destination = File.expand_path(destination.to_s)
272
+ part = File.join(File.dirname(destination), ".#{File.basename(destination)}.#{SecureRandom.hex(6)}.part")
273
+ written = File.open(part, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |file|
274
+ receive(path) { |chunk| file.write(chunk) }
275
+ end
276
+ File.rename(part, destination)
277
+ written
278
+ ensure
279
+ File.delete(part) if part && File.exist?(part)
280
+ end
281
+
282
+ # The file at `path` as a stream, for copying onward without holding it
283
+ # in memory: yields binary chunks as they arrive. Without a block,
284
+ # returns an Enumerator of the chunks that sends the request only when
285
+ # iterated (`artifacts.open(path).each { |chunk| io.write(chunk) }`).
286
+ #
287
+ # After a dropped connection, a request timeout, a 429 or a 5xx but 501,
288
+ # the SDK waits (RetryPolicy's stream backoff) and asks again from the
289
+ # next byte, of
290
+ # the same version of the file: one replaced meanwhile raises storage's
291
+ # PreconditionFailedError rather than joining two versions. It gives up
292
+ # after RetryPolicy::STREAM_RECONNECT_BUDGET tries in a row that received
293
+ # nothing. A missing file raises ArtifactNotFoundError before any chunk.
294
+ #
295
+ # @yieldparam chunk [String] the next bytes, binary encoding.
296
+ # @return [nil, Enumerator]
297
+ def open(path, &block)
298
+ path = ArtifactPath.check!(path)
299
+ return enum_for(:open, path) unless block
300
+
301
+ receive(path, &block)
302
+ nil
303
+ end
304
+
305
+ # Copies the file at `from_path` in `from_computer_id` (this Computer or
306
+ # another of the project's) to `path` in this one, replacing any file
307
+ # there, and returns the copy. Storage copies the bytes itself; nothing is
308
+ # downloaded, and a step whose answer was lost is sent again. Raises
309
+ # ArtifactNotFoundError for `from_path` when that file isn't there, and
310
+ # ArtifactStorageFullError, copying nothing, while this Computer's
311
+ # artifacts are over their limit.
312
+ #
313
+ # @return [PineSandbox::Models::ArtifactFile]
314
+ def copy(from_computer_id, from_path, path)
315
+ unless from_computer_id.to_s.match?(Computer::UUID_V7)
316
+ raise ArgumentError, "from_computer_id must be a lowercase UUIDv7"
317
+ end
318
+ from_path = ArtifactPath.check!(from_path, name: "from_path")
319
+ path = ArtifactPath.check!(path)
320
+
321
+ # A copy within this Computer reads with its own token, under its own
322
+ # prefix; another Computer's files need a token that may read them.
323
+ source_id = from_computer_id == @computer_id ? nil : from_computer_id
324
+ # A large file takes several rewrite calls; each answer says how to go on.
325
+ rewrite_token = nil
326
+ op = nil
327
+ loop do
328
+ token, body = with_token(copy_from: source_id, write: true) do |current|
329
+ source_prefix = source_id ? current.copy_from.fetch(source_id) : current.prefix
330
+ source, source_label = object_route(current, "#{source_prefix}#{from_path}")
331
+ target = "/rewriteTo/b/#{escape(current.bucket)}/o/"
332
+ route = "#{source}#{target}#{escape("#{current.prefix}#{path}")}"
333
+ op = "POST /#{source_label}#{target}{object}"
334
+ params = rewrite_token ? { "rewriteToken" => rewrite_token } : {}
335
+ # Rewriting the same object to the same name again changes nothing,
336
+ # so a rewrite whose answer was lost is sent again.
337
+ response = resending { storage_request(current, :post, route, op: op, params: params, json: {}) }
338
+ raise_storage_error!(response, op: op, path: from_path) unless response.success?
339
+
340
+ [current, parse_answer(response, op: op)]
341
+ end
342
+ if body["done"] == true
343
+ unless body["resource"].is_a?(Hash)
344
+ raise PineSandbox::Error, PineSandbox.with_context("storage ended the copy without the copy", op: op)
345
+ end
346
+
347
+ return Models::ArtifactFile.from_object(body["resource"], prefix: token.prefix)
348
+ end
349
+
350
+ rewrite_token = body["rewriteToken"]
351
+ if rewrite_token.to_s.empty?
352
+ raise PineSandbox::Error, PineSandbox.with_context("storage stopped the copy without finishing it", op: op)
353
+ end
354
+ end
355
+ end
356
+
357
+ # Deletes the file at `path`. Raises ArtifactNotFoundError when it isn't
358
+ # there. A delete whose answer was lost is sent again; when that finds no
359
+ # file, the first one removed it, and the delete succeeds. Returns true.
360
+ def delete(path)
361
+ path = ArtifactPath.check!(path)
362
+ with_token do |token|
363
+ route, label = object_route(token, "#{token.prefix}#{path}")
364
+ op = "DELETE /#{label}"
365
+ resent = false
366
+ response = resending do |resend|
367
+ resent = resend
368
+ storage_request(token, :delete, route, op: op)
369
+ end
370
+ next if response.status == 404 && resent
371
+
372
+ raise_storage_error!(response, op: op, path: path) unless response.success?
373
+ end
374
+ true
375
+ end
376
+
377
+ private
378
+
379
+ # Runs the block with a current storage token: this Computer's or, with
380
+ # `copy_from`, one that may also read that Computer's artifacts. When
381
+ # storage refuses the token (401), runs it once more with a new one.
382
+ # `write` needs a writable token: a kept one that isn't is replaced, and
383
+ # a new one that isn't either means the artifacts are over their limit.
384
+ def with_token(copy_from: nil, write: false)
385
+ current = token(copy_from, write: write)
386
+ renewed = false
387
+ begin
388
+ if write && !current.writable
389
+ raise ArtifactStorageFullError.new(used_bytes: current.used_bytes, limit_bytes: current.limit_bytes)
390
+ end
391
+
392
+ yield current
393
+ rescue UnauthorizedError
394
+ raise if renewed
395
+
396
+ renewed = true
397
+ current = token(copy_from, refused: current, write: write)
398
+ retry
399
+ end
400
+ end
401
+
402
+ # The kept token for `copy_from`, or a new one when there is none, when it
403
+ # expires within TOKEN_RENEW_BEFORE, when it is the one storage `refused`,
404
+ # or when a `write` finds it read-only (files may have been deleted since).
405
+ def token(copy_from, refused: nil, write: false)
406
+ @mutex.synchronize do
407
+ kept = @tokens[copy_from]
408
+ usable = kept && !kept.equal?(refused) && (kept.writable || !write) &&
409
+ @clock.now < kept.expires_at - TOKEN_RENEW_BEFORE
410
+ return kept if usable
411
+
412
+ payload = @token_source.artifact_token(@computer_id, copy_from: copy_from)
413
+ @tokens[copy_from] = Token.from_payload(payload)
414
+ end
415
+ end
416
+
417
+ # A connection to a storage origin. No credential rides on it: each
418
+ # request carries its own.
419
+ def connection(origin)
420
+ @mutex.synchronize do
421
+ @connections[origin] ||= Faraday.new(url: origin) do |conn|
422
+ conn.headers["User-Agent"] = PineSandbox::USER_AGENT
423
+ conn.options.open_timeout = Adapters::CoordinatorAdapter::DEFAULT_OPEN_TIMEOUT
424
+ conn.options.timeout = Adapters::CoordinatorAdapter::DEFAULT_HTTP_TIMEOUT
425
+ conn.adapter Faraday.default_adapter
426
+ end
427
+ end
428
+ end
429
+
430
+ # Sends one storage request with the token as its bearer. `op` names it
431
+ # in errors.
432
+ def storage_request(token, method, route, op:, params: {}, json: nil, headers: {})
433
+ transport(op) do
434
+ connection(token.endpoint).run_request(method, route, nil, nil) do |req|
435
+ req.params.update(params)
436
+ req.headers["Authorization"] = "Bearer #{token.access_token}"
437
+ req.headers["Accept"] = "application/json"
438
+ headers.each { |name, value| req.headers[name] = value }
439
+ if json
440
+ req.headers["Content-Type"] = "application/json"
441
+ req.body = JSON.generate(json)
442
+ end
443
+ end
444
+ end
445
+ end
446
+
447
+ # Runs the block, a request safe to send again, once more after each
448
+ # transport fault or answer the storage retry rule allows, with
449
+ # RetryPolicy's transient backoff, up to RetryPolicy::TRANSIENT_ATTEMPTS
450
+ # sends, and returns the last answer. Yields whether this send is a
451
+ # resend, which may find the first one's work already done.
452
+ def resending
453
+ attempt = 0
454
+ loop do
455
+ attempt += 1
456
+ begin
457
+ response = yield attempt > 1
458
+ rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError
459
+ raise if attempt >= RetryPolicy::TRANSIENT_ATTEMPTS
460
+ else
461
+ return response unless retry_status?(response.status) && attempt < RetryPolicy::TRANSIENT_ATTEMPTS
462
+ end
463
+ @clock.sleep(RetryPolicy.transient_backoff(attempt, @clock))
464
+ end
465
+ end
466
+
467
+ # Opens a resumable upload of `size` bytes to the object `name` and
468
+ # returns its session URI.
469
+ def start_upload(token, name, size, content_type, note)
470
+ body = { name: name, contentType: content_type }
471
+ body[:metadata] = { "pine-note" => note } unless note.nil?
472
+ route = "upload/#{objects_route(token)}"
473
+ op = "POST /#{route}"
474
+ # Starting a session writes nothing, and a session a lost answer left
475
+ # unused expires on its own, so a start is sent again.
476
+ response = resending do
477
+ storage_request(
478
+ token, :post, route, op: op,
479
+ params: { "uploadType" => "resumable", "name" => name }, json: body,
480
+ headers: { "X-Upload-Content-Type" => content_type, "X-Upload-Content-Length" => size.to_s }
481
+ )
482
+ end
483
+ raise_storage_error!(response, op: op) unless response.success?
484
+
485
+ session = response.headers["location"].to_s
486
+ raise PineSandbox::Error, PineSandbox.with_context("storage opened no upload session", op: op) if session.empty?
487
+
488
+ session
489
+ end
490
+
491
+ # Sends the upload's bytes to its session and returns the saved object.
492
+ # A PUT that ends without the whole file (a dropped connection, an answer
493
+ # worth retrying, or a 308 that kept only part) is followed, after a
494
+ # wait, by asking storage how much it kept; the rest goes in the next PUT.
495
+ # The budget counts tries in a row that moved nothing.
496
+ def send_upload(session, upload, content_type)
497
+ op = "PUT #{URI(session).path}"
498
+ offset = 0
499
+ failures = 0
500
+ loop do
501
+ outcome = upload_call(op) { put_bytes(session, upload, offset, content_type) }
502
+ until finished?(outcome)
503
+ raise_upload_failure!(outcome, op) unless interrupted?(outcome)
504
+
505
+ failures += 1
506
+ raise_upload_failure!(outcome, op) if failures > RetryPolicy::STREAM_RECONNECT_BUDGET
507
+ @clock.sleep(RetryPolicy.stream_backoff(failures, @clock))
508
+ outcome = upload_call(op) { query_upload(session, upload.size, content_type) }
509
+ break if outcome.is_a?(Faraday::Response) && outcome.status == 308
510
+ end
511
+ return parse_answer(outcome, op: op) if finished?(outcome)
512
+
513
+ kept = kept_bytes(outcome)
514
+ failures = 0 if kept > offset
515
+ offset = kept
516
+ end
517
+ end
518
+
519
+ # The session URI is the upload's credential, so these PUTs carry no
520
+ # Authorization. Once storage keeps every byte, the PUT asks for the
521
+ # finished upload instead.
522
+ def put_bytes(session, upload, offset, content_type)
523
+ return query_upload(session, upload.size, content_type) if upload.size.positive? && offset >= upload.size
524
+
525
+ upload.from(offset) do |body|
526
+ connection(origin(session)).put(session) do |req|
527
+ req.headers["Content-Type"] = content_type
528
+ req.headers["Content-Length"] = (upload.size - offset).to_s
529
+ req.headers["Content-Range"] = "bytes #{offset}-#{upload.size - 1}/#{upload.size}" if upload.size.positive?
530
+ req.body = body
531
+ end
532
+ end
533
+ end
534
+
535
+ def query_upload(session, size, content_type)
536
+ connection(origin(session)).put(session) do |req|
537
+ req.headers["Content-Type"] = content_type
538
+ req.headers["Content-Length"] = "0"
539
+ req.headers["Content-Range"] = "bytes */#{size}"
540
+ req.body = ""
541
+ end
542
+ end
543
+
544
+ # An upload request's response, or the transport fault that ended it.
545
+ def upload_call(op)
546
+ transport(op) { yield }
547
+ rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError => e
548
+ e
549
+ end
550
+
551
+ def finished?(outcome) = outcome.is_a?(Faraday::Response) && [200, 201].include?(outcome.status)
552
+
553
+ def interrupted?(outcome)
554
+ !outcome.is_a?(Faraday::Response) || outcome.status == 308 || retry_status?(outcome.status)
555
+ end
556
+
557
+ # Raises why an upload stopped: its transport fault, storage's refusal,
558
+ # or, after a 308, that storage stopped taking bytes.
559
+ def raise_upload_failure!(outcome, op)
560
+ raise outcome unless outcome.is_a?(Faraday::Response)
561
+ if outcome.status == 308
562
+ raise PineSandbox::Error, PineSandbox.with_context("storage stopped the upload without finishing it", op: op)
563
+ end
564
+
565
+ raise_storage_error!(outcome, op: op)
566
+ end
567
+
568
+ # The bytes storage holds, from a 308's `Range: bytes=0-N` (none without it).
569
+ def kept_bytes(response)
570
+ last = response.headers["range"].to_s[/\Abytes=0-(\d+)\z/, 1]
571
+ last ? Integer(last, 10) + 1 : 0
572
+ end
573
+
574
+ # Passes the file at `path` to the block in chunks, each byte once, and
575
+ # returns how many it passed. After a dropped connection or an answer
576
+ # worth retrying it waits and asks again from the next byte with a Range request pinned
577
+ # to the version the first answer named, so a file replaced meanwhile
578
+ # fails (storage's 412) instead of joining two versions. The budget
579
+ # counts tries in a row that received nothing. The block's own error
580
+ # ends the read as it is.
581
+ def receive(path, &sink)
582
+ received = 0
583
+ failures = 0
584
+ generation = nil
585
+ loop do
586
+ before = received
587
+ begin
588
+ read_object(path, from: received, generation: generation) do |chunk, headers|
589
+ generation ||= headers["x-goog-generation"]
590
+ sink.call(chunk)
591
+ received += chunk.bytesize
592
+ end
593
+ return received
594
+ rescue BlockError => e
595
+ raise e.error
596
+ rescue PineSandbox::Error => e
597
+ raise unless resumable?(e)
598
+
599
+ failures = received > before ? 1 : failures + 1
600
+ raise if failures > RetryPolicy::STREAM_RECONNECT_BUDGET
601
+
602
+ @clock.sleep(RetryPolicy.stream_backoff(failures, @clock))
603
+ end
604
+ end
605
+ end
606
+
607
+ # Whether a failed read is worth resuming: a dropped connection, a
608
+ # timeout, or an answer worth retrying.
609
+ def resumable?(error)
610
+ case error
611
+ when PineSandbox::ConnectionError, PineSandbox::TimeoutError then true
612
+ when ApiError then retry_status?(error.status.to_i)
613
+ else false
614
+ end
615
+ end
616
+
617
+ # Whether a storage answer of `status` is worth sending again, by the rule
618
+ # every SDK shares (contract/storage-retry.json): a request timeout, a 429
619
+ # and any 5xx but 501. A 412 means the file changed under a read pinned to
620
+ # its version, so it never is.
621
+ def retry_status?(status) = status == 408 || status == 429 || (status >= 500 && status != 501)
622
+
623
+ # GETs the file's bytes from byte `from` on, passing each new chunk of a
624
+ # successful answer, with its headers, to the block. A read from a later
625
+ # byte asks for `generation` only. Any other answer raises before a
626
+ # chunk: ArtifactNotFoundError for a missing file.
627
+ def read_object(path, from: 0, generation: nil, &sink)
628
+ with_token do |token|
629
+ route, label = object_route(token, "#{token.prefix}#{path}")
630
+ stream_media(token, route, "GET /#{label}", path, from, generation, sink)
631
+ end
632
+ end
633
+
634
+ def stream_media(token, route, op, path, from, generation, sink)
635
+ delivered = 0
636
+ # The bytes at the answer's start that were passed on before, known
637
+ # once the first chunk shows the answer.
638
+ repeated = nil
639
+ deliver = lambda do |chunk, answer|
640
+ repeated ||= repeated_bytes(answer, from, op)
641
+ delivered += chunk.bytesize
642
+ fresh = chunk.byteslice([repeated, chunk.bytesize].min..)
643
+ repeated -= chunk.bytesize - fresh.bytesize
644
+ pass_on(sink, fresh, answer) unless fresh.empty?
645
+ end
646
+ response = StreamedRead.read(deliver) do |on_data|
647
+ transport(op) do
648
+ connection(token.endpoint).get(route) do |req|
649
+ req.params["alt"] = "media"
650
+ req.headers["Authorization"] = "Bearer #{token.access_token}"
651
+ if from.positive?
652
+ req.headers["Range"] = "bytes=#{from}-"
653
+ req.params["ifGenerationMatch"] = generation if generation
654
+ end
655
+ req.options.on_data = on_data
656
+ end
657
+ end
658
+ end
659
+ raise_storage_error!(response, op: op, path: path) unless response.success?
660
+ # Net::HTTP ends a body that stops short of its length like a complete
661
+ # one, so a connection dropped mid-file is caught here.
662
+ declared = declared_bytes(response)
663
+ if declared && delivered < declared
664
+ raise PineSandbox::ConnectionError,
665
+ PineSandbox.with_context("storage connection ended after #{delivered} of #{declared} bytes", op: op)
666
+ end
667
+ response
668
+ end
669
+
670
+ # How many bytes at the start of a media answer to a read from byte
671
+ # `from` were passed on before: `from` when storage sends the whole file,
672
+ # none when it sends the range asked for. A range from anywhere else is
673
+ # PineSandbox::Error. `answer` is the Faraday env or response.
674
+ def repeated_bytes(answer, from, op)
675
+ return 0 unless from.positive?
676
+ return from unless answer.status == 206
677
+
678
+ range = answer_headers(answer)["content-range"].to_s
679
+ start = range[/\Abytes (\d+)-/, 1]
680
+ return 0 if start && Integer(start, 10) == from
681
+
682
+ raise PineSandbox::Error,
683
+ PineSandbox.with_context("storage resumed the artifact at the wrong place (Content-Range #{range.inspect})", op: op)
684
+ end
685
+
686
+ # The bytes a media answer says it carries, when it says so plainly (not
687
+ # for a compressed one, whose length is of the compressed bytes).
688
+ def declared_bytes(response)
689
+ encoding = response.headers["content-encoding"].to_s
690
+ return unless encoding.empty? || encoding.casecmp?("identity")
691
+
692
+ range = response.headers["content-range"].to_s.match(%r{\Abytes (\d+)-(\d+)/})
693
+ return Integer(range[2], 10) - Integer(range[1], 10) + 1 if response.status == 206 && range
694
+
695
+ length = response.headers["content-length"].to_s
696
+ Integer(length, 10) if length.match?(/\A\d+\z/)
697
+ end
698
+
699
+ # Passes a chunk, with its answer's headers, to the block.
700
+ def pass_on(sink, chunk, answer) = StreamedRead.pass(sink, chunk, answer_headers(answer))
701
+
702
+ # The headers of `answer`, a Faraday env or response.
703
+ def answer_headers(answer) = answer.respond_to?(:response_headers) ? answer.response_headers : answer.headers
704
+
705
+ # Maps a transport fault to the SDK's typed errors. `op` carries no query,
706
+ # so an upload session's id never reaches a message.
707
+ def transport(op)
708
+ yield
709
+ rescue Faraday::TimeoutError => e
710
+ raise PineSandbox::TimeoutError, PineSandbox.with_context("storage request timed out: #{e.message}", op: op)
711
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
712
+ raise PineSandbox::ConnectionError, PineSandbox.with_context("storage connection failed: #{e.message}", op: op)
713
+ end
714
+
715
+ # Raises the error for a storage answer that isn't a success, of the
716
+ # operation `op`. For a call on one file (`path`), a 404 is
717
+ # ArtifactNotFoundError; anything else is the status tier's ApiError with
718
+ # storage's own message. Its #retryable? is the storage rule's.
719
+ def raise_storage_error!(response, op:, path: nil)
720
+ body = parse_error_answer(response.body)
721
+ detail = body["error"].is_a?(Hash) ? body["error"]["message"].to_s : ""
722
+ fields = { status: response.status, response_body: response.body.to_s,
723
+ problem_extensions: { "op" => op, "retryable" => retry_status?(response.status) } }
724
+ error =
725
+ if response.status == 404 && path
726
+ ArtifactNotFoundError.new(path: path, error_message: "no artifact at #{path}", **fields)
727
+ else
728
+ message = detail.empty? ? "storage request failed (HTTP #{response.status})" : detail
729
+ status_tier_class_for(response.status).new(error_message: message, **fields)
730
+ end
731
+ seconds = Integer(response.headers["retry-after"].to_s.strip, 10, exception: false)
732
+ error.retry_after = seconds if seconds&.positive?
733
+ raise error
734
+ end
735
+
736
+ # A success answer's JSON object. Any other body is an answer the storage
737
+ # protocol doesn't allow.
738
+ def parse_answer(response, op:)
739
+ parsed = JSON.parse(response.body.to_s)
740
+ return parsed if parsed.is_a?(Hash)
741
+
742
+ raise PineSandbox::Error, PineSandbox.with_context("storage answered with JSON that isn't an object", op: op)
743
+ rescue JSON::ParserError
744
+ raise PineSandbox::Error, PineSandbox.with_context("storage answered with a body that isn't JSON", op: op)
745
+ end
746
+
747
+ # An error answer's JSON object, or {} when its body is none: the error
748
+ # keeps the raw body either way.
749
+ def parse_error_answer(body)
750
+ parsed = JSON.parse(body.to_s)
751
+ parsed.is_a?(Hash) ? parsed : {}
752
+ rescue JSON::ParserError
753
+ {}
754
+ end
755
+
756
+ # The JSON API route of the objects in the token's bucket.
757
+ def objects_route(token) = "storage/v1/b/#{escape(token.bucket)}/o"
758
+
759
+ # The JSON API route of the object `name`, and the same route with
760
+ # "{object}" for the name, for errors.
761
+ def object_route(token, name)
762
+ objects = objects_route(token)
763
+ ["#{objects}/#{escape(name)}", "#{objects}/{object}"]
764
+ end
765
+
766
+ # Percent-encodes every byte but the unreserved ones, "/" and " " included.
767
+ def escape(segment)
768
+ segment.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format("%%%02X", byte.ord) }
769
+ end
770
+
771
+ def origin(url)
772
+ uri = URI(url)
773
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
774
+ end
775
+ end
776
+ end