browserctl 0.10.0 → 0.12.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. metadata +44 -7
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require_relative "../errors"
7
+ require_relative "../error/codes"
8
+
9
+ module Browserctl
10
+ module State
11
+ # Single-file portable codec for browserctl session state — the .bctl
12
+ # bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
13
+ # alongside a payload of cookies + storage. The manifest is always
14
+ # readable without a passphrase (so `state info` can show origins and
15
+ # expiry); the payload is optionally encrypted.
16
+ #
17
+ # Wire format (big-endian):
18
+ #
19
+ # magic: "BCTL\x00" 5 bytes
20
+ # version: 0x01 1 byte
21
+ # flags: bit 0 = encrypted 1 byte
22
+ # reserved: 0x00 1 byte
23
+ # manifest_len: 4 bytes
24
+ # manifest: JSON manifest_len bytes (always plaintext)
25
+ # payload_len: 4 bytes
26
+ # payload: see below payload_len bytes
27
+ # footer: 32 bytes
28
+ #
29
+ # When flags & 0x01 is unset:
30
+ # payload = JSON bytes (plaintext)
31
+ # footer = SHA-256 over magic..payload (corruption detection)
32
+ #
33
+ # When flags & 0x01 is set:
34
+ # payload = salt(16) || nonce(12) || ciphertext || tag(16)
35
+ # footer = HMAC-SHA-256(hmac_key, magic..payload)
36
+ # salt drives PBKDF2(passphrase, salt, 200_000, SHA-256, 64-byte output);
37
+ # first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
38
+ # the HMAC-SHA-256 key.
39
+ #
40
+ # Reuses the same AES-256-GCM primitive as v0.8 session encryption
41
+ # (lib/browserctl/session.rb). The two will share a Crypto module in a
42
+ # follow-up; duplicated here to keep this PR focused.
43
+ class Bundle
44
+ MAGIC = "BCTL\x00".b.freeze
45
+ VERSION = 1
46
+ # Manifest-level format version, written as `format_version` and
47
+ # validated on decode. Distinct from the wire-format byte `VERSION`
48
+ # above (which gates the binary envelope shape) — this gates the
49
+ # manifest schema. See docs/reference/format-versions.md.
50
+ BUNDLE_FORMAT_VERSION = 1
51
+ SUPPORTED_FORMAT_VERSIONS = [BUNDLE_FORMAT_VERSION].freeze
52
+ FLAG_ENCRYPTED = 0x01
53
+ HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
54
+ LEN_SIZE = 4
55
+ FOOTER_SIZE = 32
56
+ SALT_SIZE = 16
57
+ NONCE_SIZE = 12
58
+ TAG_SIZE = 16
59
+ PBKDF2_ITERS = 200_000
60
+
61
+ class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
62
+ class TamperError < BundleError; def self.default_code = "bundle_tampered" end
63
+ class PassphraseError < BundleError; def self.default_code = "bundle_passphrase" end
64
+
65
+ # Encodes manifest + payload into a single binary blob.
66
+ #
67
+ # @param manifest [Hash] plaintext manifest (always readable)
68
+ # @param payload [Hash] cookies/storage; encrypted when passphrase given
69
+ # @param passphrase [String, nil] when given, payload is encrypted and
70
+ # the footer is an HMAC. When nil, payload is plaintext and the
71
+ # footer is a SHA-256 digest.
72
+ def self.encode(manifest:, payload:, passphrase: nil)
73
+ manifest = stamp_format_version(manifest)
74
+ manifest_bytes = JSON.generate(manifest).b
75
+ payload_json = JSON.generate(payload).b
76
+ flags = 0
77
+ hmac_key = nil
78
+
79
+ if passphrase
80
+ salt = SecureRandom.bytes(SALT_SIZE)
81
+ enc_key, hmac_key = derive_keys(passphrase, salt)
82
+ payload_bytes = salt + aes_gcm_encrypt(payload_json, enc_key)
83
+ flags |= FLAG_ENCRYPTED
84
+ else
85
+ payload_bytes = payload_json
86
+ end
87
+
88
+ body = build_body(flags, manifest_bytes, payload_bytes)
89
+ body + footer_for(body, hmac_key)
90
+ end
91
+
92
+ # Decodes a blob, verifying the footer and decrypting payload when
93
+ # encrypted. Raises TamperError on digest/HMAC mismatch and
94
+ # PassphraseError when an encrypted bundle is decoded without a
95
+ # passphrase or with the wrong one.
96
+ def self.decode(blob, passphrase: nil)
97
+ magic, version, flags = read_header!(blob)
98
+ raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
99
+
100
+ manifest_bytes, payload_bytes, footer = read_sections!(blob)
101
+ body = blob.byteslice(0, blob.bytesize - FOOTER_SIZE)
102
+
103
+ encrypted = flags.anybits?(FLAG_ENCRYPTED)
104
+ verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
105
+
106
+ manifest = JSON.parse(manifest_bytes, symbolize_names: true)
107
+ verify_format_version!(manifest)
108
+ payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
109
+
110
+ { manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
111
+ end
112
+
113
+ # Reads the manifest without verifying the footer or decrypting the
114
+ # payload. Use for `state info` and similar read-only queries.
115
+ def self.peek_manifest(blob)
116
+ _, version, = read_header!(blob)
117
+ raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
118
+
119
+ manifest_bytes, = read_sections!(blob)
120
+ manifest = JSON.parse(manifest_bytes, symbolize_names: true)
121
+ verify_format_version!(manifest)
122
+ manifest
123
+ end
124
+
125
+ # Returns the manifest with `format_version` set as the first key. When
126
+ # the caller already provided a value we keep it (so encoders can stamp
127
+ # a future version explicitly); otherwise we stamp the current one.
128
+ def self.stamp_format_version(manifest)
129
+ existing = manifest[:format_version] || manifest["format_version"]
130
+ version = existing || BUNDLE_FORMAT_VERSION
131
+ rest = manifest.except(:format_version, "format_version")
132
+ { format_version: version }.merge(rest)
133
+ end
134
+ private_class_method :stamp_format_version
135
+
136
+ # Raises Browserctl::ProtocolMismatch when the manifest declares no
137
+ # format_version or one this build does not support. The error carries
138
+ # the canonical PROTOCOL_MISMATCH code from the v0.12 error taxonomy.
139
+ def self.verify_format_version!(manifest, path: nil)
140
+ version = manifest[:format_version] || manifest["format_version"]
141
+ return if version && SUPPORTED_FORMAT_VERSIONS.include?(version)
142
+
143
+ where = path ? " at #{path}" : ""
144
+ msg = if version.nil?
145
+ "bundle manifest#{where} is missing format_version " \
146
+ "(supported: #{SUPPORTED_FORMAT_VERSIONS.inspect})"
147
+ else
148
+ "bundle manifest#{where} declares format_version=#{version.inspect}, " \
149
+ "this build supports #{SUPPORTED_FORMAT_VERSIONS.inspect}"
150
+ end
151
+ raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
152
+ end
153
+ private_class_method :verify_format_version!
154
+
155
+ def self.build_body(flags, manifest_bytes, payload_bytes)
156
+ header = MAGIC + [VERSION, flags, 0].pack("CCC")
157
+ header +
158
+ [manifest_bytes.bytesize].pack("N") + manifest_bytes +
159
+ [payload_bytes.bytesize].pack("N") + payload_bytes
160
+ end
161
+ private_class_method :build_body
162
+
163
+ def self.footer_for(body, hmac_key)
164
+ if hmac_key
165
+ OpenSSL::HMAC.digest("SHA256", hmac_key, body)
166
+ else
167
+ OpenSSL::Digest.digest("SHA256", body)
168
+ end
169
+ end
170
+ private_class_method :footer_for
171
+
172
+ def self.read_header!(blob)
173
+ raise BundleError, "blob too small for header" if blob.bytesize < HEADER_SIZE + (2 * LEN_SIZE) + FOOTER_SIZE
174
+
175
+ magic = blob.byteslice(0, MAGIC.bytesize)
176
+ raise BundleError, "bad magic — not a .bctl bundle" unless magic == MAGIC
177
+
178
+ version, flags, _reserved = blob.byteslice(MAGIC.bytesize, 3).unpack("CCC")
179
+ [magic, version, flags]
180
+ end
181
+ private_class_method :read_header!
182
+
183
+ def self.read_sections!(blob)
184
+ cursor = HEADER_SIZE
185
+ manifest_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
186
+ cursor += LEN_SIZE
187
+ manifest_bytes = blob.byteslice(cursor, manifest_len)
188
+ cursor += manifest_len
189
+ payload_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
190
+ cursor += LEN_SIZE
191
+ payload_bytes = blob.byteslice(cursor, payload_len)
192
+ cursor += payload_len
193
+ footer = blob.byteslice(cursor, FOOTER_SIZE)
194
+
195
+ unless manifest_bytes && payload_bytes && footer && footer.bytesize == FOOTER_SIZE
196
+ raise BundleError, "truncated bundle"
197
+ end
198
+
199
+ [manifest_bytes, payload_bytes, footer]
200
+ end
201
+ private_class_method :read_sections!
202
+
203
+ def self.verify_footer!(body, footer, encrypted:, passphrase:)
204
+ if encrypted
205
+ raise PassphraseError, "encrypted bundle requires a passphrase" unless passphrase
206
+
207
+ # We need the HMAC key, which depends on the salt embedded in the
208
+ # payload. Pull the salt from the payload bytes inside `body`.
209
+ salt = extract_salt!(body)
210
+ _, hmac_key = derive_keys(passphrase, salt)
211
+ expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
212
+ raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
213
+ else
214
+ expected = OpenSSL::Digest.digest("SHA256", body)
215
+ raise TamperError, "bundle digest mismatch — file is corrupted or modified" unless secure_eq?(footer,
216
+ expected)
217
+ end
218
+ end
219
+ private_class_method :verify_footer!
220
+
221
+ def self.extract_salt!(body)
222
+ cursor = HEADER_SIZE
223
+ manifest_len = body.byteslice(cursor, LEN_SIZE).unpack1("N")
224
+ cursor += LEN_SIZE + manifest_len + LEN_SIZE
225
+ body.byteslice(cursor, SALT_SIZE) or raise BundleError, "encrypted payload missing salt"
226
+ end
227
+ private_class_method :extract_salt!
228
+
229
+ def self.decode_payload(bytes, encrypted:, passphrase:)
230
+ if encrypted
231
+ salt = bytes.byteslice(0, SALT_SIZE)
232
+ ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
233
+ enc_key, = derive_keys(passphrase, salt)
234
+ plaintext = aes_gcm_decrypt(ciphertext, enc_key)
235
+ JSON.parse(plaintext)
236
+ else
237
+ JSON.parse(bytes)
238
+ end
239
+ rescue OpenSSL::Cipher::CipherError
240
+ raise PassphraseError, "wrong passphrase — payload could not be decrypted"
241
+ end
242
+ private_class_method :decode_payload
243
+
244
+ def self.derive_keys(passphrase, salt)
245
+ material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, 64, "SHA256")
246
+ [material.byteslice(0, 32), material.byteslice(32, 32)]
247
+ end
248
+ private_class_method :derive_keys
249
+
250
+ def self.aes_gcm_encrypt(plaintext, key)
251
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
252
+ cipher.encrypt
253
+ cipher.key = key
254
+ nonce = SecureRandom.bytes(NONCE_SIZE)
255
+ cipher.iv = nonce
256
+ ct = cipher.update(plaintext) + cipher.final
257
+ nonce + ct + cipher.auth_tag
258
+ end
259
+ private_class_method :aes_gcm_encrypt
260
+
261
+ def self.aes_gcm_decrypt(blob, key)
262
+ nonce = blob.byteslice(0, NONCE_SIZE)
263
+ tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
264
+ ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
265
+
266
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
267
+ cipher.decrypt
268
+ cipher.key = key
269
+ cipher.iv = nonce
270
+ cipher.auth_tag = tag
271
+ cipher.update(ciphertext) + cipher.final
272
+ end
273
+ private_class_method :aes_gcm_decrypt
274
+
275
+ def self.secure_eq?(actual, expected)
276
+ return false if actual.bytesize != expected.bytesize
277
+
278
+ OpenSSL.fixed_length_secure_compare(actual, expected)
279
+ end
280
+ private_class_method :secure_eq?
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../errors"
5
+
6
+ module Browserctl
7
+ module State
8
+ # Pluggable transport for moving .bctl bundles in and out of remote
9
+ # systems. Each transport responds to:
10
+ #
11
+ # .scheme String — URI scheme it handles ("file", "s3", "op", ...)
12
+ # #handles?(uri) -> Boolean
13
+ # #available? -> Boolean (e.g. CLI tool present, network reachable)
14
+ # #read(uri) -> binary String (the bundle bytes)
15
+ # #write(uri, blob) -> nil; raises on failure
16
+ #
17
+ # Transports are matched by URI scheme. A bare path with no scheme falls
18
+ # through to the FileTransport.
19
+ module Transport
20
+ class TransportError < Browserctl::Error; def self.default_code = "transport_error" end
21
+
22
+ class << self
23
+ def registry
24
+ @registry ||= []
25
+ end
26
+
27
+ def register(transport)
28
+ registry << transport
29
+ transport
30
+ end
31
+
32
+ def for(uri)
33
+ parsed = parse(uri)
34
+ match = registry.find { |t| t.handles?(parsed) }
35
+ raise TransportError, "no transport for #{uri.inspect}" unless match
36
+
37
+ unless match.available?
38
+ scheme = parsed.scheme || "file"
39
+ raise TransportError, "transport for '#{scheme}://' is not available — install the underlying CLI"
40
+ end
41
+
42
+ [match, parsed]
43
+ end
44
+
45
+ def parse(uri)
46
+ uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
47
+ rescue URI::InvalidURIError
48
+ # bare path with characters URI rejects — treat as file
49
+ URI.parse("file://#{uri}")
50
+ end
51
+ end
52
+
53
+ class Base
54
+ def self.scheme = raise NotImplementedError
55
+
56
+ def handles?(parsed)
57
+ parsed.scheme.nil? || parsed.scheme == self.class.scheme
58
+ end
59
+
60
+ def available? = true
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../transport"
5
+
6
+ module Browserctl
7
+ module State
8
+ module Transports
9
+ # Default transport — reads/writes plain files. Handles bare paths and
10
+ # `file://` URIs. Always available.
11
+ class File < Transport::Base
12
+ def self.scheme = "file"
13
+
14
+ def read(parsed)
15
+ path = local_path(parsed)
16
+ raise Transport::TransportError, "file not found: #{path}" unless ::File.exist?(path)
17
+
18
+ ::File.binread(path)
19
+ end
20
+
21
+ def write(parsed, blob)
22
+ path = local_path(parsed)
23
+ FileUtils.mkdir_p(::File.dirname(path))
24
+ ::File.open(path, "wb", 0o600) { |f| f.write(blob) }
25
+ end
26
+
27
+ def local_path(parsed)
28
+ ::File.expand_path(parsed.path.to_s.empty? ? parsed.opaque.to_s : parsed.path)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Browserctl::State::Transport.register(Browserctl::State::Transports::File.new)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require_relative "../transport"
6
+
7
+ module Browserctl
8
+ module State
9
+ module Transports
10
+ # 1Password transport — stores bundles as Documents.
11
+ # URIs look like `op://Vault/ItemName`.
12
+ #
13
+ # The op CLI has no streaming primitive for documents, so we stage
14
+ # the blob to a tmpfile (chmod 0600) and use `op document create` /
15
+ # `op document get`.
16
+ class OnePassword < Transport::Base
17
+ SAFE_REF = %r{\Aop://[^/]+/[^/]+\z}
18
+
19
+ def self.scheme = "op"
20
+
21
+ def available?
22
+ system("which", "op", out: ::File::NULL, err: ::File::NULL)
23
+ end
24
+
25
+ def read(parsed)
26
+ uri = parsed.to_s
27
+ validate!(uri)
28
+ out, err, status = Open3.capture3("op", "read", uri, binmode: true)
29
+ return out if status.success?
30
+
31
+ raise Transport::TransportError, "op read failed: #{err.strip.empty? ? out : err}"
32
+ end
33
+
34
+ def write(parsed, blob)
35
+ uri = parsed.to_s
36
+ validate!(uri)
37
+ vault, title = parse_ref(uri)
38
+
39
+ Dir.mktmpdir do |tmp|
40
+ path = ::File.join(tmp, "#{title}.bctl")
41
+ ::File.open(path, "wb", 0o600) { |f| f.write(blob) }
42
+
43
+ args = ["op", "document", "create", path, "--title", title, "--vault", vault]
44
+ out, err, status = Open3.capture3(*args)
45
+ unless status.success?
46
+ raise Transport::TransportError, "op document create failed: #{err.strip.empty? ? out : err}"
47
+ end
48
+ end
49
+ end
50
+
51
+ def validate!(uri)
52
+ return if SAFE_REF.match?(uri)
53
+
54
+ raise Transport::TransportError, "invalid 1Password reference: #{uri.inspect} (expected op://Vault/Item)"
55
+ end
56
+
57
+ def parse_ref(uri)
58
+ # op://Vault/Item — exactly two segments after the scheme
59
+ rest = uri.delete_prefix("op://")
60
+ rest.split("/", 2)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ Browserctl::State::Transport.register(Browserctl::State::Transports::OnePassword.new)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../transport"
5
+
6
+ module Browserctl
7
+ module State
8
+ module Transports
9
+ # S3 transport — shells out to the `aws` CLI so we don't drag the
10
+ # aws-sdk gem into core. URIs look like `s3://bucket/path/key.bctl`.
11
+ # Credentials/region come from the user's normal AWS environment
12
+ # (env vars, `~/.aws/credentials`, IAM, etc.).
13
+ class S3 < Transport::Base
14
+ def self.scheme = "s3"
15
+
16
+ def available?
17
+ system("which", "aws", out: ::File::NULL, err: ::File::NULL)
18
+ end
19
+
20
+ def read(parsed)
21
+ run!("aws", "s3", "cp", parsed.to_s, "-", binmode: true)
22
+ end
23
+
24
+ def write(parsed, blob)
25
+ out, err, status = Open3.capture3("aws", "s3", "cp", "-", parsed.to_s, stdin_data: blob, binmode: true)
26
+ return if status.success?
27
+
28
+ raise Transport::TransportError, "aws s3 cp failed: #{err.strip.empty? ? out : err}"
29
+ end
30
+
31
+ def run!(*cmd, binmode: false)
32
+ out, err, status = Open3.capture3(*cmd, binmode: binmode)
33
+ return out if status.success?
34
+
35
+ raise Transport::TransportError, "#{cmd.first} failed: #{err.strip.empty? ? out : err}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ Browserctl::State::Transport.register(Browserctl::State::Transports::S3.new)