moirai-pal 0.7.0

Platform Abstraction Layer for Moirai async I/O operations
Documentation
//! Validated WebView2 URL and resource bounds.

use std::{io, path::Path, time::Duration};

use super::folder::FolderMapping;

/// Maximum retained WebView2 events for one host.
pub const MAX_WEBVIEW_EVENTS: usize = 256;
/// Maximum UTF-8 bytes in one outbound or inbound JSON message.
pub const MAX_WEBVIEW_MESSAGE_BYTES: usize = 64 * 1024;
/// Maximum UTF-16 code units in one JSON message.
pub const MAX_WEBVIEW_MESSAGE_UNITS: usize = 64 * 1024;
/// Maximum UTF-16 code units in one WebView2 URI.
pub const MAX_WEBVIEW_URI_UNITS: usize = 2 * 1024;
/// Maximum finite wait for WebView2 creation or navigation.
pub const MAX_WEBVIEW_WAIT_MILLISECONDS: u32 = 30_000;
/// Maximum PNG bytes retained by one WebView2 preview capture.
///
/// The bound matches the maximum RGBA8 presentation payload for one surface
/// (`16,777,216` pixels × four channels). A compressed preview is rejected
/// before allocation when it exceeds the same memory budget.
pub const MAX_WEBVIEW_CAPTURE_BYTES: usize = 16 * 1024 * 1024 * 4;

/// Validated WebView2 host configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebViewConfig {
    start_uri: String,
    allowed_prefix: String,
    wait: Duration,
    /// `None` serves a packaged `file:///` directory.
    folder: Option<FolderMapping>,
}

impl WebViewConfig {
    /// Validates a packaged `file:///` entry page with the default finite wait.
    ///
    /// # Errors
    /// Returns `InvalidInput` for a non-file URI, traversal, malformed percent
    /// escapes or a URI outside the configured UTF-16 bound.
    pub fn new(start_uri: impl AsRef<str>) -> io::Result<Self> {
        Self::with_wait(start_uri, Duration::from_secs(30))
    }

    /// Validates a packaged entry page and finite WebView2 operation wait.
    ///
    /// # Errors
    /// Returns `InvalidInput` when the URI or wait exceeds the provider's
    /// resource bounds.
    pub fn with_wait(start_uri: impl AsRef<str>, wait: Duration) -> io::Result<Self> {
        validate_wait(wait)?;
        let start_uri = start_uri.as_ref();
        validate_file_uri(start_uri)?;
        let slash = start_uri.rfind('/').ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 entry URI has no directory",
            )
        })?;
        let allowed_prefix = slash
            .checked_add(1)
            .and_then(|end| start_uri.get(..end))
            .ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::InvalidInput,
                    "WebView2 entry URI is not UTF-8",
                )
            })?;
        let mut owned_uri = String::new();
        owned_uri
            .try_reserve_exact(start_uri.len())
            .map_err(|_| allocation_error())?;
        owned_uri.push_str(start_uri);
        let mut owned_prefix = String::new();
        owned_prefix
            .try_reserve_exact(allowed_prefix.len())
            .map_err(|_| allocation_error())?;
        owned_prefix.push_str(allowed_prefix);
        Ok(Self {
            start_uri: owned_uri,
            allowed_prefix: owned_prefix,
            wait,
            folder: None,
        })
    }

    /// Serves `folder` as `https://{host}/` and opens `entry` within it.
    ///
    /// A `file:///` page cannot load ES modules or stream-compile
    /// WebAssembly; a mapped host is a secure origin, so a page built for a
    /// web server runs unchanged. `host` must be a lowercase name under
    /// `.example`, `.invalid`, `.localhost` or `.test` (RFC 2606, RFC 6761),
    /// so the mapping cannot shadow a real site; navigation is confined to
    /// it, and other origins cannot read its resources.
    ///
    /// # Errors
    /// Returns `InvalidInput` for a host outside the reserved domains, a
    /// relative or non-directory folder, an entry with traversal, query or
    /// fragment syntax, or a wait beyond the provider bound, and the
    /// filesystem error when `folder` cannot be inspected.
    pub fn folder(
        host: &str,
        folder: impl AsRef<Path>,
        entry: &str,
        wait: Duration,
    ) -> io::Result<Self> {
        validate_wait(wait)?;
        let folder = FolderMapping::new(host, folder.as_ref())?;
        validate_mapped_path(entry)?;
        let allowed_prefix = format!("https://{host}/");
        let start_uri = format!("{allowed_prefix}{entry}");
        if start_uri.encode_utf16().count() > MAX_WEBVIEW_URI_UNITS {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 URI exceeds the bounded UTF-16 limit",
            ));
        }
        Ok(Self {
            start_uri,
            allowed_prefix,
            wait,
            folder: Some(folder),
        })
    }

    /// Returns the validated entry URI.
    #[must_use]
    pub fn start_uri(&self) -> &str {
        &self.start_uri
    }

    /// Returns the finite operation wait.
    #[must_use]
    pub const fn wait(&self) -> Duration {
        self.wait
    }

    pub(super) fn allows(&self, uri: &str) -> bool {
        match &self.folder {
            None => validate_file_uri(uri).is_ok() && uri.starts_with(&self.allowed_prefix),
            Some(_) => uri
                .strip_prefix(&self.allowed_prefix)
                .is_some_and(|path| validate_mapped_path(path).is_ok()),
        }
    }

    /// The host-to-folder mapping the host installs before navigating.
    pub(super) const fn folder_mapping(&self) -> Option<&FolderMapping> {
        self.folder.as_ref()
    }
}

