1use std::{borrow::Cow, path::PathBuf};
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct UiAsset {
23 pub contents: Cow<'static, [u8]>,
24 pub content_type: &'static str,
25 pub cache_control: &'static str,
26}
27
28pub trait ConsoleAssetProvider: Send + Sync {
29 fn index(&self) -> Option<UiAsset>;
30 fn asset(&self, path: &str) -> Option<UiAsset>;
31}
32
33#[derive(Clone, Copy, Debug, Default)]
34pub struct EmbeddedConsoleAssets;
35
36#[derive(Clone, Debug)]
37pub struct FileSystemConsoleAssets {
38 root: PathBuf,
39}
40
41impl FileSystemConsoleAssets {
42 pub fn new(root: impl Into<PathBuf>) -> Self {
43 Self { root: root.into() }
44 }
45}
46
47#[cfg(feature = "embed-assets")]
48mod embedded {
49 use include_dir::{Dir, include_dir};
50
51 pub(super) static CONSOLE_DIST: Dir<'_> = include_dir!("$MESH_LLM_UI_DIST");
52}
53
54pub fn index() -> Option<UiAsset> {
55 EmbeddedConsoleAssets.index()
56}
57
58pub fn asset(path: &str) -> Option<UiAsset> {
59 EmbeddedConsoleAssets.asset(path)
60}
61
62impl ConsoleAssetProvider for EmbeddedConsoleAssets {
63 fn index(&self) -> Option<UiAsset> {
64 self.asset("index.html").map(|mut asset| {
65 asset.cache_control = "public, max-age=3600";
66 asset
67 })
68 }
69
70 #[cfg(feature = "embed-assets")]
71 fn asset(&self, path: &str) -> Option<UiAsset> {
72 let rel = clean_relative_path(path)?;
73 let file = embedded::CONSOLE_DIST.get_file(rel)?;
74 Some(UiAsset {
75 contents: Cow::Borrowed(file.contents()),
76 content_type: content_type(rel),
77 cache_control: cache_control(rel),
78 })
79 }
80
81 #[cfg(not(feature = "embed-assets"))]
82 fn asset(&self, _path: &str) -> Option<UiAsset> {
83 None
84 }
85}
86
87impl ConsoleAssetProvider for FileSystemConsoleAssets {
88 fn index(&self) -> Option<UiAsset> {
89 self.asset("index.html").map(|mut asset| {
90 asset.cache_control = "public, max-age=3600";
91 asset
92 })
93 }
94
95 fn asset(&self, path: &str) -> Option<UiAsset> {
96 let rel = clean_relative_path(path)?;
97 let root = self.root.canonicalize().ok()?;
98 let full_path = root.join(rel).canonicalize().ok()?;
99 if !full_path.starts_with(&root) {
100 return None;
101 }
102 let contents = std::fs::read(full_path).ok()?;
103 Some(UiAsset {
104 contents: Cow::Owned(contents),
105 content_type: content_type(rel),
106 cache_control: cache_control(rel),
107 })
108 }
109}
110
111fn clean_relative_path(path: &str) -> Option<&str> {
112 let rel = path.trim_start_matches('/');
113 if rel.is_empty() || rel.contains("..") || rel.starts_with('.') {
114 return None;
115 }
116 Some(rel)
117}
118
119pub fn content_type(path: &str) -> &'static str {
120 match path.rsplit('.').next().unwrap_or("") {
121 "html" => "text/html; charset=utf-8",
122 "js" | "mjs" => "text/javascript; charset=utf-8",
123 "css" => "text/css; charset=utf-8",
124 "svg" => "image/svg+xml",
125 "json" => "application/json; charset=utf-8",
126 "png" => "image/png",
127 "jpg" | "jpeg" => "image/jpeg",
128 "webp" => "image/webp",
129 "woff2" => "font/woff2",
130 "wasm" => "application/wasm",
131 _ => "application/octet-stream",
132 }
133}
134
135pub fn cache_control(path: &str) -> &'static str {
136 if path.starts_with("assets/") {
137 "public, max-age=31536000, immutable"
138 } else {
139 "public, max-age=3600"
140 }
141}
142
143#[cfg(all(test, feature = "embed-assets"))]
144mod tests {
145 use super::{asset, content_type};
146
147 #[test]
148 fn rejects_parent_directory_paths() {
149 assert!(asset("../index.html").is_none());
150 }
151
152 #[test]
153 fn maps_common_asset_content_types() {
154 assert_eq!(content_type("index.html"), "text/html; charset=utf-8");
155 assert_eq!(
156 content_type("assets/app.js"),
157 "text/javascript; charset=utf-8"
158 );
159 assert_eq!(content_type("assets/app.css"), "text/css; charset=utf-8");
160 assert_eq!(
161 content_type("manifest.json"),
162 "application/json; charset=utf-8"
163 );
164 }
165}
166
167#[cfg(test)]
168mod filesystem_tests {
169 use super::{ConsoleAssetProvider, FileSystemConsoleAssets};
170 use std::fs;
171
172 #[test]
173 fn filesystem_assets_read_from_root() {
174 let root = std::env::temp_dir().join(format!("mesh-llm-ui-assets-{}", std::process::id()));
175 let _ = fs::remove_dir_all(&root);
176 fs::create_dir_all(root.join("assets")).expect("create temp asset root");
177 fs::write(root.join("index.html"), "<html></html>").expect("write index");
178 fs::write(root.join("assets/app.js"), "console.log('ok')").expect("write js");
179
180 let assets = FileSystemConsoleAssets::new(&root);
181 assert_eq!(
182 assets.index().expect("index").contents.as_ref(),
183 b"<html></html>"
184 );
185 assert_eq!(
186 assets.asset("/assets/app.js").expect("asset").content_type,
187 "text/javascript; charset=utf-8"
188 );
189 assert!(assets.asset("../secret").is_none());
190
191 let _ = fs::remove_dir_all(root);
192 }
193
194 #[cfg(unix)]
195 #[test]
196 fn filesystem_assets_reject_symlinks_outside_root() {
197 let temp =
198 std::env::temp_dir().join(format!("mesh-llm-ui-assets-symlink-{}", std::process::id()));
199 let root = temp.join("root");
200 let secret = temp.join("secret.txt");
201 let _ = fs::remove_dir_all(&temp);
202 fs::create_dir_all(root.join("assets")).expect("create temp asset root");
203 fs::write(root.join("index.html"), "<html></html>").expect("write index");
204 fs::write(&secret, "secret").expect("write secret");
205 std::os::unix::fs::symlink(&secret, root.join("assets/secret.txt"))
206 .expect("create symlink");
207
208 let assets = FileSystemConsoleAssets::new(&root);
209 assert!(assets.asset("/assets/secret.txt").is_none());
210
211 let _ = fs::remove_dir_all(temp);
212 }
213}
214
215#[cfg(all(test, not(feature = "embed-assets")))]
216mod stub_tests {
217 use super::{asset, index};
218
219 #[test]
220 fn returns_none_when_assets_not_embedded() {
221 assert!(index().is_none());
222 assert!(asset("index.html").is_none());
223 assert!(asset("assets/app.js").is_none());
224 }
225}