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

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.



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

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



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

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

.exist?(name) ⇒ Boolean

Returns:

  • (Boolean)


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

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:



91
92
93
94
95
96
97
98
99
# File 'lib/browserctl/state.rb', line 91

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`.



105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/browserctl/state.rb', line 105

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:



138
139
140
141
142
143
# File 'lib/browserctl/state.rb', line 138

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:



75
76
77
78
79
80
# File 'lib/browserctl/state.rb', line 75

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



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

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

.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) ⇒ Object

Persist a bundle. ‘payload` is { cookies:, local_storage:, session_storage: }. `manifest_extras` may carry origins (override), flow, flow_version.



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/browserctl/state.rb', line 56

def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
  validate_name!(name)
  FileUtils.mkdir_p(BASE_DIR)

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

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

.validate_name!(name) ⇒ Object

Raises:

  • (ArgumentError)


48
49
50
51
52
# File 'lib/browserctl/state.rb', line 48

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