Module: Browserctl::State

Defined in:
lib/browserctl/state.rb,
lib/browserctl/state/bundle.rb,
lib/browserctl/state/transport.rb,
lib/browserctl/state/transports/s3.rb,
lib/browserctl/state/transports/file.rb,
lib/browserctl/state/transports/one_password.rb

Overview

Top-level state store: a single .bctl bundle per name under ~/.browserctl/state/<name>.bctl. Wraps the Bundle codec with on-disk naming, validation, and a small inventory API used by ‘state list/info`.

Data shape inside a bundle:

manifest = {
  name:        String,
  version:     1,                       # bundle schema version
  producer:    "browserctl/<gem-ver>",
  created_at:  ISO-8601,
  origins:     [String, ...],
  flow:        String | nil,            # bound flow name, for `state rotate`
  flow_version: String | nil,
  expires_at:  ISO-8601 | nil,          # earliest cookie expiry
  encrypted:   Boolean
}

payload = {
  cookies:         [Hash, ...],
  local_storage:   { origin => { key => value } },
  session_storage: { origin => { key => value } }
}

Defined Under Namespace

Modules: Transport, Transports Classes: Bundle, Payload

Constant Summary collapse

BASE_DIR =
File.join(BROWSERCTL_DIR, "state")
SAFE_NAME =
/\A[a-zA-Z0-9_-]{1,64}\z/
EXTENSION =
".bctl"
MANIFEST_VERSION =
1

Class Method Summary collapse

Class Method Details

.allObject

Read manifests for all stored bundles. Errors on a single file are surfaced via { error: “…”, path: “…” } rather than aborting the list.



166
167
168
169
170
171
172
# File 'lib/browserctl/state.rb', line 166

def self.all
  return [] unless Dir.exist?(BASE_DIR)

  Dir[File.join(BASE_DIR, "*#{EXTENSION}")].map do |file|
    info_for(file)
  end
end

.delete(name) ⇒ Object



119
120
121
122
# File 'lib/browserctl/state.rb', line 119

def self.delete(name)
  validate_name!(name)
  FileUtils.rm_f(path(name))
end

.exist?(name) ⇒ Boolean

Returns:

  • (Boolean)


81
# File 'lib/browserctl/state.rb', line 81

def self.exist?(name) = File.exist?(path(name))

.export(name, destination) ⇒ Object

Copies the on-disk .bctl bundle to a transport-addressable destination (file path, s3://bucket/key, op://Vault/Item, or any registered scheme). Bundle bytes are written verbatim — no re-encoding — so the receiving side can verify the manifest/payload exactly as produced.

Raises:



128
129
130
131
132
133
134
135
136
# File 'lib/browserctl/state.rb', line 128

def self.export(name, destination)
  validate_name!(name)
  raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)

  transport, parsed = Transport.for(destination)
  blob = ::File.binread(path(name))
  transport.write(parsed, blob)
  { name: name, destination: destination, bytes: blob.bytesize }
end

.import(source, name: nil) ⇒ Object

Pulls a bundle from a transport-addressable source and stores it as a local state. Validates the magic header before persisting so we never leave a corrupt bundle in the state directory. ‘name` defaults to the source’s basename without ‘.bctl`.



142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/browserctl/state.rb', line 142

def self.import(source, name: nil)
  transport, parsed = Transport.for(source)
  blob = transport.read(parsed)
  raise Bundle::BundleError, "imported blob is not a .bctl bundle" unless blob.start_with?(Bundle::MAGIC)

  manifest = Bundle.peek_manifest(blob)
  target_name = name || derive_name(source) || manifest[:name]
  validate_name!(target_name)

  FileUtils.mkdir_p(BASE_DIR)
  ::File.open(path(target_name), "wb", 0o600) { |f| f.write(blob) }
  { name: target_name, source: source, bytes: blob.bytesize, encrypted: manifest[:encrypted] }
end

.info(name) ⇒ Object

Inspect a single bundle without decrypting the payload.

Raises:



175
176
177
178
179
180
# File 'lib/browserctl/state.rb', line 175

def self.info(name)
  validate_name!(name)
  raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)

  info_for(path(name))
end

.load(name, passphrase: nil) ⇒ Object

Load and decode a bundle. Returns { manifest:, payload:, encrypted: }.

Raises:



112
113
114
115
116
117
# File 'lib/browserctl/state.rb', line 112

def self.load(name, passphrase: nil)
  validate_name!(name)
  raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)

  Bundle.decode(File.binread(path(name)), passphrase: passphrase)
end

.path(name) ⇒ Object



80
# File 'lib/browserctl/state.rb', line 80

def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")

.save(name, payload) ⇒ Object

Persist a bundle. ‘payload` is a `State::Payload` value object carrying cookies, local/session storage, and the manifest extras (origins, flow, flow_version, passphrase).



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/browserctl/state.rb', line 92

def self.save(name, payload)
  validate_name!(name)
  FileUtils.mkdir_p(BASE_DIR)

  bundle_payload = payload.to_bundle_payload
  manifest = build_manifest(
    name: name,
    origins: payload.origins || derive_origins(bundle_payload),
    flow: payload.flow,
    flow_version: payload.flow_version,
    cookies: payload.cookies || [],
    encrypted: !payload.passphrase.nil?
  )

  blob = Bundle.encode(manifest: manifest, payload: bundle_payload, passphrase: payload.passphrase)
  File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
  manifest
end

.validate_name!(name) ⇒ Object

Raises:

  • (ArgumentError)


83
84
85
86
87
# File 'lib/browserctl/state.rb', line 83

def self.validate_name!(name)
  return if SAFE_NAME.match?(name.to_s)

  raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
end