browserctl 0.9.0 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +1 -1
  4. data/bin/browserctl +45 -4
  5. data/lib/browserctl/client.rb +47 -3
  6. data/lib/browserctl/commands/cli_output.rb +16 -3
  7. data/lib/browserctl/commands/flow.rb +123 -0
  8. data/lib/browserctl/commands/state.rb +193 -0
  9. data/lib/browserctl/commands/workflow.rb +62 -4
  10. data/lib/browserctl/constants.rb +1 -1
  11. data/lib/browserctl/detectors/auth_required.rb +128 -0
  12. data/lib/browserctl/detectors.rb +2 -0
  13. data/lib/browserctl/errors.rb +36 -0
  14. data/lib/browserctl/flow.rb +215 -0
  15. data/lib/browserctl/flow_registry.rb +66 -0
  16. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  17. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  18. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  19. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  20. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  21. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  22. data/lib/browserctl/recording.rb +212 -26
  23. data/lib/browserctl/replay/context.rb +40 -0
  24. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  25. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  26. data/lib/browserctl/replay/telemetry.rb +60 -0
  27. data/lib/browserctl/runner.rb +38 -4
  28. data/lib/browserctl/server/command_dispatcher.rb +10 -1
  29. data/lib/browserctl/server/handlers/interaction.rb +3 -3
  30. data/lib/browserctl/server/handlers/navigation.rb +33 -4
  31. data/lib/browserctl/server/handlers/observation.rb +43 -2
  32. data/lib/browserctl/server/handlers/state.rb +149 -0
  33. data/lib/browserctl/server/page_session.rb +9 -7
  34. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  35. data/lib/browserctl/snapshot/annotator.rb +75 -0
  36. data/lib/browserctl/snapshot/extractor.rb +21 -0
  37. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  38. data/lib/browserctl/snapshot/ref.rb +70 -0
  39. data/lib/browserctl/snapshot/serializer.rb +17 -0
  40. data/lib/browserctl/state/bundle.rb +242 -0
  41. data/lib/browserctl/state/transport.rb +64 -0
  42. data/lib/browserctl/state/transports/file.rb +35 -0
  43. data/lib/browserctl/state/transports/one_password.rb +67 -0
  44. data/lib/browserctl/state/transports/s3.rb +42 -0
  45. data/lib/browserctl/state.rb +208 -0
  46. data/lib/browserctl/version.rb +1 -1
  47. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  48. data/lib/browserctl/workflow/promoter.rb +96 -0
  49. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  50. data/lib/browserctl/workflow.rb +180 -16
  51. metadata +32 -2
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "browserctl/snapshot/ref"
4
+
5
+ module Browserctl
6
+ module Snapshot
7
+ # Builds a per-element fingerprint that survives small DOM changes.
8
+ #
9
+ # The fingerprint is later used by the replay layer to rematch an
10
+ # element when its recorded selector no longer resolves: score the
11
+ # candidate elements in the new DOM against the recorded fingerprint
12
+ # and pick the best match above a threshold.
13
+ #
14
+ # Shape:
15
+ # {
16
+ # text: <accessible name>,
17
+ # role: <ARIA role, explicit or implicit>,
18
+ # neighbors: [<short text of nearby siblings>, ...],
19
+ # position: { index: <int>, depth: <int> }
20
+ # }
21
+ class Fingerprint
22
+ NEIGHBOR_RADIUS = 2 # siblings to capture on each side
23
+ NEIGHBOR_TEXT_LEN = 40
24
+
25
+ def initialize(ref_deriver: RefDeriver.new)
26
+ @ref_deriver = ref_deriver
27
+ end
28
+
29
+ def build(node)
30
+ {
31
+ text: accessible_name(node),
32
+ role: role(node),
33
+ neighbors: neighbors(node),
34
+ position: position(node)
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def role(node)
41
+ explicit = node["role"]
42
+ return explicit if explicit && !explicit.empty?
43
+
44
+ RefDeriver::IMPLICIT_ROLE[node.name] || node.name
45
+ end
46
+
47
+ def accessible_name(node)
48
+ %w[aria-label placeholder alt title].each do |attr|
49
+ v = node[attr]
50
+ return v.strip if v && !v.strip.empty?
51
+ end
52
+ node.text.to_s.strip.slice(0, 80)
53
+ end
54
+
55
+ def neighbors(node)
56
+ parent = node.parent
57
+ return [] unless parent.respond_to?(:children)
58
+
59
+ idx = parent.children.to_a.index(node) || 0
60
+ window = parent.children.to_a[[idx - NEIGHBOR_RADIUS, 0].max...(idx + NEIGHBOR_RADIUS + 1)] || []
61
+ window
62
+ .reject { |c| c == node || !c.respond_to?(:name) }
63
+ .map { |c| neighbor_signal(c) }
64
+ .reject(&:empty?)
65
+ end
66
+
67
+ def neighbor_signal(node)
68
+ text = node.text.to_s.strip.gsub(/\s+/, " ").slice(0, NEIGHBOR_TEXT_LEN)
69
+ text.empty? ? "" : "#{node.name}:#{text}"
70
+ end
71
+
72
+ def position(node)
73
+ idx = node.parent.respond_to?(:children) ? (node.parent.children.to_a.index(node) || 0) : 0
74
+ { index: idx, depth: depth(node) }
75
+ end
76
+
77
+ def depth(node)
78
+ d = 0
79
+ cur = node.parent
80
+ while cur.respond_to?(:name) && cur.name != "document"
81
+ d += 1
82
+ cur = cur.parent
83
+ end
84
+ d
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Browserctl
6
+ module Snapshot
7
+ # Derives a stable element ref from semantic + structural signals.
8
+ #
9
+ # The same DOM element should produce the same ref across two snapshots
10
+ # of the same page. Inputs to the hash are:
11
+ # - role (explicit @role, else implicit ARIA role from tag)
12
+ # - accessible name (aria-label || text || placeholder || alt)
13
+ # - tag
14
+ # - parent path (chain of ancestor tag names up to <html>)
15
+ #
16
+ # Collisions within a single snapshot are disambiguated by the caller via
17
+ # `disambiguate(ref, taken)` — the deriver itself is pure.
18
+ class RefDeriver
19
+ IMPLICIT_ROLE = {
20
+ "a" => "link", "button" => "button", "input" => "textbox",
21
+ "select" => "combobox", "textarea" => "textbox"
22
+ }.freeze
23
+
24
+ HASH_LEN = 7
25
+
26
+ def derive(node)
27
+ signal = [role(node), accessible_name(node), node.name, parent_path(node)].join("|")
28
+ "e#{Digest::SHA256.hexdigest(signal)[0, HASH_LEN]}"
29
+ end
30
+
31
+ # Given a candidate ref and a set of already-taken refs in the current
32
+ # snapshot, return a unique ref. Adds `-2`, `-3`, ... as needed.
33
+ def disambiguate(ref, taken)
34
+ return ref unless taken.include?(ref)
35
+
36
+ n = 2
37
+ n += 1 while taken.include?("#{ref}-#{n}")
38
+ "#{ref}-#{n}"
39
+ end
40
+
41
+ private
42
+
43
+ def role(node)
44
+ explicit = node["role"]
45
+ return explicit if explicit && !explicit.empty?
46
+
47
+ IMPLICIT_ROLE[node.name] || node.name
48
+ end
49
+
50
+ def accessible_name(node)
51
+ %w[aria-label placeholder alt title].each do |attr|
52
+ v = node[attr]
53
+ return v.strip if v && !v.strip.empty?
54
+ end
55
+ text = node.text.to_s.strip
56
+ text.empty? ? "" : text.slice(0, 80)
57
+ end
58
+
59
+ def parent_path(node)
60
+ parts = []
61
+ cur = node.parent
62
+ while cur.respond_to?(:name) && cur.name != "html" && cur.name != "document"
63
+ parts.unshift(cur.name)
64
+ cur = cur.parent
65
+ end
66
+ parts.join(">")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Snapshot
5
+ # Stage 3 of the snapshot pipeline.
6
+ #
7
+ # Right now this is the identity function — annotated entries are
8
+ # already in the wire shape clients expect. It exists as a seam so
9
+ # later milestones can canonicalize, redact, or compress without
10
+ # touching extraction or annotation.
11
+ class Serializer
12
+ def call(entries)
13
+ entries
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require_relative "../errors"
7
+
8
+ module Browserctl
9
+ module State
10
+ # Single-file portable codec for browserctl session state — the .bctl
11
+ # bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
12
+ # alongside a payload of cookies + storage. The manifest is always
13
+ # readable without a passphrase (so `state info` can show origins and
14
+ # expiry); the payload is optionally encrypted.
15
+ #
16
+ # Wire format (big-endian):
17
+ #
18
+ # magic: "BCTL\x00" 5 bytes
19
+ # version: 0x01 1 byte
20
+ # flags: bit 0 = encrypted 1 byte
21
+ # reserved: 0x00 1 byte
22
+ # manifest_len: 4 bytes
23
+ # manifest: JSON manifest_len bytes (always plaintext)
24
+ # payload_len: 4 bytes
25
+ # payload: see below payload_len bytes
26
+ # footer: 32 bytes
27
+ #
28
+ # When flags & 0x01 is unset:
29
+ # payload = JSON bytes (plaintext)
30
+ # footer = SHA-256 over magic..payload (corruption detection)
31
+ #
32
+ # When flags & 0x01 is set:
33
+ # payload = salt(16) || nonce(12) || ciphertext || tag(16)
34
+ # footer = HMAC-SHA-256(hmac_key, magic..payload)
35
+ # salt drives PBKDF2(passphrase, salt, 200_000, SHA-256, 64-byte output);
36
+ # first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
37
+ # the HMAC-SHA-256 key.
38
+ #
39
+ # Reuses the same AES-256-GCM primitive as v0.8 session encryption
40
+ # (lib/browserctl/session.rb). The two will share a Crypto module in a
41
+ # follow-up; duplicated here to keep this PR focused.
42
+ class Bundle
43
+ MAGIC = "BCTL\x00".b.freeze
44
+ VERSION = 1
45
+ FLAG_ENCRYPTED = 0x01
46
+ HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
47
+ LEN_SIZE = 4
48
+ FOOTER_SIZE = 32
49
+ SALT_SIZE = 16
50
+ NONCE_SIZE = 12
51
+ TAG_SIZE = 16
52
+ PBKDF2_ITERS = 200_000
53
+
54
+ class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
55
+ class TamperError < BundleError; def self.default_code = "bundle_tampered" end
56
+ class PassphraseError < BundleError; def self.default_code = "bundle_passphrase" end
57
+
58
+ # Encodes manifest + payload into a single binary blob.
59
+ #
60
+ # @param manifest [Hash] plaintext manifest (always readable)
61
+ # @param payload [Hash] cookies/storage; encrypted when passphrase given
62
+ # @param passphrase [String, nil] when given, payload is encrypted and
63
+ # the footer is an HMAC. When nil, payload is plaintext and the
64
+ # footer is a SHA-256 digest.
65
+ def self.encode(manifest:, payload:, passphrase: nil)
66
+ manifest_bytes = JSON.generate(manifest).b
67
+ payload_json = JSON.generate(payload).b
68
+ flags = 0
69
+ hmac_key = nil
70
+
71
+ if passphrase
72
+ salt = SecureRandom.bytes(SALT_SIZE)
73
+ enc_key, hmac_key = derive_keys(passphrase, salt)
74
+ payload_bytes = salt + aes_gcm_encrypt(payload_json, enc_key)
75
+ flags |= FLAG_ENCRYPTED
76
+ else
77
+ payload_bytes = payload_json
78
+ end
79
+
80
+ body = build_body(flags, manifest_bytes, payload_bytes)
81
+ body + footer_for(body, hmac_key)
82
+ end
83
+
84
+ # Decodes a blob, verifying the footer and decrypting payload when
85
+ # encrypted. Raises TamperError on digest/HMAC mismatch and
86
+ # PassphraseError when an encrypted bundle is decoded without a
87
+ # passphrase or with the wrong one.
88
+ def self.decode(blob, passphrase: nil)
89
+ magic, version, flags = read_header!(blob)
90
+ raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
91
+
92
+ manifest_bytes, payload_bytes, footer = read_sections!(blob)
93
+ body = blob.byteslice(0, blob.bytesize - FOOTER_SIZE)
94
+
95
+ encrypted = flags.anybits?(FLAG_ENCRYPTED)
96
+ verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
97
+
98
+ manifest = JSON.parse(manifest_bytes, symbolize_names: true)
99
+ payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
100
+
101
+ { manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
102
+ end
103
+
104
+ # Reads the manifest without verifying the footer or decrypting the
105
+ # payload. Use for `state info` and similar read-only queries.
106
+ def self.peek_manifest(blob)
107
+ _, version, = read_header!(blob)
108
+ raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
109
+
110
+ manifest_bytes, = read_sections!(blob)
111
+ JSON.parse(manifest_bytes, symbolize_names: true)
112
+ end
113
+
114
+ def self.build_body(flags, manifest_bytes, payload_bytes)
115
+ header = MAGIC + [VERSION, flags, 0].pack("CCC")
116
+ header +
117
+ [manifest_bytes.bytesize].pack("N") + manifest_bytes +
118
+ [payload_bytes.bytesize].pack("N") + payload_bytes
119
+ end
120
+ private_class_method :build_body
121
+
122
+ def self.footer_for(body, hmac_key)
123
+ if hmac_key
124
+ OpenSSL::HMAC.digest("SHA256", hmac_key, body)
125
+ else
126
+ OpenSSL::Digest.digest("SHA256", body)
127
+ end
128
+ end
129
+ private_class_method :footer_for
130
+
131
+ def self.read_header!(blob)
132
+ raise BundleError, "blob too small for header" if blob.bytesize < HEADER_SIZE + (2 * LEN_SIZE) + FOOTER_SIZE
133
+
134
+ magic = blob.byteslice(0, MAGIC.bytesize)
135
+ raise BundleError, "bad magic — not a .bctl bundle" unless magic == MAGIC
136
+
137
+ version, flags, _reserved = blob.byteslice(MAGIC.bytesize, 3).unpack("CCC")
138
+ [magic, version, flags]
139
+ end
140
+ private_class_method :read_header!
141
+
142
+ def self.read_sections!(blob)
143
+ cursor = HEADER_SIZE
144
+ manifest_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
145
+ cursor += LEN_SIZE
146
+ manifest_bytes = blob.byteslice(cursor, manifest_len)
147
+ cursor += manifest_len
148
+ payload_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
149
+ cursor += LEN_SIZE
150
+ payload_bytes = blob.byteslice(cursor, payload_len)
151
+ cursor += payload_len
152
+ footer = blob.byteslice(cursor, FOOTER_SIZE)
153
+
154
+ unless manifest_bytes && payload_bytes && footer && footer.bytesize == FOOTER_SIZE
155
+ raise BundleError, "truncated bundle"
156
+ end
157
+
158
+ [manifest_bytes, payload_bytes, footer]
159
+ end
160
+ private_class_method :read_sections!
161
+
162
+ def self.verify_footer!(body, footer, encrypted:, passphrase:)
163
+ if encrypted
164
+ raise PassphraseError, "encrypted bundle requires a passphrase" unless passphrase
165
+
166
+ # We need the HMAC key, which depends on the salt embedded in the
167
+ # payload. Pull the salt from the payload bytes inside `body`.
168
+ salt = extract_salt!(body)
169
+ _, hmac_key = derive_keys(passphrase, salt)
170
+ expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
171
+ raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
172
+ else
173
+ expected = OpenSSL::Digest.digest("SHA256", body)
174
+ raise TamperError, "bundle digest mismatch — file is corrupted or modified" unless secure_eq?(footer,
175
+ expected)
176
+ end
177
+ end
178
+ private_class_method :verify_footer!
179
+
180
+ def self.extract_salt!(body)
181
+ cursor = HEADER_SIZE
182
+ manifest_len = body.byteslice(cursor, LEN_SIZE).unpack1("N")
183
+ cursor += LEN_SIZE + manifest_len + LEN_SIZE
184
+ body.byteslice(cursor, SALT_SIZE) or raise BundleError, "encrypted payload missing salt"
185
+ end
186
+ private_class_method :extract_salt!
187
+
188
+ def self.decode_payload(bytes, encrypted:, passphrase:)
189
+ if encrypted
190
+ salt = bytes.byteslice(0, SALT_SIZE)
191
+ ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
192
+ enc_key, = derive_keys(passphrase, salt)
193
+ plaintext = aes_gcm_decrypt(ciphertext, enc_key)
194
+ JSON.parse(plaintext)
195
+ else
196
+ JSON.parse(bytes)
197
+ end
198
+ rescue OpenSSL::Cipher::CipherError
199
+ raise PassphraseError, "wrong passphrase — payload could not be decrypted"
200
+ end
201
+ private_class_method :decode_payload
202
+
203
+ def self.derive_keys(passphrase, salt)
204
+ material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, 64, "SHA256")
205
+ [material.byteslice(0, 32), material.byteslice(32, 32)]
206
+ end
207
+ private_class_method :derive_keys
208
+
209
+ def self.aes_gcm_encrypt(plaintext, key)
210
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
211
+ cipher.encrypt
212
+ cipher.key = key
213
+ nonce = SecureRandom.bytes(NONCE_SIZE)
214
+ cipher.iv = nonce
215
+ ct = cipher.update(plaintext) + cipher.final
216
+ nonce + ct + cipher.auth_tag
217
+ end
218
+ private_class_method :aes_gcm_encrypt
219
+
220
+ def self.aes_gcm_decrypt(blob, key)
221
+ nonce = blob.byteslice(0, NONCE_SIZE)
222
+ tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
223
+ ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
224
+
225
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
226
+ cipher.decrypt
227
+ cipher.key = key
228
+ cipher.iv = nonce
229
+ cipher.auth_tag = tag
230
+ cipher.update(ciphertext) + cipher.final
231
+ end
232
+ private_class_method :aes_gcm_decrypt
233
+
234
+ def self.secure_eq?(actual, expected)
235
+ return false if actual.bytesize != expected.bytesize
236
+
237
+ OpenSSL.fixed_length_secure_compare(actual, expected)
238
+ end
239
+ private_class_method :secure_eq?
240
+ end
241
+ end
242
+ 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)