Skip to main content

pharos_runtime_static/
route_map.rs

1#![allow(clippy::result_unit_err)]
2
3use std::fs;
4use std::path::Path;
5
6use serde_json::Value;
7
8/// Performs manifest value.
9fn manifest_value(root: &Path) -> Result<Value, ()> {
10    let text = fs::read_to_string(root.join("pharos.app.json")).map_err(|_| ())?;
11    serde_json::from_str(&text).map_err(|_| ())
12}
13
14/// Performs manifest string.
15fn manifest_string<'a>(manifest: &'a Value, path: &[&str]) -> Option<&'a str> {
16    let mut value = manifest;
17    for key in path {
18        value = value.get(*key)?;
19    }
20    value.as_str().filter(|text| !text.is_empty())
21}
22
23/// Generates the bounded static route map for one built app artifact.
24///
25/// # Parameters
26///
27/// - `root`: Input consumed by [`crate::route_map::write_route_map`].
28///
29/// # Returns
30///
31/// Returns the successful value produced by [`crate::route_map::write_route_map`] or an error when the operation fails.
32///
33/// # Errors
34///
35/// Returns an error value when [`crate::route_map::write_route_map`] cannot complete successfully.
36///
37/// # Performance
38///
39/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::route_map::write_route_map`].
40///
41/// # Examples
42///
43/// ```rust,ignore
44/// let _result = pharos_runtime_static::route_map::write_route_map(std::path::Path::new("."));
45/// ```
46///
47/// # Related
48///
49/// - [`crate::route_map`] for adjacent helpers that share the same runtime surface.
50pub fn write_route_map(root: &Path) -> Result<(), ()> {
51    if root.as_os_str().is_empty() || !root.is_dir() {
52        return Err(());
53    }
54    let manifest = manifest_value(root)?;
55    let routes_path = root.join("data/contracts/routes.json");
56    let routes_text = fs::read_to_string(routes_path).map_err(|_| ())?;
57    let routes = serde_json::from_str::<Value>(&routes_text)
58        .map_err(|_| ())?
59        .get("routes")
60        .and_then(Value::as_array)
61        .cloned()
62        .ok_or(())?;
63    let host_profile = manifest_string(&manifest, &["runtime", "host_profile"]);
64    let mut lines = Vec::new();
65    for route in routes {
66        if route.get("kind").and_then(Value::as_str) != Some("page") {
67            continue;
68        }
69        let path = route.get("path").and_then(Value::as_str).ok_or(())?;
70        let Some(artifact) = route.get("artifact").and_then(Value::as_str) else {
71            if host_profile == Some("static-pages") {
72                return Err(());
73            }
74            continue;
75        };
76        let (artifact, content_type) = resolve_declared_page_artifact(root, artifact).ok_or(())?;
77        lines.push(format!("GET\t{path}\t{artifact}\t{content_type}\t200"));
78        if path == "/" && artifact == "index.html" {
79            lines.push(format!("GET\t/index.html\t{artifact}\t{content_type}\t200"));
80        }
81    }
82    append_html_subtree_routes(root, "html", 0, &mut lines)?;
83    if host_profile == Some("static-pages") && lines.is_empty() {
84        return Err(());
85    }
86    lines.sort();
87    let map_path = root.join("runtime-route-map.tsv");
88    pharos_runtime_core::fs::write_file_within(
89        root,
90        &map_path,
91        format!("{}\n", lines.join("\n")).as_bytes(),
92        0o644,
93    )
94    .map_err(|_| ())
95}
96
97fn append_html_subtree_routes(
98    root: &Path,
99    relative_dir: &str,
100    depth: usize,
101    lines: &mut Vec<String>,
102) -> Result<(), ()> {
103    if depth > 16 || relative_dir.is_empty() || relative_dir.contains("..") {
104        return Err(());
105    }
106    let dir = root.join(relative_dir);
107    if !dir.is_dir() {
108        return Ok(());
109    }
110    for entry in fs::read_dir(&dir).map_err(|_| ())? {
111        let entry = entry.map_err(|_| ())?;
112        let name = entry.file_name();
113        let name = name.to_str().ok_or(())?;
114        if name.starts_with('.') {
115            continue;
116        }
117        let child_relative = format!("{relative_dir}/{name}");
118        let metadata = entry.metadata().map_err(|_| ())?;
119        if metadata.is_dir() {
120            append_html_subtree_routes(root, &child_relative, depth + 1, lines)?;
121            continue;
122        }
123        if !metadata.is_file() {
124            continue;
125        }
126        let route_path = format!("/{child_relative}");
127        let content_type = content_type_for_artifact(&child_relative);
128        lines.push(format!(
129            "GET\t{route_path}\t{child_relative}\t{content_type}\t200"
130        ));
131    }
132    Ok(())
133}
134
135/// Resolves one explicitly declared page artifact under the app root.
136fn resolve_declared_page_artifact(root: &Path, artifact: &str) -> Option<(String, &'static str)> {
137    if artifact.is_empty() || Path::new(artifact).is_absolute() || artifact.contains("..") {
138        return None;
139    }
140    let path = root.join(artifact);
141    path.is_file()
142        .then(|| (artifact.to_owned(), "text/html; charset=utf-8"))
143}
144
145fn content_type_for_artifact(artifact: &str) -> &'static str {
146    if artifact.ends_with(".html") {
147        "text/html; charset=utf-8"
148    } else if artifact.ends_with(".css") {
149        "text/css; charset=utf-8"
150    } else if artifact.ends_with(".js") {
151        "application/javascript; charset=utf-8"
152    } else if artifact.ends_with(".svg") {
153        "image/svg+xml"
154    } else if artifact.ends_with(".png") {
155        "image/png"
156    } else if artifact.ends_with(".woff2") {
157        "font/woff2"
158    } else if artifact.ends_with(".woff") {
159        "font/woff"
160    } else {
161        "application/octet-stream"
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::write_route_map;
168
169    fn root(label: &str) -> std::path::PathBuf {
170        let nonce = std::time::SystemTime::now()
171            .duration_since(std::time::UNIX_EPOCH)
172            .expect("clock")
173            .as_nanos();
174        std::env::temp_dir().join(format!(
175            "pharos-route-map-{label}-{}-{nonce}",
176            std::process::id()
177        ))
178    }
179
180    #[test]
181    /// Emits a concrete /index.html alias for the mounted root page artifact.
182    fn write_route_map_includes_root_index_alias() {
183        let root = root("index-alias");
184        std::fs::create_dir_all(root.join("data/contracts")).expect("contracts dir");
185        std::fs::write(root.join("index.html"), "<html></html>").expect("index");
186        std::fs::write(
187            root.join("pharos.app.json"),
188            r#"{"runtime":{"host_profile":"static-pages"}}"#,
189        )
190        .expect("manifest");
191        std::fs::write(
192            root.join("data/contracts/routes.json"),
193            r#"{"routes":[{"kind":"page","path":"/","artifact":"index.html"}]}"#,
194        )
195        .expect("routes");
196        write_route_map(&root).expect("write route map");
197        let text = std::fs::read_to_string(root.join("runtime-route-map.tsv")).expect("route map");
198        assert!(text.contains("GET\t/\tindex.html\ttext/html; charset=utf-8\t200"));
199        assert!(text.contains("GET\t/index.html\tindex.html\ttext/html; charset=utf-8\t200"));
200        std::fs::remove_dir_all(root).expect("cleanup");
201    }
202}