zpwrchrome-host
Rust port of browserpass-native — a drop-in replacement for the Go binary that the browserpass-extension browser extension talks to via Chrome / Firefox native messaging — plus three additive actions (OTP, whole-store search, segmented download manager) that browserpass-extension does not call.
Single static binary. Pure-Rust dependency tree (serde, serde_json, ureq with rustls). No aria2, no system OpenSSL, no Go toolchain at runtime.
Wire compatibility with upstream
zpwrchrome-host implements every action documented in browserpass-native's PROTOCOL.md v3.1.2:
| Action | Implementation | Test pin |
|---|---|---|
configure |
ported/request/configure.rs |
tests/ported_configure_helpers.rs + integration |
list |
ported/request/list.rs |
tests/ported_integration.rs (4 cases) |
tree |
ported/request/tree.rs |
tests/ported_integration.rs |
fetch |
ported/request/fetch.rs |
tests/ported_integration.rs |
save |
ported/request/save.rs |
tests/ported_integration.rs |
delete |
ported/request/delete.rs |
tests/ported_integration.rs (incl. parent cleanup) |
echo |
bin/zpwrchrome_host.rs |
tests/ported_integration.rs |
Error codes 10–32 from errors/errors.go pin to the same integers; exit code equals the error code (matches upstream errors.ExitWithCode); version is reported as 3.1.2 (packed int 3_001_002).
Strict port discipline
The src/ported/ tree is a 1:1 Rust mirror of the upstream Go source:
- Every file under
src/ported/mirrors a single upstream Go file by stem and relative subpath.errors/errors.go→src/ported/errors/errors.rs,request/configure.go→src/ported/request/configure.rs, etc. - Every Rust fn carries a
/// Port of <name>() from <go_file>:<line>doc comment. - Go's PascalCase / camelCase identifiers are preserved verbatim (
DetectGpgBinary,MakeConfigureResponse,parseRequestLength). File-level#![allow(non_snake_case)]makes this an explicit, audit-friendly decision rather than a style accident. - Every Go inline comment carries over to the Rust port with a
// go:NNline-number citation on the corresponding Rust statement. - Local variable names match Go's (
gpgPath,normalizedStorePath,parentDir,responseData). - No invented helpers — the two recursive store walkers inside
list.rsandtree.rsare inlined private fns at the call site because they replace external Go deps (mattn/go-zglob,mattn/go-zglob/fastwalk), not Go-source fns. - Drift is caught by
tests/ported_errors.rs(every error code pinned to its PROTOCOL.md value) +tests/ported_version.rs(version triple + packed int) +tests/ported_integration.rs(every action exercised end-to-end against the compiled binary).
This discipline is borrowed from the zshrs PORT.md rules and adapted to a Go→Rust port.
Extension actions
These are additive — browserpass-extension never sends them, so wire compatibility with upstream is preserved. They live under src/extensions/:
Pass extensions
| Action | Behavior | Wire shape |
|---|---|---|
otp |
Shells pass otp <entry> with PASSWORD_STORE_DIR set to the matching store. Returns the current TOTP code. |
request {action:"otp", storeId, file, settings} → ok {code:"123456"} |
search |
Host-side fuzzy + substring scoring across every configured store. Faster than client-side fzf for large stores. | request {action:"search", settings, echoResponse:"<query>"} → ok {matches:[{store, path}, …]} |
zcite connector
| Action | Behavior | Wire shape |
|---|---|---|
zcite.save |
Writes a CSL-JSON reference (extracted from the active web page by the extension's "Save to zcite") to zcite's inbox at <data_dir>/zcite/inbox/zpwrchrome-<nanos>.json. zcite's inbox.import drains it into the library. No link to the proprietary zcite engine — the handoff is a plain CSL-JSON file in a shared directory. |
request {action:"zcite.save", item:<CSL-JSON object|array>} → ok {status:"ok", path, bytes} |
Download manager
| Action | Behavior | Wire shape |
|---|---|---|
dl.add |
Spawns a detached worker (zpwrchrome-host --dl-worker <gid>) that performs a multi-segment download via ureq + Range requests. State lives at $XDG_CACHE_HOME/zpwrchrome/dl/gid_NNNNNN.json. name accepts a naming-mask template. |
{action:"dl.add", url, dir?, name?, mask?, segments?, cookies?, userAgent?} → ok {gid, dest} |
dl.list |
Reads every state file under the cache dir and returns the job array. Each row carries a host-computed dest_exists bool so the UI can hide reveal actions for deleted files. |
{action:"dl.list"} → ok {jobs:[JobView, …]} |
dl.pause |
Writes paused=true into the state file. Worker polls the flag between chunks. |
{action:"dl.pause", gid} → ok {gid, status:"paused"} |
dl.resume |
Clears paused/cancelled flags. Respawns the worker if the prior worker_pid is dead (SW-suspension self-heal). |
{action:"dl.resume", gid} → ok {gid, status:"resumed"} |
dl.cancel |
Writes cancelled=true. Worker removes partial dest file + state file on exit. |
{action:"dl.cancel", gid} → ok {gid, status:"cancelled"} |
dl.remove |
Cancels (if running) + deletes the state file. The dest file on disk is left alone so partial bytes survive. UI: 🗑 button on every row. | {action:"dl.remove", gid} → ok {gid, status:"removed"} |
dl.clear |
Bulk variant of dl.remove scoped by status. Optional deleteFromDisk also unlinks the destination files. |
{action:"dl.clear", scope:"done"|"failed"|"missing"|"all", deleteFromDisk:bool} → ok {cleared:[gid,…], deletedOnDisk:[path,…]} |
dl.openDir |
Spawn the platform file manager (open / xdg-open / explorer) for a directory. Empty dir opens the host's default download dir; a path opens that dir (refuses if the path doesn't exist — no fake folders). |
{action:"dl.openDir", dir?} → ok {opened:"<path>"} |
dl.openFile |
Same opener as above but for a single file (uses the platform's default-app association). Refuses missing files. | {action:"dl.openFile", dir} (dir carries the file path) → ok {opened:"<path>"} |
dl.writeFile |
Single-shot: base64-decode bytes and write to dir/name. Used by short payloads where the whole message fits inside Chrome's 64 MiB NM cap. |
{action:"dl.writeFile", dir?, name, base64} → ok {dest, bytes} |
dl.writeFileChunk |
Streaming append protocol for payloads larger than the NM cap (full-page screenshots, archive captures). First chunk creates upload-<sessionId>.part; later chunks append; final chunk renames to dir/name. |
{action:"dl.writeFileChunk", sessionId, chunkIndex, base64, final, dir?, name?} → ok {sessionId, chunkIndex, final, …, dest, bytes} (only on final) |
Worker process model
The download worker (zpwrchrome-host --dl-worker <gid>) is the detached child process that performs the actual byte transfer:
- Spawn isolation —
setsid()+close(fd)for everyfd >= 3beforeexec. Without this the worker inherits Chrome's NM stdout pipe; Chrome never sees EOF on the parent host and reportsNative host has exitedeven on a successful response. - Liveness check on resume —
worker_pidis written into the state file byrun_workerat start. Ondl.resumefor a paused job,worker_alive(prior_pid)(viakill(pid, 0)) decides whether to flip the paused flag (live worker will notice) or spawn a fresh worker (dead worker means SW was suspended and the previous worker process was reaped). - Path probing —
probe_headers(url, cookies, ua)tries HEAD first; on failure or non-OK (common on pre-signed S3 URLs that bind the signature to the GET method — GitHub releases, Cloudflare R2, etc.) falls back toGETwithRange: bytes=0-0. A 206 response gives Content-Range + confirms Range support; a 200 means Range is ignored and we record the full size. - Segmented mode (download accelerator) — this is the acceleration path: total ≥
MIN_SEGMENT_BYTES+accept_ranges+segments > 1→ N threads each fetch one byte range over its own connection, so throughput scales past what a single stream gives (IDM / aria2 / axel model). A sharedAtomicU64 done_totalaccumulates bytes across threads; aprogress_pumpthread flushes it to disk everySTATE_FLUSH_INTERVALso the UI sees live progress. When the server lacksAccept-Rangesor the file is below the minimum, it falls back to a single-stream download. - Filename derivation — at HEAD time, if the URL-derived name looks like query-string garbage (
looks_like_query_garbageheuristic) the worker usesdownload-{ts}.bin. The HEAD response'sContent-Dispositionheader is parsed (RFC 5987filename*=UTF-8'', quotedfilename="", barefilename=) and renames before the file is opened. - Cookies + User-Agent are forwarded from
chrome.cookies.getAll()so logged-in downloads work the same way the browser would.
Naming mask tokens
dl.add accepts a mask template. Tokens are substituted in apply_naming_mask before the filename is sanitized:
| Token | Substitution |
|---|---|
*name* |
Basename without extension |
*ext* |
Extension without dot (empty if none) |
*host* |
URL hostname |
*url* |
URL path (slashes kept) |
*flat* |
URL path with / → _ |
*subdirs* |
URL path's parent dirs (no trailing slash) |
*date* |
YYYY-MM-DD (UTC) |
*time* |
HHMMSS (UTC) |
*size* |
? placeholder (size unknown at name time) |
Unknown tokens are left literal so the user can spot typos. Empty mask = filename verbatim.
Install
That installs zpwrchrome-host into $CARGO_HOME/bin. Then register the NM manifest for the calling extension's ID:
# 1. Find your extension's ID at chrome://extensions (Developer mode)
# 2. Register the host for that ID:
The installer writes com.menketechnologies.zpwrchrome.json into every detected Chromium-family browser config dir on macOS / Linux (Chrome / Chromium / Brave / Edge). It also registers into zwire's own profile — the canonical state dir (~/Library/Application Support/com.menketechnologies.zwire/profile/NativeMessagingHosts on macOS, ${XDG_CONFIG_HOME:-~/.config}/zwire/... elsewhere, matching scripts/state-dir.sh) is created even before zwire's first launch, so the host is reachable on the very first run; $ZWIRE_STATE overrides it. allowed_origins is set to chrome-extension://<ext-id>/ so only the calling extension can spawn the host. Reload the extension after running it.
Use with upstream browserpass-extension
This binary also implements every action documented in browserpass-native's PROTOCOL.md v3.1.2 (see the table above), so it works as a drop-in replacement for the Go browserpass-native binary. Register it under the upstream NM name (com.github.browserpass.native) by passing the public browserpass-extension IDs to --install:
# Chrome Web Store + AMO + Edge Add-ons IDs for browserpass-extension:
If the upstream browserpass-native package is already installed (apt / brew / nix etc.), uninstall it first — both binaries register under the same NM name and the last one to write the manifest wins.
Upgrade
cargo install zpwrchrome-host --force — the NM manifest already points at $CARGO_HOME/bin/zpwrchrome-host so no re-install is needed.
Architecture
zpwrchrome-host/src/
├── lib.rs # pub mod ported + extensions + frame + diag
├── frame.rs # NM length-prefixed JSON framing
│ # Chrome cap: 64 MiB outbound (ext → host),
│ # 1 MiB inbound (host → ext).
├── diag.rs # ~/.cache/zpwrchrome/host.log — START, RECV,
│ # ACTION, DISPATCH, SEND, EXIT, panic hook.
├── ported/ # 1:1 Rust port of browserpass-native
│ ├── errors/errors.rs
│ ├── helpers/helpers.rs
│ ├── request/
│ │ ├── common.rs # normalizePasswordStorePath (env expansion inlined)
│ │ ├── configure.rs # configure + getDefaultPasswordStorePath + readDefaultSettings
│ │ ├── delete.rs # deleteFile + parent-dir cleanup loop
│ │ ├── fetch.rs # fetchDecryptedContents + gpg dispatch chain
│ │ ├── list.rs # listFiles (inline std::fs walker replaces zglob)
│ │ ├── process.rs # Process + parseRequestLength + parseRequest + request types
│ │ ├── save.rs # saveEncryptedContents + .gpg-id recipient walk
│ │ └── tree.rs # listDirectories (inline std::fs walker replaces fastwalk)
│ ├── response/response.rs # ok/error envelopes, send_ok/send_err/send_raw
│ └── version/version.rs # 3.1.2 / 3_001_002
├── extensions/ # additive — not in upstream
│ ├── dl.rs # file-state segmented downloader, worker process,
│ │ # dl.add/list/pause/resume/cancel/remove/clear,
│ │ # dl.openDir/openFile, dl.writeFile/writeFileChunk,
│ │ # apply_naming_mask, probe_headers,
│ │ # parse_content_disposition_filename, expand_home,
│ │ # looks_like_query_garbage, percent_decode.
│ ├── otp.rs # shells pass otp
│ └── search.rs # host-side fuzzy + substring scoring
└── bin/
└── zpwrchrome_host.rs # port of main.go + extension dispatch hook
# + --install <ext-id> NM manifest writer
# + --dl-worker <gid> detached worker entry
Testing
121 tests, 0 failures across:
- Pure protocol pins (
tests/ported_version.rs,tests/ported_errors.rs) - Pure helpers (
tests/ported_helpers.rs,tests/ported_common.rs,tests/ported_configure_helpers.rs) - End-to-end with spawned binary (
tests/ported_integration.rs) — echo round-trip, every error code path, configure/list/tree/delete against tempdir stores - Frame round-trip (
tests/frame_roundtrip.rs) - Live pass store (
tests/live_password_store.rs) — gated on~/.password-store/.gpg-idpresence; verifies byte-equalpass showround-trip - Extensions:
extensions_otp.rs,extensions_search.rs,extensions_run_command.rs,extensions_dl_state.rs,extensions_dl_integration.rs(78 cases: 2 MiB segmented download against a local HTTP server with Range support, dl.clear scopes, dl.remove cancel-and-delete, dl.writeFile + writeFileChunk streaming protocol, naming-mask token substitution, probe_headers HEAD-then-Range-GET fallback, spawn_worker setsid + close-fd, dl.resume worker-pid liveness check, expand_home tilde resolution).
All green on push/PR via GitHub Actions on ubuntu-latest — the repo .github/workflows/ci.yml runs cargo test --locked for this crate on the Node 22 matrix leg, alongside the extension's npm test.
License
MIT. See LICENSE.
Credits
- Upstream Go reference: browserpass/browserpass-native by Maxim Baz + contributors
- Protocol spec: browserpass-native/PROTOCOL.md
- This Rust port + extensions: MenkeTechnologies