use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
use std::time::Duration;
use index_core::{IndexUrl, Origin};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadlessRequest {
pub url: IndexUrl,
pub static_html: String,
pub config: HeadlessConfig,
}
impl HeadlessRequest {
#[must_use]
pub fn new(url: IndexUrl, static_html: impl Into<String>) -> Self {
Self {
url,
static_html: static_html.into(),
config: HeadlessConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadlessConfig {
pub timeout: TimeoutPolicy,
pub scripts: ScriptPolicy,
pub network: NetworkPolicy,
pub sandbox: SandboxPolicy,
}
impl Default for HeadlessConfig {
fn default() -> Self {
Self {
timeout: TimeoutPolicy::default(),
scripts: ScriptPolicy::Enabled,
network: NetworkPolicy::DenyExternal,
sandbox: SandboxPolicy::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutPolicy {
pub max_render_time: Duration,
}
impl TimeoutPolicy {
#[must_use]
pub const fn from_millis(milliseconds: u64) -> Self {
Self {
max_render_time: Duration::from_millis(milliseconds),
}
}
}
impl Default for TimeoutPolicy {
fn default() -> Self {
Self::from_millis(5_000)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptPolicy {
Disabled,
Enabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkPolicy {
DenyAll,
DenyExternal,
AllowAll,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxPolicy {
pub enabled: bool,
pub read_only_filesystem: bool,
pub no_credentials: bool,
}
impl Default for SandboxPolicy {
fn default() -> Self {
Self {
enabled: true,
read_only_filesystem: true,
no_credentials: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadlessSnapshot {
pub final_url: IndexUrl,
pub dom_html: String,
pub accessibility: Option<AccessibilitySnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessibilitySnapshot {
pub nodes: Vec<AccessibilityNode>,
}
impl AccessibilitySnapshot {
#[must_use]
pub fn text_content(&self) -> String {
let mut parts = Vec::new();
for node in &self.nodes {
collect_accessibility_text(node, &mut parts);
}
parts.join(" ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessibilityNode {
pub role: String,
pub name: String,
pub children: Vec<AccessibilityNode>,
}
impl AccessibilityNode {
#[must_use]
pub fn leaf(role: impl Into<String>, name: impl Into<String>) -> Self {
Self {
role: role.into(),
name: name.into(),
children: Vec::new(),
}
}
}
pub trait HeadlessBackend {
fn snapshot(&self, request: &HeadlessRequest) -> Result<HeadlessSnapshot, HeadlessError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadlessError {
TimedOut {
timeout_ms: u128,
},
PermissionDenied {
origin: Origin,
policy: NetworkPolicy,
},
SandboxRequired,
SnapshotFailed(String),
}
impl Display for HeadlessError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::TimedOut { timeout_ms } => {
write!(f, "headless rendering timed out after {timeout_ms}ms")
}
Self::PermissionDenied { origin, policy } => {
write!(
f,
"headless network request denied for {origin} by {policy:?}"
)
}
Self::SandboxRequired => f.write_str("headless rendering requires sandboxing"),
Self::SnapshotFailed(reason) => write!(f, "headless snapshot failed: {reason}"),
}
}
}
impl std::error::Error for HeadlessError {}
#[derive(Debug, Clone, Default)]
pub struct FixtureHeadlessBackend {
rendered: BTreeMap<String, FixtureSnapshot>,
denied_origins: BTreeSet<Origin>,
}
impl FixtureHeadlessBackend {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, url: IndexUrl, snapshot: FixtureSnapshot) {
self.rendered.insert(url.as_str().to_owned(), snapshot);
}
pub fn deny_origin(&mut self, origin: Origin) {
self.denied_origins.insert(origin);
}
}
impl HeadlessBackend for FixtureHeadlessBackend {
fn snapshot(&self, request: &HeadlessRequest) -> Result<HeadlessSnapshot, HeadlessError> {
enforce_sandbox(&request.config.sandbox)?;
let fixture = self
.rendered
.get(request.url.as_str())
.ok_or_else(|| HeadlessError::SnapshotFailed("no rendered fixture".to_owned()))?;
enforce_network_permissions(request, fixture, &self.denied_origins)?;
if fixture.render_time > request.config.timeout.max_render_time {
return Err(HeadlessError::TimedOut {
timeout_ms: request.config.timeout.max_render_time.as_millis(),
});
}
Ok(HeadlessSnapshot {
final_url: fixture.final_url.clone(),
dom_html: fixture.dom_html.clone(),
accessibility: fixture.accessibility.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixtureSnapshot {
pub final_url: IndexUrl,
pub dom_html: String,
pub accessibility: Option<AccessibilitySnapshot>,
pub render_time: Duration,
pub requested_origins: Vec<Origin>,
}
impl FixtureSnapshot {
#[must_use]
pub fn rendered(final_url: IndexUrl, dom_html: impl Into<String>) -> Self {
Self {
final_url,
dom_html: dom_html.into(),
accessibility: None,
render_time: Duration::from_millis(1),
requested_origins: Vec::new(),
}
}
}
fn enforce_sandbox(policy: &SandboxPolicy) -> Result<(), HeadlessError> {
if policy.enabled && policy.read_only_filesystem && policy.no_credentials {
Ok(())
} else {
Err(HeadlessError::SandboxRequired)
}
}
fn enforce_network_permissions(
request: &HeadlessRequest,
fixture: &FixtureSnapshot,
denied_origins: &BTreeSet<Origin>,
) -> Result<(), HeadlessError> {
let request_origin = request.url.origin();
for origin in &fixture.requested_origins {
let denied_by_policy = match request.config.network {
NetworkPolicy::DenyAll => true,
NetworkPolicy::DenyExternal => request_origin.as_ref() != Some(origin),
NetworkPolicy::AllowAll => false,
};
let denied_explicitly = denied_origins.contains(origin);
if denied_by_policy || denied_explicitly {
return Err(HeadlessError::PermissionDenied {
origin: origin.clone(),
policy: request.config.network,
});
}
}
if let Some(origin) = request_origin.filter(|origin| denied_origins.contains(origin)) {
return Err(HeadlessError::PermissionDenied {
origin,
policy: request.config.network,
});
}
Ok(())
}
fn collect_accessibility_text(node: &AccessibilityNode, parts: &mut Vec<String>) {
if !node.name.is_empty() {
parts.push(format!("{}: {}", node.role, node.name));
}
for child in &node.children {
collect_accessibility_text(child, parts);
}
}
#[cfg(test)]
mod tests {
use super::{
AccessibilityNode, AccessibilitySnapshot, FixtureHeadlessBackend, FixtureSnapshot,
HeadlessBackend, HeadlessConfig, HeadlessError, HeadlessRequest, NetworkPolicy,
SandboxPolicy, TimeoutPolicy,
};
use index_core::{IndexUrl, Origin};
#[test]
fn delayed_render_fixture_returns_rendered_dom() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/app")?;
let mut backend = FixtureHeadlessBackend::new();
backend.insert(
url.clone(),
FixtureSnapshot::rendered(
url.clone(),
"<main><h1>Loaded</h1><p>Rendered after delay.</p></main>",
),
);
let snapshot = backend.snapshot(&HeadlessRequest::new(url, "<main id=\"app\"></main>"))?;
assert!(snapshot.dom_html.contains("Rendered after delay."));
Ok(())
}
#[test]
fn spa_fixture_can_include_accessibility_tree() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/spa")?;
let mut backend = FixtureHeadlessBackend::new();
let mut fixture = FixtureSnapshot::rendered(
url.clone(),
"<main><button>Search</button><a href=\"/docs\">Docs</a></main>",
);
fixture.accessibility = Some(AccessibilitySnapshot {
nodes: vec![AccessibilityNode {
role: "main".to_owned(),
name: "Application".to_owned(),
children: vec![
AccessibilityNode::leaf("button", "Search"),
AccessibilityNode::leaf("link", "Docs"),
],
}],
});
backend.insert(url.clone(), fixture);
let snapshot = backend.snapshot(&HeadlessRequest::new(url, "<div id=\"root\"></div>"))?;
assert_eq!(
snapshot.accessibility.map(|tree| tree.text_content()),
Some("main: Application button: Search link: Docs".to_owned())
);
Ok(())
}
#[test]
fn timeout_errors_are_deterministic() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/slow")?;
let mut backend = FixtureHeadlessBackend::new();
let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Slow</main>");
fixture.render_time = std::time::Duration::from_millis(50);
backend.insert(url.clone(), fixture);
let mut request = HeadlessRequest::new(url, "<main></main>");
request.config.timeout = TimeoutPolicy::from_millis(10);
assert_eq!(
backend.snapshot(&request),
Err(HeadlessError::TimedOut { timeout_ms: 10 })
);
Ok(())
}
#[test]
fn denied_origin_returns_permission_error() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/app")?;
let mut backend = FixtureHeadlessBackend::new();
backend.insert(
url.clone(),
FixtureSnapshot::rendered(url.clone(), "<main>Denied</main>"),
);
backend.deny_origin(Origin::from_stored("https://example.com"));
let mut request = HeadlessRequest::new(url, "<main></main>");
request.config.network = NetworkPolicy::DenyExternal;
assert_eq!(
backend.snapshot(&request),
Err(HeadlessError::PermissionDenied {
origin: Origin::from_stored("https://example.com"),
policy: NetworkPolicy::DenyExternal
})
);
Ok(())
}
#[test]
fn sandbox_policy_must_remain_strict() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/app")?;
let backend = FixtureHeadlessBackend::new();
let mut request = HeadlessRequest::new(url, "<main></main>");
request.config = HeadlessConfig {
sandbox: SandboxPolicy {
enabled: false,
read_only_filesystem: true,
no_credentials: true,
},
..HeadlessConfig::default()
};
assert_eq!(
backend.snapshot(&request),
Err(HeadlessError::SandboxRequired)
);
Ok(())
}
#[test]
fn network_policy_variants_enforce_expected_origin_rules()
-> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/app")?;
let external_origin = Origin::from_stored("https://cdn.example.net");
let mut backend = FixtureHeadlessBackend::new();
let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Network</main>");
fixture.requested_origins = vec![external_origin.clone()];
backend.insert(url.clone(), fixture);
let mut deny_all = HeadlessRequest::new(url.clone(), "<main></main>");
deny_all.config.network = NetworkPolicy::DenyAll;
assert_eq!(
backend.snapshot(&deny_all),
Err(HeadlessError::PermissionDenied {
origin: external_origin.clone(),
policy: NetworkPolicy::DenyAll,
})
);
let mut deny_external = HeadlessRequest::new(url.clone(), "<main></main>");
deny_external.config.network = NetworkPolicy::DenyExternal;
assert_eq!(
backend.snapshot(&deny_external),
Err(HeadlessError::PermissionDenied {
origin: external_origin.clone(),
policy: NetworkPolicy::DenyExternal,
})
);
let mut allow_all = HeadlessRequest::new(url, "<main></main>");
allow_all.config.network = NetworkPolicy::AllowAll;
let snapshot = backend.snapshot(&allow_all)?;
assert!(snapshot.dom_html.contains("Network"));
Ok(())
}
#[test]
fn explicit_deny_origin_blocks_allow_all_policy() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/app")?;
let denied_origin = Origin::from_stored("https://cdn.example.net");
let mut backend = FixtureHeadlessBackend::new();
let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Denied</main>");
fixture.requested_origins = vec![denied_origin.clone()];
backend.insert(url.clone(), fixture);
backend.deny_origin(denied_origin.clone());
let mut request = HeadlessRequest::new(url, "<main></main>");
request.config.network = NetworkPolicy::AllowAll;
assert_eq!(
backend.snapshot(&request),
Err(HeadlessError::PermissionDenied {
origin: denied_origin,
policy: NetworkPolicy::AllowAll,
})
);
Ok(())
}
#[test]
fn missing_fixture_returns_snapshot_failed() -> Result<(), Box<dyn std::error::Error>> {
let request = HeadlessRequest::new(IndexUrl::parse("https://example.com/missing")?, "");
let backend = FixtureHeadlessBackend::new();
let result = backend.snapshot(&request);
assert!(matches!(
result,
Err(HeadlessError::SnapshotFailed(reason)) if reason.contains("no rendered fixture")
));
Ok(())
}
#[test]
fn headless_error_display_variants_are_actionable() {
let timed_out = HeadlessError::TimedOut { timeout_ms: 250 }.to_string();
assert!(timed_out.contains("timed out"));
assert!(timed_out.contains("250ms"));
let denied = HeadlessError::PermissionDenied {
origin: Origin::from_stored("https://example.com"),
policy: NetworkPolicy::DenyAll,
}
.to_string();
assert!(denied.contains("denied"));
assert!(denied.contains("DenyAll"));
let sandbox = HeadlessError::SandboxRequired.to_string();
assert!(sandbox.contains("requires sandboxing"));
let failed = HeadlessError::SnapshotFailed("boom".to_owned()).to_string();
assert!(failed.contains("boom"));
}
}