pub(super) fn validate_message(bytes: &[u8]) -> io::Result<()> {
    if bytes.len() > MAX_WEBVIEW_MESSAGE_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 JSON message exceeds the bounded byte limit",
        ));
    }
    if bytes.contains(&0) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 JSON message contains NUL",
        ));
    }
    Ok(())
}

fn validate_wait(wait: Duration) -> io::Result<()> {
    let milliseconds = u32::try_from(wait.as_millis()).map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 wait exceeds the 30 second bound",
        )
    })?;
    if milliseconds > MAX_WEBVIEW_WAIT_MILLISECONDS {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 wait exceeds the 30 second bound",
        ));
    }
    Ok(())
}

fn validate_file_uri(uri: &str) -> io::Result<()> {
    if uri.is_empty()
        || uri
            .chars()
            .any(|character| matches!(character, '\0' | '\\' | '?' | '#'))
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 URI must be a non-empty NUL-free file URI",
        ));
    }
    if uri.encode_utf16().count() > MAX_WEBVIEW_URI_UNITS {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 URI exceeds the bounded UTF-16 limit",
        ));
    }
    let Some(rest) = uri
        .strip_prefix("file:///")
        .or_else(|| uri.strip_prefix("FILE:///"))
    else {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 navigation is restricted to file:/// resources",
        ));
    };
    if rest.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 file URI has an empty path",
        ));
    }
    validate_percent_escapes(uri)?;
    for segment in rest.split('/') {
        if segment == "." || segment == ".." {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 file URI contains a traversal segment",
            ));
        }
    }
    Ok(())
}

/// A path below a mapped host: non-empty segments, no traversal, and no
/// query, fragment, backslash or NUL.
fn validate_mapped_path(path: &str) -> io::Result<()> {
    if path.is_empty()
        || path
            .chars()
            .any(|character| matches!(character, '\0' | '\\' | '?' | '#'))
        || path
            .split('/')
            .any(|segment| segment.is_empty() || segment == "." || segment == "..")
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "WebView2 mapped path must be relative segments without traversal or query",
        ));
    }
    validate_percent_escapes(path)
}

fn validate_percent_escapes(uri: &str) -> io::Result<()> {
    let bytes = uri.as_bytes();
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] != b'%' {
            index += 1;
            continue;
        }
        let pair = bytes.get(index + 1..index + 3).ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 URI contains an incomplete percent escape",
            )
        })?;
        let high = hex(pair[0]).ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 URI contains a non-hex percent escape",
            )
        })?;
        let low = hex(pair[1]).ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 URI contains a non-hex percent escape",
            )
        })?;
        let value = (high << 4) | low;
        if matches!(value, b'.' | b'/' | b'\\') {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "WebView2 URI contains an encoded path separator",
            ));
        }
        index += 3;
    }
    Ok(())
}

fn hex(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

fn allocation_error() -> io::Error {
    io::Error::new(
        io::ErrorKind::OutOfMemory,
        "WebView2 configuration reservation failed",
    )
}