ailloli_ui_runtime/app/external_url.rs
1//! Validated external HTTP(S) URLs and provider-neutral opening.
2
3use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6
7/// An absolute HTTP(S) URL accepted for opening outside the application.
8///
9/// Validation uses a standards-compliant parser, while the accepted source is
10/// preserved byte-for-byte so query strings and fragments reach the system
11/// opener unchanged.
12///
13/// User info, ports, query strings, and fragments are allowed and may contain
14/// sensitive data. Debug output includes the stored source, so callers must not
15/// log values indiscriminately. Host adapters must pass the URL as one argument
16/// to an opener API/process and never interpolate it into a shell command.
17///
18/// # Examples
19///
20/// ```
21/// use ailloli_ui_runtime::app::ExternalUrl;
22/// let source = "https://example.com:8443/docs?q=rust#api";
23/// let url = ExternalUrl::parse(source)?;
24/// assert_eq!(url.as_str(), source);
25/// # Ok::<(), ailloli_ui_runtime::app::ExternalUrlError>(())
26/// ```
27#[derive(Clone, Debug, PartialEq, Eq, Hash)]
28pub struct ExternalUrl(String);
29
30/// Provides the operations defined for ExternalUrl.
31impl ExternalUrl {
32 /// Validates and preserves an absolute HTTP or HTTPS URL.
33 ///
34 /// Empty input and any leading/trailing Unicode whitespace are rejected
35 /// before parsing. The parsed scheme must be HTTP(S) and a host must be
36 /// present. The original bytes (not the parser's normalized serialization)
37 /// are stored. Validation performs no DNS, network, allow-list, credential,
38 /// or reachability check.
39 ///
40 /// # Errors
41 ///
42 /// Returns a specific [`ExternalUrlError`] in validation order. Errors never
43 /// include the rejected source string.
44 ///
45 /// # Examples
46 ///
47 /// ```
48 /// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlError};
49 /// assert!(ExternalUrl::parse("http://localhost").is_ok());
50 /// assert_eq!(ExternalUrl::parse(" file:///tmp/x"), Err(ExternalUrlError::SurroundingWhitespace));
51 /// assert_eq!(ExternalUrl::parse("file:///tmp/x"), Err(ExternalUrlError::UnsupportedScheme));
52 /// ```
53 pub fn parse(value: impl AsRef<str>) -> Result<Self, ExternalUrlError> {
54 let value = value.as_ref();
55 if value.is_empty() {
56 return Err(ExternalUrlError::Empty);
57 }
58 if value.trim() != value {
59 return Err(ExternalUrlError::SurroundingWhitespace);
60 }
61
62 let parsed = url::Url::parse(value).map_err(|_| ExternalUrlError::Malformed)?;
63 if !matches!(parsed.scheme(), "http" | "https") {
64 return Err(ExternalUrlError::UnsupportedScheme);
65 }
66 if parsed.host().is_none() {
67 return Err(ExternalUrlError::MissingHost);
68 }
69
70 Ok(Self(value.to_owned()))
71 }
72
73 /// Borrows the exact accepted source string.
74 ///
75 /// No normalization, redaction, or percent-decoding is performed.
76 ///
77 /// # Examples
78 ///
79 /// ```
80 /// use ailloli_ui_runtime::app::ExternalUrl;
81 /// let url = ExternalUrl::parse("HTTPS://EXAMPLE.COM/a%20b?q=1#x")?;
82 /// assert_eq!(url.as_str(), "HTTPS://EXAMPLE.COM/a%20b?q=1#x");
83 /// # Ok::<(), ailloli_ui_runtime::app::ExternalUrlError>(())
84 /// ```
85 pub fn as_str(&self) -> &str {
86 &self.0
87 }
88}
89
90/// Implements the `TryFrom<&str>` contract for `ExternalUrl`.
91impl TryFrom<&str> for ExternalUrl {
92 /// Error type produced by this conversion.
93 type Error = ExternalUrlError;
94
95 /// Validates and performs the requested conversion.
96 ///
97 /// # Errors
98 ///
99 /// Propagates the validation errors documented by [`ExternalUrl::parse`].
100 fn try_from(value: &str) -> Result<Self, Self::Error> {
101 Self::parse(value)
102 }
103}
104
105/// Implements the `TryFrom<String>` contract for `ExternalUrl`.
106impl TryFrom<String> for ExternalUrl {
107 /// Error type produced by this conversion.
108 type Error = ExternalUrlError;
109
110 /// Validates and performs the requested conversion.
111 ///
112 /// # Errors
113 ///
114 /// Propagates the validation errors documented by [`ExternalUrl::parse`].
115 fn try_from(value: String) -> Result<Self, Self::Error> {
116 Self::parse(value)
117 }
118}
119
120/// Why an external URL was rejected. Error messages intentionally omit input.
121///
122/// # Examples
123///
124/// ```
125/// use ailloli_ui_runtime::app::ExternalUrlError;
126/// assert_eq!(ExternalUrlError::Empty.to_string(), "external URL is empty");
127/// assert!(!ExternalUrlError::Malformed.to_string().contains("secret"));
128/// ```
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum ExternalUrlError {
131 /// The source contains zero bytes.
132 Empty,
133 /// [`str::trim`] would remove leading or trailing Unicode whitespace.
134 SurroundingWhitespace,
135 /// `url::Url` could not parse the source as an absolute URL.
136 Malformed,
137 /// The parsed scheme is neither HTTP nor HTTPS.
138 UnsupportedScheme,
139 /// The parsed HTTP(S) URL has no host.
140 MissingHost,
141}
142
143/// Implements the fmt::Display contract for ExternalUrlError.
144impl fmt::Display for ExternalUrlError {
145 /// Formats the value for human-readable diagnostics.
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(match self {
148 Self::Empty => "external URL is empty",
149 Self::SurroundingWhitespace => "external URL has surrounding whitespace",
150 Self::Malformed => "external URL is not a valid absolute URL",
151 Self::UnsupportedScheme => "external URL scheme is not supported",
152 Self::MissingHost => "external URL has no host",
153 })
154 }
155}
156
157/// Implements the std::error::Error contract for ExternalUrlError.
158impl std::error::Error for ExternalUrlError {}
159
160/// A non-fatal failure while handing a validated URL to the host system.
161///
162/// Errors deliberately carry no URL, process command, or platform detail so
163/// UI diagnostics cannot accidentally expose credentials or query secrets.
164///
165/// # Examples
166///
167/// ```
168/// use ailloli_ui_runtime::app::OpenUrlError;
169/// assert_eq!(OpenUrlError::Unavailable.to_string(), "system URL opener is unavailable");
170/// assert_eq!(OpenUrlError::LaunchFailed.to_string(), "system URL opener failed");
171/// ```
172#[derive(Clone, Debug, PartialEq, Eq)]
173pub enum OpenUrlError {
174 /// No suitable host URL-opening capability is installed or enabled.
175 Unavailable,
176 /// A configured opener failed to accept or launch the URL.
177 LaunchFailed,
178}
179
180/// Implements the fmt::Display contract for OpenUrlError.
181impl fmt::Display for OpenUrlError {
182 /// Formats the value for human-readable diagnostics.
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 Self::Unavailable => f.write_str("system URL opener is unavailable"),
186 Self::LaunchFailed => f.write_str("system URL opener failed"),
187 }
188 }
189}
190
191/// Implements the std::error::Error contract for OpenUrlError.
192impl std::error::Error for OpenUrlError {}
193
194/// Provider-neutral capability used by widgets to open validated URLs.
195///
196/// Implementations should return promptly, must not shell-interpolate the URL,
197/// and should avoid logging its potentially sensitive exact source.
198///
199/// # Examples
200///
201/// ```
202/// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener};
203/// let opener: &dyn ExternalUrlOpener = &MemoryExternalUrlOpener::new();
204/// let url = ExternalUrl::parse("https://example.com")?;
205/// opener.open(&url)?;
206/// # Ok::<(), Box<dyn std::error::Error>>(())
207/// ```
208pub trait ExternalUrlOpener {
209 /// Hands one already validated URL to the host.
210 ///
211 /// A successful return means the host accepted the request, not that a
212 /// browser loaded the resource.
213 ///
214 /// # Errors
215 ///
216 /// Returns [`OpenUrlError::Unavailable`] when no host capability can accept
217 /// the URL, or [`OpenUrlError::LaunchFailed`] when an available capability
218 /// fails to launch it.
219 ///
220 /// # Examples
221 ///
222 /// ```
223 /// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener};
224 /// let opener = MemoryExternalUrlOpener::new();
225 /// let url = ExternalUrl::parse("https://example.com")?;
226 /// assert_eq!(opener.open(&url), Ok(()));
227 /// assert_eq!(opener.opened_urls(), ["https://example.com"]);
228 /// # Ok::<(), ailloli_ui_runtime::app::ExternalUrlError>(())
229 /// ```
230 fn open(&self, url: &ExternalUrl) -> Result<(), OpenUrlError>;
231}
232
233/// Deterministic opener for headless runtimes and tests.
234///
235/// Clones share recorded URLs and the one-shot error slot through `Rc<RefCell<_>>`.
236/// The type is neither `Send` nor `Sync`. Successful opens append exact URL
237/// strings without a capacity bound; tests should drain them when appropriate.
238///
239/// # Examples
240///
241/// ```
242/// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener};
243/// let opener = MemoryExternalUrlOpener::new();
244/// let alias = opener.clone();
245/// alias.open(&ExternalUrl::parse("https://example.com")?)?;
246/// assert_eq!(opener.opened_urls(), ["https://example.com"]);
247/// # Ok::<(), Box<dyn std::error::Error>>(())
248/// ```
249#[derive(Clone, Default)]
250pub struct MemoryExternalUrlOpener {
251 /// Shared unbounded successful-open log.
252 opened: Rc<RefCell<Vec<String>>>,
253 /// Shared error consumed by the next open attempt only.
254 next_error: Rc<RefCell<Option<OpenUrlError>>>,
255}
256
257/// Provides the operations defined for MemoryExternalUrlOpener.
258impl MemoryExternalUrlOpener {
259 /// Creates an opener with an empty log and no scheduled failure.
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// use ailloli_ui_runtime::app::MemoryExternalUrlOpener;
265 /// let opener = MemoryExternalUrlOpener::new();
266 /// assert!(opener.opened_urls().is_empty());
267 /// ```
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 /// Clones and returns all successful URL sources in call order.
273 ///
274 /// This is O(total stored URL bytes) and does not clear the log.
275 ///
276 /// # Panics
277 ///
278 /// Panics on a conflicting borrow through a reentrant alias.
279 ///
280 /// # Examples
281 ///
282 /// ```
283 /// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener};
284 /// let opener = MemoryExternalUrlOpener::new();
285 /// opener.open(&ExternalUrl::parse("https://example.com/a")?)?;
286 /// assert_eq!(opener.opened_urls(), ["https://example.com/a"]);
287 /// assert_eq!(opener.opened_urls().len(), 1);
288 /// # Ok::<(), Box<dyn std::error::Error>>(())
289 /// ```
290 pub fn opened_urls(&self) -> Vec<String> {
291 self.opened.borrow().clone()
292 }
293
294 /// Drains and returns all successful URL sources in call order.
295 ///
296 /// A second call returns an empty vector until another successful open.
297 ///
298 /// # Panics
299 ///
300 /// Panics on a conflicting borrow through a reentrant alias.
301 ///
302 /// # Examples
303 ///
304 /// ```
305 /// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener};
306 /// let opener = MemoryExternalUrlOpener::new();
307 /// opener.open(&ExternalUrl::parse("https://example.com")?)?;
308 /// assert_eq!(opener.take_opened_urls(), ["https://example.com"]);
309 /// assert!(opener.take_opened_urls().is_empty());
310 /// # Ok::<(), Box<dyn std::error::Error>>(())
311 /// ```
312 pub fn take_opened_urls(&self) -> Vec<String> {
313 std::mem::take(&mut *self.opened.borrow_mut())
314 }
315
316 /// Makes the next open attempt return `error` without recording its URL.
317 ///
318 /// Calling this again before an attempt replaces the pending error. Clones
319 /// observe and consume the same one-shot slot.
320 ///
321 /// # Panics
322 ///
323 /// Panics on a conflicting borrow through a reentrant alias.
324 ///
325 /// # Examples
326 ///
327 /// ```
328 /// use ailloli_ui_runtime::app::{ExternalUrl, ExternalUrlOpener, MemoryExternalUrlOpener, OpenUrlError};
329 /// let opener = MemoryExternalUrlOpener::new();
330 /// let url = ExternalUrl::parse("https://example.com")?;
331 /// opener.fail_next(OpenUrlError::Unavailable);
332 /// assert_eq!(opener.open(&url), Err(OpenUrlError::Unavailable));
333 /// assert_eq!(opener.open(&url), Ok(()));
334 /// # Ok::<(), ailloli_ui_runtime::app::ExternalUrlError>(())
335 /// ```
336 pub fn fail_next(&self, error: OpenUrlError) {
337 *self.next_error.borrow_mut() = Some(error);
338 }
339}
340
341/// Implements the ExternalUrlOpener contract for MemoryExternalUrlOpener.
342impl ExternalUrlOpener for MemoryExternalUrlOpener {
343 /// Attempts to open the validated external URL.
344 ///
345 /// # Errors
346 ///
347 /// Returns and consumes the error installed by [`Self::fail_next`], if any.
348 ///
349 /// # Panics
350 ///
351 /// Panics on a conflicting reentrant borrow of the opener's local state.
352 fn open(&self, url: &ExternalUrl) -> Result<(), OpenUrlError> {
353 if let Some(error) = self.next_error.borrow_mut().take() {
354 return Err(error);
355 }
356 self.opened.borrow_mut().push(url.as_str().to_owned());
357 Ok(())
358 }
359}
360
361#[cfg(test)]
362/// Tests implementation details.
363mod tests {
364 use super::*;
365
366 #[test]
367 /// Verifies that accepts http https and preserves exact source.
368 fn accepts_http_https_and_preserves_exact_source() {
369 let source = "https://user:secret@example.com:8443/docs?q=token#part";
370 assert_eq!(ExternalUrl::parse(source).unwrap().as_str(), source);
371 assert_eq!(
372 ExternalUrl::parse("http://example.com").unwrap().as_str(),
373 "http://example.com"
374 );
375 }
376
377 #[test]
378 /// Verifies that rejects unsafe or non absolute urls.
379 fn rejects_unsafe_or_non_absolute_urls() {
380 for value in [
381 "",
382 " docs/index.html",
383 "docs/index.html",
384 "javascript:alert(1)",
385 "data:text/plain,hello",
386 "file:///tmp/secret",
387 "https://",
388 ] {
389 assert!(ExternalUrl::parse(value).is_err(), "accepted {value:?}");
390 }
391 }
392
393 #[test]
394 /// Verifies that validation errors do not echo sensitive input.
395 fn validation_errors_do_not_echo_sensitive_input() {
396 let secret = "secret-query-value";
397 let error = ExternalUrl::parse(format!("javascript:{secret}")).unwrap_err();
398 assert!(!error.to_string().contains(secret));
399 }
400
401 #[test]
402 /// Verifies that memory opener records success and can fail once.
403 fn memory_opener_records_success_and_can_fail_once() {
404 let opener = MemoryExternalUrlOpener::new();
405 let url = ExternalUrl::parse("https://example.com?q=1").unwrap();
406 opener.open(&url).unwrap();
407 assert_eq!(opener.opened_urls(), [url.as_str()]);
408
409 opener.fail_next(OpenUrlError::Unavailable);
410 assert_eq!(opener.open(&url), Err(OpenUrlError::Unavailable));
411 opener.open(&url).unwrap();
412 assert_eq!(opener.opened_urls().len(), 2);
413 }
414}