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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- 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)
|