Skip to main content

rtb_cli/
browser.rs

1//! URL opening with a scheme allowlist and input hygiene.
2//!
3//! All URL-opening in RTB and in scaffolded tools must route through
4//! [`open_url`]; calling `open::that`, `webbrowser::open`, or
5//! `xdg-open`/`rundll32` directly is forbidden (CLAUDE.md §URL Opening).
6//! The helper enforces a scheme allowlist, a length bound, and
7//! control-character rejection *before* handing the URL to the OS handler,
8//! and never interpolates it into a shell.
9//!
10//! Callers constructing `mailto:` URLs from user-influenced data must
11//! `urlencoding::encode` every parameter value first — this helper rejects
12//! raw control characters but does not itself encode query values.
13//!
14//! See `docs/development/specs/2026-06-26-rtb-cli-browser-open-url.md`.
15
16use std::io;
17
18/// Maximum accepted URL length, in bytes.
19pub const MAX_URL_LEN: usize = 8192; // 8 KiB
20
21/// Non-configurable scheme allowlist.
22pub const ALLOWED_SCHEMES: [&str; 3] = ["https", "http", "mailto"];
23
24/// Error returned when a URL is rejected by the guardrails or the OS
25/// handler fails to launch.
26#[derive(Debug, thiserror::Error, miette::Diagnostic)]
27pub enum BrowserError {
28    /// The URL exceeds [`MAX_URL_LEN`].
29    #[error("URL is {len} bytes; limit is {} bytes", MAX_URL_LEN)]
30    #[diagnostic(code(rtb_cli::browser::too_long))]
31    TooLong {
32        /// Actual URL length, in bytes.
33        len: usize,
34    },
35
36    /// The URL contains control characters (CR/LF/NUL etc.).
37    #[error("URL contains control characters")]
38    #[diagnostic(code(rtb_cli::browser::control_chars))]
39    ControlChars,
40
41    /// The URL could not be parsed.
42    #[error("URL is malformed")]
43    #[diagnostic(code(rtb_cli::browser::malformed))]
44    Malformed(#[source] url::ParseError),
45
46    /// The URL's scheme is not in [`ALLOWED_SCHEMES`].
47    #[error("scheme {scheme:?} is not permitted (allowed: https, http, mailto)")]
48    #[diagnostic(code(rtb_cli::browser::scheme_denied))]
49    SchemeDenied {
50        /// The rejected scheme.
51        scheme: String,
52    },
53
54    /// The OS browser/handler could not be invoked.
55    #[error("failed to invoke the OS browser handler")]
56    #[diagnostic(code(rtb_cli::browser::launch))]
57    Launch(#[source] io::Error),
58}
59
60/// Validate `url` against the allowlist + hygiene rules, then open it with
61/// the OS handler.
62///
63/// # Errors
64///
65/// Returns a [`BrowserError`] if the URL is over-length, contains control
66/// characters, is malformed, has a non-allowlisted scheme, or the OS
67/// handler fails to launch.
68pub fn open_url(url: &str) -> Result<(), BrowserError> {
69    open_url_with(url, os_open)
70}
71
72/// The single sanctioned OS-handler invocation.
73fn os_open(url: &str) -> io::Result<()> {
74    open::that(url)
75}
76
77/// Validation core, parameterised over the opener so tests can assert
78/// behaviour without spawning a real browser.
79fn open_url_with<F>(url: &str, opener: F) -> Result<(), BrowserError>
80where
81    F: FnOnce(&str) -> io::Result<()>,
82{
83    if url.len() > MAX_URL_LEN {
84        return Err(BrowserError::TooLong { len: url.len() });
85    }
86    if url.chars().any(char::is_control) {
87        return Err(BrowserError::ControlChars);
88    }
89    let parsed = url::Url::parse(url).map_err(BrowserError::Malformed)?;
90    let scheme = parsed.scheme();
91    if !ALLOWED_SCHEMES.iter().any(|allowed| allowed.eq_ignore_ascii_case(scheme)) {
92        return Err(BrowserError::SchemeDenied { scheme: scheme.to_owned() });
93    }
94    opener(url).map_err(BrowserError::Launch)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::{open_url_with, BrowserError, ALLOWED_SCHEMES, MAX_URL_LEN};
100    use std::cell::{Cell, RefCell};
101    use std::io;
102
103    #[test]
104    fn allowed_schemes_reach_the_opener() {
105        for url in
106            ["https://example.com/", "http://localhost:8080/path", "mailto:someone@example.com"]
107        {
108            let seen = RefCell::new(None);
109            let result = open_url_with(url, |u| {
110                *seen.borrow_mut() = Some(u.to_owned());
111                Ok(())
112            });
113            assert!(result.is_ok(), "url {url} should open");
114            assert_eq!(seen.into_inner().as_deref(), Some(url));
115        }
116    }
117
118    #[test]
119    fn denied_schemes_never_reach_the_opener() {
120        for url in [
121            "file:///etc/passwd",
122            "javascript:alert(1)",
123            "data:text/html,<script>1</script>",
124            "ftp://host/file",
125        ] {
126            let called = Cell::new(false);
127            let result = open_url_with(url, |_| {
128                called.set(true);
129                Ok(())
130            });
131            assert!(
132                matches!(result, Err(BrowserError::SchemeDenied { .. })),
133                "url {url} should be scheme-denied"
134            );
135            assert!(!called.get(), "opener must not run for {url}");
136        }
137    }
138
139    #[test]
140    fn rejects_control_characters() {
141        let called = Cell::new(false);
142        let result = open_url_with("https://example.com/\npath", |_| {
143            called.set(true);
144            Ok(())
145        });
146        assert!(matches!(result, Err(BrowserError::ControlChars)));
147        assert!(!called.get());
148    }
149
150    #[test]
151    fn rejects_overlong_url() {
152        let url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
153        let called = Cell::new(false);
154        let result = open_url_with(&url, |_| {
155            called.set(true);
156            Ok(())
157        });
158        assert!(matches!(result, Err(BrowserError::TooLong { .. })));
159        assert!(!called.get());
160    }
161
162    #[test]
163    fn rejects_malformed_url() {
164        let result = open_url_with("not-a-valid-url", |_| Ok(()));
165        assert!(matches!(result, Err(BrowserError::Malformed(_))));
166    }
167
168    #[test]
169    fn surfaces_opener_failure_as_launch() {
170        let result = open_url_with("https://example.com/", |_| {
171            Err(io::Error::new(io::ErrorKind::NotFound, "no handler"))
172        });
173        assert!(matches!(result, Err(BrowserError::Launch(_))));
174    }
175
176    #[test]
177    fn constants_match_documented_values() {
178        assert_eq!(MAX_URL_LEN, 8192);
179        assert_eq!(ALLOWED_SCHEMES, ["https", "http", "mailto"]);
180    }
181}