Skip to main content

pharos_runtime_static/
navigation.rs

1//! Canonical browser navigation for mounted app roots.
2
3use pharos_runtime_core::http::HttpRequest;
4
5/// Redirects a bare app mount to its directory URL so relative assets resolve inside the app.
6pub fn mount_redirect(request: &HttpRequest, mount: &str) -> Option<Vec<u8>> {
7    if !matches!(request.method.as_str(), "GET" | "HEAD")
8        || !mount.starts_with('/')
9        || mount.starts_with("//")
10        || mount.len() > 1024
11        || !mount
12            .bytes()
13            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.'))
14        || request.target.bytes().any(|byte| byte.is_ascii_control())
15    {
16        return None;
17    }
18    let mount = mount.trim_end_matches('/');
19    let (path, query) = request
20        .target
21        .split_once('?')
22        .map_or((request.target.as_str(), None), |(path, query)| {
23            (path, Some(query))
24        });
25    if mount.is_empty() || path != mount {
26        return None;
27    }
28    let location = query.map_or_else(|| format!("{mount}/"), |query| format!("{mount}/?{query}"));
29    Some(format!("HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n").into_bytes())
30}
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35
36    /// Preserves query strings and methods while rejecting unsafe redirect headers.
37    #[test]
38    fn canonicalizes_only_bare_navigation_mounts() {
39        let mut request = HttpRequest {
40            method: "GET".into(),
41            target: "/dev_docs?search=database".into(),
42            ..Default::default()
43        };
44        let response = String::from_utf8(mount_redirect(&request, "/dev_docs").unwrap()).unwrap();
45        assert!(response.contains("Location: /dev_docs/?search=database\r\n"));
46        request.target = "/dev_docs/".into();
47        assert!(mount_redirect(&request, "/dev_docs").is_none());
48        request.target = "/dev_docs".into();
49        request.method = "POST".into();
50        assert!(mount_redirect(&request, "/dev_docs").is_none());
51        request.method = "GET".into();
52        request.target = "/dev_docs?x=\r\nInjected: true".into();
53        assert!(mount_redirect(&request, "/dev_docs").is_none());
54    }
55}