use crate::webview_limits::{
MAX_WEBVIEW_EVENTS, MAX_WEBVIEW_MESSAGE_BYTES, MAX_WEBVIEW_MESSAGE_UNITS, validate_message,
};
use serde::Deserialize;
use std::{collections::BTreeSet, io};
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Selection {
pub(crate) start_uri: String,
pub(crate) selectors: Vec<String>,
}
impl Selection {
pub(crate) fn decode(text: &str, expected_start_uri: &str) -> io::Result<Self> {
validate_message(text.as_bytes())?;
if MAX_WEBVIEW_MESSAGE_BYTES > MAX_WEBVIEW_MESSAGE_UNITS
&& text.encode_utf16().count() > MAX_WEBVIEW_MESSAGE_UNITS
{
return Err(invalid("Startup selection exceeds the UTF16 unit bound"));
}
let selection: Self = serde_json::from_str(text)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
if selection.start_uri != expected_start_uri {
return Err(invalid(
"Startup selection URI differs from the configured URI",
));
}
if selection.selectors.is_empty() || selection.selectors.len() > MAX_WEBVIEW_EVENTS {
return Err(invalid(
"Startup selector count exceeds the admitted domain",
));
}
let mut unique = BTreeSet::new();
for selector in &selection.selectors {
if selector.is_empty() || selector.contains('\0') {
return Err(invalid(
"Startup selectors must be nonempty and contain no NUL",
));
}
if !unique.insert(selector.as_str()) {
return Err(invalid("Startup selectors must be unique"));
}
}
Ok(selection)
}
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
webview_limits::MAX_WEBVIEW_MESSAGE_BYTES,
webview_startup::{SELECTION_ENV, tests::trace},
};
use serde_json::json;
use std::io::ErrorKind;
fn selection(selectors: Vec<String>) -> String {
json!({"start_uri":"https://example.invalid/index.html", "selectors":selectors}).to_string()
}
#[test]
fn selection_preserves_exact_uri_and_unicode_selector_values() {
let _trace = trace();
let _test = tracing::info_span!("startup_selection_values").entered();
let (selectors, text) = tracing::info_span!("arrange").in_scope(|| {
let selectors = vec!["#状態-🦀".to_owned(), "[role=\"status\"]".to_owned()];
let text = selection(selectors.clone());
(selectors, text)
});
let value = tracing::info_span!("act").in_scope(|| {
Selection::decode(&text, "https://example.invalid/index.html").expect("selected URI")
});
tracing::info_span!("assert").in_scope(|| {
assert_eq!(value.start_uri, "https://example.invalid/index.html");
assert_eq!(value.selectors, selectors);
assert_eq!(SELECTION_ENV, "MOIRAI_WEBVIEW_STARTUP_OBSERVATION");
});
}
#[test]
fn selection_rejects_unknown_duplicate_and_malformed_fields() {
let _trace = trace();
let _test = tracing::info_span!("startup_selection_closed_schema").entered();
let inputs = tracing::info_span!("arrange").in_scope(|| {
[
(
r#"{"start_uri":"x","selectors":["p"],"other":1}"#,
"unknown field",
),
(
r#"{"start_uri":"x","start_uri":"x","selectors":["p"]}"#,
"duplicate field",
),
(
r#"{"start_uri":"x","selectors":["p"],"selectors":["p"]}"#,
"duplicate field",
),
(r#"{"start_uri":"x","selectors":false}"#, "invalid type"),
(r#"{"start_uri":"x"}"#, "missing field"),
(
r#"{"start_uri":"x","selectors":["p"]} true"#,
"trailing characters",
),
]
});
for (text, expected) in inputs {
let error = tracing::info_span!("act", text)
.in_scope(|| Selection::decode(text, "x").expect_err("closed schema"));
tracing::info_span!("assert").in_scope(|| {
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(error.to_string().contains(expected), "{error}");
let source = error
.get_ref()
.and_then(|source| source.downcast_ref::<serde_json::Error>())
.expect("original structured JSON failure");
assert_eq!(source.line(), 1);
assert!(source.column() > 0);
});
}
}
#[test]
fn selector_domain_admits_inclusive_count_and_rejects_each_invalid_dimension() {
let _trace = trace();
let _test = tracing::info_span!("startup_selector_domain").entered();
let selectors = tracing::info_span!("arrange").in_scope(|| {
(0..MAX_WEBVIEW_EVENTS)
.map(|index| format!("#status-{index}"))
.collect::<Vec<_>>()
});
let selected = tracing::info_span!("act").in_scope(|| {
Selection::decode(
&selection(selectors.clone()),
"https://example.invalid/index.html",
)
.expect("inclusive selector count")
});
tracing::info_span!("assert").in_scope(|| assert_eq!(selected.selectors, selectors));
let cases = tracing::info_span!("arrange").in_scope(|| {
let mut excessive = selectors;
excessive.push("#extra".to_owned());
[
(Vec::new(), "selector count"),
(excessive, "selector count"),
(vec![String::new()], "nonempty"),
(vec!["#one".to_owned(), "#one".to_owned()], "unique"),
(vec!["#a\0b".to_owned()], "contain no NUL"),
]
});
for (selectors, expected) in cases {
let error = tracing::info_span!("act").in_scope(|| {
Selection::decode(&selection(selectors), "https://example.invalid/index.html")
.expect_err("selector domain")
});
tracing::info_span!("assert").in_scope(|| {
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(error.to_string().contains(expected), "{error}");
});
}
let error = tracing::info_span!("act").in_scope(|| {
Selection::decode(&selection(vec!["p".to_owned()]), "https://other.invalid/")
.expect_err("different owner URI")
});
tracing::info_span!("assert").in_scope(|| {
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"Startup selection URI differs from the configured URI"
);
});
}
#[test]
fn selection_representation_admits_exact_limit_and_rejects_excess() {
let _trace = trace();
let _test = tracing::info_span!("startup_selection_representation").entered();
let overhead =
tracing::info_span!("arrange").in_scope(|| selection(vec![String::new()]).len());
for length in [
MAX_WEBVIEW_MESSAGE_BYTES - 1,
MAX_WEBVIEW_MESSAGE_BYTES,
MAX_WEBVIEW_MESSAGE_BYTES + 1,
] {
let text = tracing::info_span!("arrange", length)
.in_scope(|| selection(vec!["a".repeat(length - overhead)]));
let result = tracing::info_span!("act")
.in_scope(|| Selection::decode(&text, "https://example.invalid/index.html"));
tracing::info_span!("assert").in_scope(|| {
assert_eq!(text.len(), length);
assert_eq!(text.encode_utf16().count(), length);
if length <= MAX_WEBVIEW_MESSAGE_BYTES {
assert_eq!(
result.expect("inclusive bound").selectors,
vec!["a".repeat(length - overhead)]
);
} else {
let error = result.expect_err("excess bytes");
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"WebView2 JSON message exceeds the bounded byte limit"
);
}
});
}
}
}