1use std::io;
17
18pub const MAX_URL_LEN: usize = 8192; pub const ALLOWED_SCHEMES: [&str; 3] = ["https", "http", "mailto"];
23
24#[derive(Debug, thiserror::Error, miette::Diagnostic)]
27pub enum BrowserError {
28 #[error("URL is {len} bytes; limit is {} bytes", MAX_URL_LEN)]
30 #[diagnostic(code(rtb_cli::browser::too_long))]
31 TooLong {
32 len: usize,
34 },
35
36 #[error("URL contains control characters")]
38 #[diagnostic(code(rtb_cli::browser::control_chars))]
39 ControlChars,
40
41 #[error("URL is malformed")]
43 #[diagnostic(code(rtb_cli::browser::malformed))]
44 Malformed(#[source] url::ParseError),
45
46 #[error("scheme {scheme:?} is not permitted (allowed: https, http, mailto)")]
48 #[diagnostic(code(rtb_cli::browser::scheme_denied))]
49 SchemeDenied {
50 scheme: String,
52 },
53
54 #[error("failed to invoke the OS browser handler")]
56 #[diagnostic(code(rtb_cli::browser::launch))]
57 Launch(#[source] io::Error),
58}
59
60pub fn open_url(url: &str) -> Result<(), BrowserError> {
69 open_url_with(url, os_open)
70}
71
72fn os_open(url: &str) -> io::Result<()> {
74 open::that(url)
75}
76
77fn 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}