Skip to main content

index_headless/
lib.rs

1//! Headless snapshot fallback abstractions.
2//!
3//! This crate does not embed a browser. It defines the deterministic boundary
4//! that a future browser-backed implementation must satisfy.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::{Display, Formatter};
8use std::time::Duration;
9
10use index_core::{IndexUrl, Origin};
11
12/// Request sent to a headless backend.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct HeadlessRequest {
15    /// Target URL.
16    pub url: IndexUrl,
17    /// Static HTML already fetched by Index.
18    pub static_html: String,
19    /// Rendering configuration.
20    pub config: HeadlessConfig,
21}
22
23impl HeadlessRequest {
24    /// Creates a request with default fallback policy.
25    #[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/// Headless fallback configuration.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HeadlessConfig {
38    /// Maximum time allowed for rendering.
39    pub timeout: TimeoutPolicy,
40    /// Script execution policy.
41    pub scripts: ScriptPolicy,
42    /// Network expansion policy.
43    pub network: NetworkPolicy,
44    /// Sandbox policy.
45    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/// Timeout policy.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct TimeoutPolicy {
62    /// Maximum render duration.
63    pub max_render_time: Duration,
64}
65
66impl TimeoutPolicy {
67    /// Creates a timeout policy from milliseconds.
68    #[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/// Script execution policy.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ScriptPolicy {
85    /// Do not execute page scripts.
86    Disabled,
87    /// Execute scripts inside the sandbox.
88    Enabled,
89}
90
91/// Network permission policy for rendered pages.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum NetworkPolicy {
94    /// No network access while rendering.
95    DenyAll,
96    /// Allow only same-origin requests.
97    DenyExternal,
98    /// Allow all network requests.
99    AllowAll,
100}
101
102/// Sandbox policy for headless execution.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SandboxPolicy {
105    /// Whether sandboxing is required.
106    pub enabled: bool,
107    /// Whether local filesystem writes are denied.
108    pub read_only_filesystem: bool,
109    /// Whether credentials are withheld from the browser context.
110    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/// Rendered snapshot emitted by a headless backend.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct HeadlessSnapshot {
126    /// Final URL for the snapshot.
127    pub final_url: IndexUrl,
128    /// Rendered DOM HTML.
129    pub dom_html: String,
130    /// Accessibility tree when available.
131    pub accessibility: Option<AccessibilitySnapshot>,
132}
133
134/// Accessibility snapshot.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct AccessibilitySnapshot {
137    /// Root accessibility nodes.
138    pub nodes: Vec<AccessibilityNode>,
139}
140
141impl AccessibilitySnapshot {
142    /// Extracts readable text in deterministic tree order.
143    #[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/// Accessibility tree node.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct AccessibilityNode {
156    /// Role such as heading, link, or button.
157    pub role: String,
158    /// Accessible name.
159    pub name: String,
160    /// Child nodes.
161    pub children: Vec<AccessibilityNode>,
162}
163
164impl AccessibilityNode {
165    /// Creates a leaf accessibility node.
166    #[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
176/// Headless backend abstraction.
177pub trait HeadlessBackend {
178    /// Renders a deterministic snapshot.
179    fn snapshot(&self, request: &HeadlessRequest) -> Result<HeadlessSnapshot, HeadlessError>;
180}
181
182/// Headless fallback errors.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum HeadlessError {
185    /// Rendering exceeded the configured timeout.
186    TimedOut {
187        /// Timeout in milliseconds.
188        timeout_ms: u128,
189    },
190    /// A requested origin was denied by policy.
191    PermissionDenied {
192        /// Denied origin.
193        origin: Origin,
194        /// Policy that denied the request.
195        policy: NetworkPolicy,
196    },
197    /// Sandbox policy was not strong enough.
198    SandboxRequired,
199    /// The backend could not produce a snapshot.
200    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/// Deterministic fixture backend for tests and future harnesses.
224#[derive(Debug, Clone, Default)]
225pub struct FixtureHeadlessBackend {
226    rendered: BTreeMap<String, FixtureSnapshot>,
227    denied_origins: BTreeSet<Origin>,
228}
229
230impl FixtureHeadlessBackend {
231    /// Creates an empty fixture backend.
232    #[must_use]
233    pub fn new() -> Self {
234        Self::default()
235    }
236
237    /// Registers a rendered DOM snapshot.
238    pub fn insert(&mut self, url: IndexUrl, snapshot: FixtureSnapshot) {
239        self.rendered.insert(url.as_str().to_owned(), snapshot);
240    }
241
242    /// Denies an origin.
243    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/// Fixture snapshot used by the deterministic backend.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct FixtureSnapshot {
274    /// Final URL.
275    pub final_url: IndexUrl,
276    /// Rendered DOM HTML.
277    pub dom_html: String,
278    /// Accessibility tree.
279    pub accessibility: Option<AccessibilitySnapshot>,
280    /// Simulated render duration.
281    pub render_time: Duration,
282    /// Origins requested while rendering.
283    pub requested_origins: Vec<Origin>,
284}
285
286impl FixtureSnapshot {
287    /// Creates a fast DOM snapshot fixture.
288    #[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}