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}