1use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::{Display, Formatter};
8use std::time::Duration;
9
10use index_core::{IndexUrl, Origin};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct HeadlessRequest {
15 pub url: IndexUrl,
17 pub static_html: String,
19 pub config: HeadlessConfig,
21}
22
23impl HeadlessRequest {
24 #[must_use]
26 pub fn new(url: IndexUrl, static_html: impl Into<String>) -> Self {
27 Self {
28 url,
29 static_html: static_html.into(),
30 config: HeadlessConfig::default(),
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HeadlessConfig {
38 pub timeout: TimeoutPolicy,
40 pub scripts: ScriptPolicy,
42 pub network: NetworkPolicy,
44 pub sandbox: SandboxPolicy,
46}
47
48impl Default for HeadlessConfig {
49 fn default() -> Self {
50 Self {
51 timeout: TimeoutPolicy::default(),
52 scripts: ScriptPolicy::Enabled,
53 network: NetworkPolicy::DenyExternal,
54 sandbox: SandboxPolicy::default(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct TimeoutPolicy {
62 pub max_render_time: Duration,
64}
65
66impl TimeoutPolicy {
67 #[must_use]
69 pub const fn from_millis(milliseconds: u64) -> Self {
70 Self {
71 max_render_time: Duration::from_millis(milliseconds),
72 }
73 }
74}
75
76impl Default for TimeoutPolicy {
77 fn default() -> Self {
78 Self::from_millis(5_000)
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ScriptPolicy {
85 Disabled,
87 Enabled,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum NetworkPolicy {
94 DenyAll,
96 DenyExternal,
98 AllowAll,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SandboxPolicy {
105 pub enabled: bool,
107 pub read_only_filesystem: bool,
109 pub no_credentials: bool,
111}
112
113impl Default for SandboxPolicy {
114 fn default() -> Self {
115 Self {
116 enabled: true,
117 read_only_filesystem: true,
118 no_credentials: true,
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct HeadlessSnapshot {
126 pub final_url: IndexUrl,
128 pub dom_html: String,
130 pub accessibility: Option<AccessibilitySnapshot>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct AccessibilitySnapshot {
137 pub nodes: Vec<AccessibilityNode>,
139}
140
141impl AccessibilitySnapshot {
142 #[must_use]
144 pub fn text_content(&self) -> String {
145 let mut parts = Vec::new();
146 for node in &self.nodes {
147 collect_accessibility_text(node, &mut parts);
148 }
149 parts.join(" ")
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct AccessibilityNode {
156 pub role: String,
158 pub name: String,
160 pub children: Vec<AccessibilityNode>,
162}
163
164impl AccessibilityNode {
165 #[must_use]
167 pub fn leaf(role: impl Into<String>, name: impl Into<String>) -> Self {
168 Self {
169 role: role.into(),
170 name: name.into(),
171 children: Vec::new(),
172 }
173 }
174}
175
176pub trait HeadlessBackend {
178 fn snapshot(&self, request: &HeadlessRequest) -> Result<HeadlessSnapshot, HeadlessError>;
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum HeadlessError {
185 TimedOut {
187 timeout_ms: u128,
189 },
190 PermissionDenied {
192 origin: Origin,
194 policy: NetworkPolicy,
196 },
197 SandboxRequired,
199 SnapshotFailed(String),
201}
202
203impl Display for HeadlessError {
204 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
205 match self {
206 Self::TimedOut { timeout_ms } => {
207 write!(f, "headless rendering timed out after {timeout_ms}ms")
208 }
209 Self::PermissionDenied { origin, policy } => {
210 write!(
211 f,
212 "headless network request denied for {origin} by {policy:?}"
213 )
214 }
215 Self::SandboxRequired => f.write_str("headless rendering requires sandboxing"),
216 Self::SnapshotFailed(reason) => write!(f, "headless snapshot failed: {reason}"),
217 }
218 }
219}
220
221impl std::error::Error for HeadlessError {}
222
223#[derive(Debug, Clone, Default)]
225pub struct FixtureHeadlessBackend {
226 rendered: BTreeMap<String, FixtureSnapshot>,
227 denied_origins: BTreeSet<Origin>,
228}
229
230impl FixtureHeadlessBackend {
231 #[must_use]
233 pub fn new() -> Self {
234 Self::default()
235 }
236
237 pub fn insert(&mut self, url: IndexUrl, snapshot: FixtureSnapshot) {
239 self.rendered.insert(url.as_str().to_owned(), snapshot);
240 }
241
242 pub fn deny_origin(&mut self, origin: Origin) {
244 self.denied_origins.insert(origin);
245 }
246}
247
248impl HeadlessBackend for FixtureHeadlessBackend {
249 fn snapshot(&self, request: &HeadlessRequest) -> Result<HeadlessSnapshot, HeadlessError> {
250 enforce_sandbox(&request.config.sandbox)?;
251
252 let fixture = self
253 .rendered
254 .get(request.url.as_str())
255 .ok_or_else(|| HeadlessError::SnapshotFailed("no rendered fixture".to_owned()))?;
256 enforce_network_permissions(request, fixture, &self.denied_origins)?;
257 if fixture.render_time > request.config.timeout.max_render_time {
258 return Err(HeadlessError::TimedOut {
259 timeout_ms: request.config.timeout.max_render_time.as_millis(),
260 });
261 }
262
263 Ok(HeadlessSnapshot {
264 final_url: fixture.final_url.clone(),
265 dom_html: fixture.dom_html.clone(),
266 accessibility: fixture.accessibility.clone(),
267 })
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct FixtureSnapshot {
274 pub final_url: IndexUrl,
276 pub dom_html: String,
278 pub accessibility: Option<AccessibilitySnapshot>,
280 pub render_time: Duration,
282 pub requested_origins: Vec<Origin>,
284}
285
286impl FixtureSnapshot {
287 #[must_use]
289 pub fn rendered(final_url: IndexUrl, dom_html: impl Into<String>) -> Self {
290 Self {
291 final_url,
292 dom_html: dom_html.into(),
293 accessibility: None,
294 render_time: Duration::from_millis(1),
295 requested_origins: Vec::new(),
296 }
297 }
298}
299
300fn enforce_sandbox(policy: &SandboxPolicy) -> Result<(), HeadlessError> {
301 if policy.enabled && policy.read_only_filesystem && policy.no_credentials {
302 Ok(())
303 } else {
304 Err(HeadlessError::SandboxRequired)
305 }
306}
307
308fn enforce_network_permissions(
309 request: &HeadlessRequest,
310 fixture: &FixtureSnapshot,
311 denied_origins: &BTreeSet<Origin>,
312) -> Result<(), HeadlessError> {
313 let request_origin = request.url.origin();
314 for origin in &fixture.requested_origins {
315 let denied_by_policy = match request.config.network {
316 NetworkPolicy::DenyAll => true,
317 NetworkPolicy::DenyExternal => request_origin.as_ref() != Some(origin),
318 NetworkPolicy::AllowAll => false,
319 };
320 let denied_explicitly = denied_origins.contains(origin);
321 if denied_by_policy || denied_explicitly {
322 return Err(HeadlessError::PermissionDenied {
323 origin: origin.clone(),
324 policy: request.config.network,
325 });
326 }
327 }
328
329 if let Some(origin) = request_origin.filter(|origin| denied_origins.contains(origin)) {
330 return Err(HeadlessError::PermissionDenied {
331 origin,
332 policy: request.config.network,
333 });
334 }
335 Ok(())
336}
337
338fn collect_accessibility_text(node: &AccessibilityNode, parts: &mut Vec<String>) {
339 if !node.name.is_empty() {
340 parts.push(format!("{}: {}", node.role, node.name));
341 }
342 for child in &node.children {
343 collect_accessibility_text(child, parts);
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::{
350 AccessibilityNode, AccessibilitySnapshot, FixtureHeadlessBackend, FixtureSnapshot,
351 HeadlessBackend, HeadlessConfig, HeadlessError, HeadlessRequest, NetworkPolicy,
352 SandboxPolicy, TimeoutPolicy,
353 };
354 use index_core::{IndexUrl, Origin};
355
356 #[test]
357 fn delayed_render_fixture_returns_rendered_dom() -> Result<(), Box<dyn std::error::Error>> {
358 let url = IndexUrl::parse("https://example.com/app")?;
359 let mut backend = FixtureHeadlessBackend::new();
360 backend.insert(
361 url.clone(),
362 FixtureSnapshot::rendered(
363 url.clone(),
364 "<main><h1>Loaded</h1><p>Rendered after delay.</p></main>",
365 ),
366 );
367
368 let snapshot = backend.snapshot(&HeadlessRequest::new(url, "<main id=\"app\"></main>"))?;
369
370 assert!(snapshot.dom_html.contains("Rendered after delay."));
371 Ok(())
372 }
373
374 #[test]
375 fn spa_fixture_can_include_accessibility_tree() -> Result<(), Box<dyn std::error::Error>> {
376 let url = IndexUrl::parse("https://example.com/spa")?;
377 let mut backend = FixtureHeadlessBackend::new();
378 let mut fixture = FixtureSnapshot::rendered(
379 url.clone(),
380 "<main><button>Search</button><a href=\"/docs\">Docs</a></main>",
381 );
382 fixture.accessibility = Some(AccessibilitySnapshot {
383 nodes: vec![AccessibilityNode {
384 role: "main".to_owned(),
385 name: "Application".to_owned(),
386 children: vec![
387 AccessibilityNode::leaf("button", "Search"),
388 AccessibilityNode::leaf("link", "Docs"),
389 ],
390 }],
391 });
392 backend.insert(url.clone(), fixture);
393
394 let snapshot = backend.snapshot(&HeadlessRequest::new(url, "<div id=\"root\"></div>"))?;
395
396 assert_eq!(
397 snapshot.accessibility.map(|tree| tree.text_content()),
398 Some("main: Application button: Search link: Docs".to_owned())
399 );
400 Ok(())
401 }
402
403 #[test]
404 fn timeout_errors_are_deterministic() -> Result<(), Box<dyn std::error::Error>> {
405 let url = IndexUrl::parse("https://example.com/slow")?;
406 let mut backend = FixtureHeadlessBackend::new();
407 let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Slow</main>");
408 fixture.render_time = std::time::Duration::from_millis(50);
409 backend.insert(url.clone(), fixture);
410 let mut request = HeadlessRequest::new(url, "<main></main>");
411 request.config.timeout = TimeoutPolicy::from_millis(10);
412
413 assert_eq!(
414 backend.snapshot(&request),
415 Err(HeadlessError::TimedOut { timeout_ms: 10 })
416 );
417 Ok(())
418 }
419
420 #[test]
421 fn denied_origin_returns_permission_error() -> Result<(), Box<dyn std::error::Error>> {
422 let url = IndexUrl::parse("https://example.com/app")?;
423 let mut backend = FixtureHeadlessBackend::new();
424 backend.insert(
425 url.clone(),
426 FixtureSnapshot::rendered(url.clone(), "<main>Denied</main>"),
427 );
428 backend.deny_origin(Origin::from_stored("https://example.com"));
429 let mut request = HeadlessRequest::new(url, "<main></main>");
430 request.config.network = NetworkPolicy::DenyExternal;
431
432 assert_eq!(
433 backend.snapshot(&request),
434 Err(HeadlessError::PermissionDenied {
435 origin: Origin::from_stored("https://example.com"),
436 policy: NetworkPolicy::DenyExternal
437 })
438 );
439 Ok(())
440 }
441
442 #[test]
443 fn sandbox_policy_must_remain_strict() -> Result<(), Box<dyn std::error::Error>> {
444 let url = IndexUrl::parse("https://example.com/app")?;
445 let backend = FixtureHeadlessBackend::new();
446 let mut request = HeadlessRequest::new(url, "<main></main>");
447 request.config = HeadlessConfig {
448 sandbox: SandboxPolicy {
449 enabled: false,
450 read_only_filesystem: true,
451 no_credentials: true,
452 },
453 ..HeadlessConfig::default()
454 };
455
456 assert_eq!(
457 backend.snapshot(&request),
458 Err(HeadlessError::SandboxRequired)
459 );
460 Ok(())
461 }
462
463 #[test]
464 fn network_policy_variants_enforce_expected_origin_rules()
465 -> Result<(), Box<dyn std::error::Error>> {
466 let url = IndexUrl::parse("https://example.com/app")?;
467 let external_origin = Origin::from_stored("https://cdn.example.net");
468 let mut backend = FixtureHeadlessBackend::new();
469 let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Network</main>");
470 fixture.requested_origins = vec![external_origin.clone()];
471 backend.insert(url.clone(), fixture);
472
473 let mut deny_all = HeadlessRequest::new(url.clone(), "<main></main>");
474 deny_all.config.network = NetworkPolicy::DenyAll;
475 assert_eq!(
476 backend.snapshot(&deny_all),
477 Err(HeadlessError::PermissionDenied {
478 origin: external_origin.clone(),
479 policy: NetworkPolicy::DenyAll,
480 })
481 );
482
483 let mut deny_external = HeadlessRequest::new(url.clone(), "<main></main>");
484 deny_external.config.network = NetworkPolicy::DenyExternal;
485 assert_eq!(
486 backend.snapshot(&deny_external),
487 Err(HeadlessError::PermissionDenied {
488 origin: external_origin.clone(),
489 policy: NetworkPolicy::DenyExternal,
490 })
491 );
492
493 let mut allow_all = HeadlessRequest::new(url, "<main></main>");
494 allow_all.config.network = NetworkPolicy::AllowAll;
495 let snapshot = backend.snapshot(&allow_all)?;
496 assert!(snapshot.dom_html.contains("Network"));
497 Ok(())
498 }
499
500 #[test]
501 fn explicit_deny_origin_blocks_allow_all_policy() -> Result<(), Box<dyn std::error::Error>> {
502 let url = IndexUrl::parse("https://example.com/app")?;
503 let denied_origin = Origin::from_stored("https://cdn.example.net");
504 let mut backend = FixtureHeadlessBackend::new();
505 let mut fixture = FixtureSnapshot::rendered(url.clone(), "<main>Denied</main>");
506 fixture.requested_origins = vec![denied_origin.clone()];
507 backend.insert(url.clone(), fixture);
508 backend.deny_origin(denied_origin.clone());
509
510 let mut request = HeadlessRequest::new(url, "<main></main>");
511 request.config.network = NetworkPolicy::AllowAll;
512 assert_eq!(
513 backend.snapshot(&request),
514 Err(HeadlessError::PermissionDenied {
515 origin: denied_origin,
516 policy: NetworkPolicy::AllowAll,
517 })
518 );
519 Ok(())
520 }
521
522 #[test]
523 fn missing_fixture_returns_snapshot_failed() -> Result<(), Box<dyn std::error::Error>> {
524 let request = HeadlessRequest::new(IndexUrl::parse("https://example.com/missing")?, "");
525 let backend = FixtureHeadlessBackend::new();
526 let result = backend.snapshot(&request);
527 assert!(matches!(
528 result,
529 Err(HeadlessError::SnapshotFailed(reason)) if reason.contains("no rendered fixture")
530 ));
531 Ok(())
532 }
533
534 #[test]
535 fn headless_error_display_variants_are_actionable() {
536 let timed_out = HeadlessError::TimedOut { timeout_ms: 250 }.to_string();
537 assert!(timed_out.contains("timed out"));
538 assert!(timed_out.contains("250ms"));
539
540 let denied = HeadlessError::PermissionDenied {
541 origin: Origin::from_stored("https://example.com"),
542 policy: NetworkPolicy::DenyAll,
543 }
544 .to_string();
545 assert!(denied.contains("denied"));
546 assert!(denied.contains("DenyAll"));
547
548 let sandbox = HeadlessError::SandboxRequired.to_string();
549 assert!(sandbox.contains("requires sandboxing"));
550
551 let failed = HeadlessError::SnapshotFailed("boom".to_owned()).to_string();
552 assert!(failed.contains("boom"));
553 }
554}