pharos_runtime_static/
route_map.rs1#![allow(clippy::result_unit_err)]
2
3use std::fs;
4use std::path::Path;
5
6use serde_json::Value;
7
8fn 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
14fn 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
23pub 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
135fn 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 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}