use std::io;
pub const MAX_URL_LEN: usize = 8192;
pub const ALLOWED_SCHEMES: [&str; 3] = ["https", "http", "mailto"];
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum BrowserError {
#[error("URL is {len} bytes; limit is {} bytes", MAX_URL_LEN)]
#[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] io::Error),
}
pub fn open_url(url: &str) -> Result<(), BrowserError> {
open_url_with(url, os_open)
}
fn os_open(url: &str) -> io::Result<()> {
open::that(url)
}
fn open_url_with<F>(url: &str, opener: F) -> Result<(), BrowserError>
where
F: FnOnce(&str) -> io::Result<()>,
{
if url.len() > MAX_URL_LEN {
return Err(BrowserError::TooLong { len: url.len() });
}
if url.chars().any(char::is_control) {
return Err(BrowserError::ControlChars);
}
let parsed = url::Url::parse(url).map_err(BrowserError::Malformed)?;
let scheme = parsed.scheme();
if !ALLOWED_SCHEMES.iter().any(|allowed| allowed.eq_ignore_ascii_case(scheme)) {
return Err(BrowserError::SchemeDenied { scheme: scheme.to_owned() });
}
opener(url).map_err(BrowserError::Launch)
}
#[cfg(test)]
mod tests {
use super::{open_url_with, BrowserError, ALLOWED_SCHEMES, MAX_URL_LEN};
use std::cell::{Cell, RefCell};
use std::io;
#[test]
fn allowed_schemes_reach_the_opener() {
for url in
["https://example.com/", "http://localhost:8080/path", "mailto:someone@example.com"]
{
let seen = RefCell::new(None);
let result = open_url_with(url, |u| {
*seen.borrow_mut() = Some(u.to_owned());
Ok(())
});
assert!(result.is_ok(), "url {url} should open");
assert_eq!(seen.into_inner().as_deref(), Some(url));
}
}
#[test]
fn denied_schemes_never_reach_the_opener() {
for url in [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,<script>1</script>",
"ftp://host/file",
] {
let called = Cell::new(false);
let result = open_url_with(url, |_| {
called.set(true);
Ok(())
});
assert!(
matches!(result, Err(BrowserError::SchemeDenied { .. })),
"url {url} should be scheme-denied"
);
assert!(!called.get(), "opener must not run for {url}");
}
}
#[test]
fn rejects_control_characters() {
let called = Cell::new(false);
let result = open_url_with("https://example.com/\npath", |_| {
called.set(true);
Ok(())
});
assert!(matches!(result, Err(BrowserError::ControlChars)));
assert!(!called.get());
}
#[test]
fn rejects_overlong_url() {
let url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
let called = Cell::new(false);
let result = open_url_with(&url, |_| {
called.set(true);
Ok(())
});
assert!(matches!(result, Err(BrowserError::TooLong { .. })));
assert!(!called.get());
}
#[test]
fn rejects_malformed_url() {
let result = open_url_with("not-a-valid-url", |_| Ok(()));
assert!(matches!(result, Err(BrowserError::Malformed(_))));
}
#[test]
fn surfaces_opener_failure_as_launch() {
let result = open_url_with("https://example.com/", |_| {
Err(io::Error::new(io::ErrorKind::NotFound, "no handler"))
});
assert!(matches!(result, Err(BrowserError::Launch(_))));
}
#[test]
fn constants_match_documented_values() {
assert_eq!(MAX_URL_LEN, 8192);
assert_eq!(ALLOWED_SCHEMES, ["https", "http", "mailto"]);
}
}