use std::{io, path::Path, time::Duration};
use super::folder::FolderMapping;
pub const MAX_WEBVIEW_EVENTS: usize = 256;
pub const MAX_WEBVIEW_MESSAGE_BYTES: usize = 64 * 1024;
pub const MAX_WEBVIEW_MESSAGE_UNITS: usize = 64 * 1024;
pub const MAX_WEBVIEW_URI_UNITS: usize = 2 * 1024;
pub const MAX_WEBVIEW_WAIT_MILLISECONDS: u32 = 30_000;
pub const MAX_WEBVIEW_CAPTURE_BYTES: usize = 16 * 1024 * 1024 * 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebViewConfig {
start_uri: String,
allowed_prefix: String,
wait: Duration,
folder: Option<FolderMapping>,
}
impl WebViewConfig {
pub fn new(start_uri: impl AsRef<str>) -> io::Result<Self> {
Self::with_wait(start_uri, Duration::from_secs(30))
}
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,
})
}
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),
})
}
#[must_use]
pub fn start_uri(&self) -> &str {
&self.start_uri
}
#[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()),
}
}
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(())
}
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",
)
}