rtb-cli browser::open_url¶
Status: APPROVED — closes an RTB documented-but-unimplemented security
contract surfaced by the GTB v0.22.0 parity audit (Phase 2, §B). The API
and its guardrails are already mandated by CLAUDE.md (§URL Opening) and
referenced in five places by the approved 2026-04-23-rtb-docs-v0.1.md
spec (lines 227, 300, …). This spec records the contract + tests for TDD.
No design decisions were open; §6 folds them from the standard.
Honest scoping note: there is no current caller — the rtb-docs
docs serve --open flag and external-link routing that will consume this
helper are themselves not yet implemented (run_serve has no --open
handling). This is the foundational primitive that those features
require; building it first means they inherit the allowlist for free and
no code ever reaches open::that/xdg-open directly.
1. Motivation¶
CLAUDE.md §URL Opening mandates that all URL-opening route through
rtb_cli::browser::open_url, which "enforces a scheme allowlist
(https, http, mailto), a URL-length bound, and control-character
rejection before invoking the OS handler," and forbids calling
open::that, webbrowser::open, or xdg-open directly. The helper does
not exist, so the standard is currently unenforceable. GTB's analogue is
pkg/browser (≤8 KiB, control-char reject, non-configurable
{https,http,mailto} allowlist, no shell interpolation).
2. Surface¶
New module crates/rtb-cli/src/browser.rs, pub mod browser; in
rtb-cli's lib.rs.
/// Maximum URL length accepted (matches gtb pkg/browser).
pub const MAX_URL_LEN: usize = 8192; // 8 KiB
/// Non-configurable scheme allowlist.
pub const ALLOWED_SCHEMES: [&str; 3] = ["https", "http", "mailto"];
/// Validate `url` against the allowlist + hygiene rules, then hand it to
/// the OS handler. Never interpolates into a shell.
pub fn open_url(url: &str) -> Result<(), BrowserError>;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum BrowserError {
#[error("URL is {len} bytes; limit is {MAX_URL_LEN} bytes")]
#[diagnostic(code(rtb_cli::browser::too_long))]
TooLong { len: usize },
#[error("URL contains control characters")]
#[diagnostic(code(rtb_cli::browser::control_chars))]
ControlChars,
#[error("URL is malformed")]
#[diagnostic(code(rtb_cli::browser::malformed))]
Malformed(#[source] url::ParseError),
#[error("scheme {scheme:?} is not permitted (allowed: https, http, mailto)")]
#[diagnostic(code(rtb_cli::browser::scheme_denied))]
SchemeDenied { scheme: String },
#[error("failed to invoke the OS browser handler")]
#[diagnostic(code(rtb_cli::browser::launch))]
Launch(#[source] std::io::Error),
}
3. Mechanism¶
- Length — reject
url.len() > MAX_URL_LEN→TooLong(cheap first). - Control chars — reject any
char::is_control()(incl. CR/LF/NUL, the argument-injection vectors) →ControlChars. - Parse + scheme — parse with the
urlcrate; reject a scheme not inALLOWED_SCHEMES(case-insensitive) →SchemeDenied. This rejectsfile:,javascript:,data:,ftp:, etc. - Open — pass the validated string to the OS handler via the
opencrate (open::that), the single sanctioned wrapper. No shell, no string interpolation. Map failure →Launch.
mailto: callers constructing the URL from user-influenced data must
urlencoding::encode every parameter value before calling (documented
in CLAUDE.md §URL Opening; this helper rejects raw control chars but does
not itself encode query values).
4. Test seam¶
open::that actually spawns a browser — unacceptable in tests. Provide an
internal open_url_with(url, opener: impl FnOnce(&str) -> io::Result<()>)
that open_url calls with the real open::that; tests pass a recording
no-op closure. Public surface stays open_url(url); the seam is
pub(crate)/#[doc(hidden)]. (DI via closure — no global state, per the
testing standard.)
5. Dependencies¶
Add to crates/rtb-cli/Cargo.toml: open (OS handler), url (parse),
urlencoding (already used per CLAUDE.md for mailto). All exist in the
ecosystem; pin via [workspace.dependencies].
6. Resolutions (folded — no open questions)¶
- [R-1] Host crate →
rtb-cli(CLAUDE.md mandatesrtb_cli::browser). - [R-2] Backend → the
opencrate (cross-platform, maintained; the one sanctioned wrapper). Rejected:webbrowser(heavier), hand-rolled per-OSCommand(more surface). - [R-3] Allowlist
{https,http,mailto}, non-configurable; length 8 KiB — the CLAUDE.md / gtb values. - [R-4] Test seam via injected opener closure — no real spawn in tests.
7. Testing (TDD, ≥90%)¶
- Each allowed scheme (
https://…,http://…,mailto:[email protected]) reaches the (mock) opener exactly once with the exact string. - Denied schemes (
file:///etc/passwd,javascript:alert(1),data:…,ftp://…) →SchemeDenied, opener not called. - A URL with an embedded
\n/\r/NUL →ControlChars, opener not called. - A URL of
MAX_URL_LEN + 1→TooLong. - A malformed URL →
Malformed. - An opener returning
io::Error→Launch. - Allowlist/length constants match the documented values.
8. Out of scope¶
mailtoquery-parameter encoding (caller's responsibility, documented).- Configurable allowlists or a
--no-open/headless override (callers gate whether to call at all; e.g.docs serve --openis opt-in). - Wiring the first consumer (
docs serve --open) — that lands with the rtb-docs feature, which this primitive unblocks.