browserctl 0.10.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 +38 -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 +30 -0
  14. data/lib/browserctl/flow.rb +22 -1
  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 +31 -2
@@ -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)
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "constants"
7
+ require_relative "errors"
8
+ require_relative "version"
9
+ require_relative "state/bundle"
10
+ require_relative "state/transport"
11
+ require_relative "state/transports/file"
12
+ require_relative "state/transports/s3"
13
+ require_relative "state/transports/one_password"
14
+
15
+ module Browserctl
16
+ # Top-level state store: a single .bctl bundle per name under
17
+ # ~/.browserctl/state/<name>.bctl. Wraps the Bundle codec with on-disk
18
+ # naming, validation, and a small inventory API used by `state list/info`.
19
+ #
20
+ # Data shape inside a bundle:
21
+ #
22
+ # manifest = {
23
+ # name: String,
24
+ # version: 1, # bundle schema version
25
+ # producer: "browserctl/<gem-ver>",
26
+ # created_at: ISO-8601,
27
+ # origins: [String, ...],
28
+ # flow: String | nil, # bound flow name, for `state rotate`
29
+ # flow_version: String | nil,
30
+ # expires_at: ISO-8601 | nil, # earliest cookie expiry
31
+ # encrypted: Boolean
32
+ # }
33
+ #
34
+ # payload = {
35
+ # cookies: [Hash, ...],
36
+ # local_storage: { origin => { key => value } },
37
+ # session_storage: { origin => { key => value } }
38
+ # }
39
+ module State
40
+ BASE_DIR = File.join(BROWSERCTL_DIR, "state")
41
+ SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
42
+ EXTENSION = ".bctl"
43
+ MANIFEST_VERSION = 1
44
+
45
+ def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
46
+ def self.exist?(name) = File.exist?(path(name))
47
+
48
+ def self.validate_name!(name)
49
+ return if SAFE_NAME.match?(name.to_s)
50
+
51
+ raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
52
+ end
53
+
54
+ # Persist a bundle. `payload` is { cookies:, local_storage:, session_storage: }.
55
+ # `manifest_extras` may carry origins (override), flow, flow_version.
56
+ def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
57
+ validate_name!(name)
58
+ FileUtils.mkdir_p(BASE_DIR)
59
+
60
+ manifest = build_manifest(
61
+ name: name,
62
+ origins: origins || derive_origins(payload),
63
+ flow: flow,
64
+ flow_version: flow_version,
65
+ cookies: payload[:cookies] || payload["cookies"] || [],
66
+ encrypted: !passphrase.nil?
67
+ )
68
+
69
+ blob = Bundle.encode(manifest: manifest, payload: payload, passphrase: passphrase)
70
+ File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
71
+ manifest
72
+ end
73
+
74
+ # Load and decode a bundle. Returns { manifest:, payload:, encrypted: }.
75
+ def self.load(name, passphrase: nil)
76
+ validate_name!(name)
77
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
78
+
79
+ Bundle.decode(File.binread(path(name)), passphrase: passphrase)
80
+ end
81
+
82
+ def self.delete(name)
83
+ validate_name!(name)
84
+ FileUtils.rm_f(path(name))
85
+ end
86
+
87
+ # Copies the on-disk .bctl bundle to a transport-addressable destination
88
+ # (file path, s3://bucket/key, op://Vault/Item, or any registered scheme).
89
+ # Bundle bytes are written verbatim — no re-encoding — so the receiving
90
+ # side can verify the manifest/payload exactly as produced.
91
+ def self.export(name, destination)
92
+ validate_name!(name)
93
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
94
+
95
+ transport, parsed = Transport.for(destination)
96
+ blob = ::File.binread(path(name))
97
+ transport.write(parsed, blob)
98
+ { name: name, destination: destination, bytes: blob.bytesize }
99
+ end
100
+
101
+ # Pulls a bundle from a transport-addressable source and stores it as a
102
+ # local state. Validates the magic header before persisting so we never
103
+ # leave a corrupt bundle in the state directory. `name` defaults to the
104
+ # source's basename without `.bctl`.
105
+ def self.import(source, name: nil)
106
+ transport, parsed = Transport.for(source)
107
+ blob = transport.read(parsed)
108
+ raise Bundle::BundleError, "imported blob is not a .bctl bundle" unless blob.start_with?(Bundle::MAGIC)
109
+
110
+ manifest = Bundle.peek_manifest(blob)
111
+ target_name = name || derive_name(source) || manifest[:name]
112
+ validate_name!(target_name)
113
+
114
+ FileUtils.mkdir_p(BASE_DIR)
115
+ ::File.open(path(target_name), "wb", 0o600) { |f| f.write(blob) }
116
+ { name: target_name, source: source, bytes: blob.bytesize, encrypted: manifest[:encrypted] }
117
+ end
118
+
119
+ def self.derive_name(uri)
120
+ base = ::File.basename(uri.to_s.split("?").first.to_s, EXTENSION)
121
+ return nil if base.empty?
122
+
123
+ base
124
+ end
125
+ private_class_method :derive_name
126
+
127
+ # Read manifests for all stored bundles. Errors on a single file are
128
+ # surfaced via { error: "...", path: "..." } rather than aborting the list.
129
+ def self.all
130
+ return [] unless Dir.exist?(BASE_DIR)
131
+
132
+ Dir[File.join(BASE_DIR, "*#{EXTENSION}")].map do |file|
133
+ info_for(file)
134
+ end
135
+ end
136
+
137
+ # Inspect a single bundle without decrypting the payload.
138
+ def self.info(name)
139
+ validate_name!(name)
140
+ raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
141
+
142
+ info_for(path(name))
143
+ end
144
+
145
+ def self.info_for(file)
146
+ blob = File.binread(file)
147
+ manifest = Bundle.peek_manifest(blob)
148
+ manifest.merge(
149
+ path: file,
150
+ size: blob.bytesize
151
+ )
152
+ rescue Bundle::BundleError => e
153
+ { name: File.basename(file, EXTENSION), path: file, error: e.message }
154
+ end
155
+ private_class_method :info_for
156
+
157
+ def self.build_manifest(name:, origins:, flow:, flow_version:, cookies:, encrypted:) # rubocop:disable Metrics/ParameterLists
158
+ {
159
+ name: name,
160
+ version: MANIFEST_VERSION,
161
+ producer: "browserctl/#{Browserctl::VERSION}",
162
+ created_at: Time.now.utc.iso8601,
163
+ origins: Array(origins).compact.uniq,
164
+ flow: flow,
165
+ flow_version: flow_version,
166
+ expires_at: earliest_expiry(cookies),
167
+ encrypted: encrypted
168
+ }
169
+ end
170
+ private_class_method :build_manifest
171
+
172
+ # Origins captured from the payload itself when not overridden. Pulls from
173
+ # cookie domains and storage keys to cover both navigation-tracked and
174
+ # cookie-only auth.
175
+ def self.derive_origins(payload)
176
+ cookies = fetch_either(payload, :cookies, "cookies", default: [])
177
+ ls = fetch_either(payload, :local_storage, "local_storage", default: {})
178
+ ss = fetch_either(payload, :session_storage, "session_storage", default: {})
179
+
180
+ (origins_from_cookies(cookies) + ls.keys.map(&:to_s) + ss.keys.map(&:to_s)).uniq
181
+ end
182
+ private_class_method :derive_origins
183
+
184
+ def self.origins_from_cookies(cookies)
185
+ cookies.filter_map do |c|
186
+ domain = (c[:domain] || c["domain"]).to_s
187
+ next if domain.empty?
188
+
189
+ domain.start_with?(".") ? "https://#{domain[1..]}" : "https://#{domain}"
190
+ end
191
+ end
192
+ private_class_method :origins_from_cookies
193
+
194
+ def self.fetch_either(hash, sym_key, str_key, default:)
195
+ hash[sym_key] || hash[str_key] || default
196
+ end
197
+ private_class_method :fetch_either
198
+
199
+ def self.earliest_expiry(cookies)
200
+ times = cookies.filter_map do |c|
201
+ v = c[:expires] || c["expires"] || c[:expiresAt] || c["expiresAt"]
202
+ v&.to_f&.positive? ? Time.at(v.to_f).utc.iso8601 : nil
203
+ end
204
+ times.min
205
+ end
206
+ private_class_method :earliest_expiry
207
+ end
208
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end