rtb-cli 0.7.0

Rust Tool Base — CLI application scaffolding, clap integration, and built-in commands.
Documentation
//! URL opening with a scheme allowlist and input hygiene.
//!
//! All URL-opening in RTB and in scaffolded tools must route through
//! [`open_url`]; calling `open::that`, `webbrowser::open`, or
//! `xdg-open`/`rundll32` directly is forbidden (CLAUDE.md §URL Opening).
//! The helper enforces a scheme allowlist, a length bound, and
//! control-character rejection *before* handing the URL to the OS handler,
//! and never interpolates it into a shell.
//!
//! Callers constructing `mailto:` URLs from user-influenced data must
//! `urlencoding::encode` every parameter value first — this helper rejects
//! raw control characters but does not itself encode query values.
//!
//! See `docs/development/specs/2026-06-26-rtb-cli-browser-open-url.md`.

use std::io;

/// Maximum accepted URL length, in bytes.
pub const MAX_URL_LEN: usize = 8192; // 8 KiB

/// Non-configurable scheme allowlist.
pub const ALLOWED_SCHEMES: [&str; 3] = ["https", "http", "mailto"];

/// Error returned when a URL is rejected by the guardrails or the OS
/// handler fails to launch.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum BrowserError {
    /// The URL exceeds [`MAX_URL_LEN`].
    #[error("URL is {len} bytes; limit is {} bytes", MAX_URL_LEN)]
    #[diagnostic(code(rtb_cli::browser::too_long))]
    TooLong {
        /// Actual URL length, in bytes.
        len: usize,
    },

    /// The URL contains control characters (CR/LF/NUL etc.).
    #[error("URL contains control characters")]
    #[diagnostic(code(rtb_cli::browser::control_chars))]
    ControlChars,

    /// The URL could not be parsed.
    #[error("URL is malformed")]
    #[diagnostic(code(rtb_cli::browser::malformed))]
    Malformed(#[source] url::ParseError),

    /// The URL's scheme is not in [`ALLOWED_SCHEMES`].
    #[error("scheme {scheme:?} is not permitted (allowed: https, http, mailto)")]
    #[diagnostic(code(rtb_cli::browser::scheme_denied))]
    SchemeDenied {
        /// The rejected scheme.
        scheme: String,
    },

    /// The OS browser/handler could not be invoked.
    #[error("failed to invoke the OS browser handler")]
    #[diagnostic(code(rtb_cli::browser::launch))]
    Launch(#[source] io::Error),
}

/// Validate `url` against the allowlist + hygiene rules, then open it with
/// the OS handler.
///
/// # Errors
///
/// Returns a [`BrowserError`] if the URL is over-length, contains control
/// characters, is malformed, has a non-allowlisted scheme, or the OS
/// handler fails to launch.
pub fn open_url(url: &str) -> Result<(), BrowserError> {
    open_url_with(url, os_open)
}

/// The single sanctioned OS-handler invocation.
fn os_open(url: &str) -> io::Result<()> {
    open::that(url)
}

/// Validation core, parameterised over the opener so tests can assert
/// behaviour without spawning a real browser.
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"]);
    }
}