oxide_browser/
manifest.rs1use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21
22use serde::Deserialize;
23
24use crate::permissions::PermissionKind;
25use crate::url::OxideUrl;
26
27const MAX_MANIFEST_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Deserialize)]
32pub struct AppManifest {
33 pub name: String,
35 #[serde(default)]
37 pub description: String,
38 #[serde(default)]
40 pub version: String,
41 #[serde(default)]
44 pub permissions: Vec<String>,
45}
46
47impl AppManifest {
48 pub fn parse(text: &str) -> Result<Self, String> {
50 toml::from_str(text).map_err(|e| e.to_string())
51 }
52
53 pub fn allows(&self, kind: PermissionKind) -> bool {
55 self.permissions.iter().any(|p| p == kind.name())
56 }
57}
58
59pub type SharedManifest = Arc<Mutex<Option<AppManifest>>>;
61
62pub fn manifest_allows(manifest: &SharedManifest, kind: PermissionKind) -> bool {
67 match manifest.lock().unwrap().as_ref() {
68 Some(m) => m.allows(kind),
69 None => true,
70 }
71}
72
73pub fn manifest_url_for(wasm_url: &OxideUrl) -> Option<String> {
79 let url = wasm_url.as_str();
80 let without_fragment = url.split('#').next().unwrap_or(url);
81 let (path, query) = match without_fragment.split_once('?') {
82 Some((path, query)) => (path, Some(query)),
83 None => (without_fragment, None),
84 };
85 path.strip_suffix(".wasm").map(|base| match query {
86 Some(query) => format!("{base}.toml?{query}"),
87 None => format!("{base}.toml"),
88 })
89}
90
91pub fn manifest_path_for(wasm_path: &Path) -> Option<PathBuf> {
93 if wasm_path.extension().and_then(|e| e.to_str()) == Some("wasm") {
94 Some(wasm_path.with_extension("toml"))
95 } else {
96 None
97 }
98}
99
100pub fn load_local_manifest(wasm_path: &Path) -> Result<Option<AppManifest>, String> {
104 let Some(path) = manifest_path_for(wasm_path) else {
105 return Ok(None);
106 };
107 if !path.exists() {
108 return Ok(None);
109 }
110 let text = std::fs::read_to_string(&path)
111 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
112 AppManifest::parse(&text)
113 .map(Some)
114 .map_err(|e| format!("invalid manifest {}: {e}", path.display()))
115}
116
117pub async fn fetch_manifest(wasm_url: &OxideUrl) -> Result<Option<AppManifest>, String> {
122 if wasm_url.is_local_file() {
123 let Some(path) = wasm_url.to_file_path() else {
124 return Ok(None);
125 };
126 return load_local_manifest(&path);
127 }
128
129 if !wasm_url.is_fetchable() {
130 return Ok(None);
131 }
132 let Some(url) = manifest_url_for(wasm_url) else {
133 return Ok(None);
134 };
135
136 let client = reqwest::Client::builder()
137 .timeout(std::time::Duration::from_secs(10))
138 .build()
139 .map_err(|e| format!("failed to build HTTP client: {e}"))?;
140
141 let mut response = match client.get(&url).send().await {
142 Ok(r) => r,
143 Err(_) => return Ok(None),
145 };
146 if !response.status().is_success() {
147 return Ok(None);
148 }
149 if response
153 .content_length()
154 .is_some_and(|len| usize::try_from(len).map_or(true, |len| len > MAX_MANIFEST_SIZE))
155 {
156 return Err(format!(
157 "manifest too large (Content-Length exceeds limit {MAX_MANIFEST_SIZE})"
158 ));
159 }
160 let mut bytes = Vec::new();
161 loop {
162 match response.chunk().await {
163 Ok(Some(chunk)) => {
164 if bytes.len() + chunk.len() > MAX_MANIFEST_SIZE {
165 return Err(format!("manifest too large (limit {MAX_MANIFEST_SIZE})"));
166 }
167 bytes.extend_from_slice(&chunk);
168 }
169 Ok(None) => break,
170 Err(e) => return Err(format!("failed to read manifest body: {e}")),
171 }
172 }
173 let text =
174 String::from_utf8(bytes).map_err(|_| format!("manifest at {url} is not valid UTF-8"))?;
175 AppManifest::parse(&text)
176 .map(Some)
177 .map_err(|e| format!("invalid manifest at {url}: {e}"))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn parses_full_manifest() {
186 let m = AppManifest::parse(
187 r#"
188name = "Demo"
189description = "A demo app"
190version = "1.2.3"
191permissions = ["camera", "geolocation"]
192"#,
193 )
194 .unwrap();
195 assert_eq!(m.name, "Demo");
196 assert_eq!(m.description, "A demo app");
197 assert_eq!(m.version, "1.2.3");
198 assert!(m.allows(PermissionKind::Camera));
199 assert!(m.allows(PermissionKind::Geolocation));
200 assert!(!m.allows(PermissionKind::Microphone));
201 assert!(!m.allows(PermissionKind::ScreenCapture));
202 }
203
204 #[test]
205 fn name_is_required() {
206 assert!(AppManifest::parse("version = \"1.0\"").is_err());
207 }
208
209 #[test]
210 fn defaults_for_optional_fields() {
211 let m = AppManifest::parse("name = \"Min\"").unwrap();
212 assert_eq!(m.description, "");
213 assert_eq!(m.version, "");
214 assert!(m.permissions.is_empty());
215 assert!(!m.allows(PermissionKind::Camera));
216 }
217
218 #[test]
219 fn manifest_url_swaps_extension() {
220 let url = OxideUrl::parse("https://example.com/apps/demo.wasm").unwrap();
221 assert_eq!(
222 manifest_url_for(&url).as_deref(),
223 Some("https://example.com/apps/demo.toml")
224 );
225 let not_wasm = OxideUrl::parse("https://example.com/apps/demo").unwrap();
226 assert_eq!(manifest_url_for(¬_wasm), None);
227 }
228
229 #[test]
230 fn manifest_url_preserves_query() {
231 let url = OxideUrl::parse("https://example.com/apps/demo.wasm?token=abc&v=2").unwrap();
232 assert_eq!(
233 manifest_url_for(&url).as_deref(),
234 Some("https://example.com/apps/demo.toml?token=abc&v=2")
235 );
236 }
237
238 #[test]
239 fn manifest_path_is_sibling_toml() {
240 assert_eq!(
241 manifest_path_for(Path::new("/tmp/app.wasm")),
242 Some(PathBuf::from("/tmp/app.toml"))
243 );
244 assert_eq!(manifest_path_for(Path::new("/tmp/app.txt")), None);
245 }
246
247 #[test]
248 fn no_manifest_allows_everything() {
249 let shared: SharedManifest = Arc::new(Mutex::new(None));
250 assert!(manifest_allows(&shared, PermissionKind::Camera));
251 }
252
253 #[test]
254 fn manifest_denies_undeclared() {
255 let m = AppManifest::parse("name = \"x\"\npermissions = [\"microphone\"]").unwrap();
256 let shared: SharedManifest = Arc::new(Mutex::new(Some(m)));
257 assert!(manifest_allows(&shared, PermissionKind::Microphone));
258 assert!(!manifest_allows(&shared, PermissionKind::Camera));
259 }
260
261 #[test]
262 fn load_local_manifest_roundtrip() {
263 let dir = tempfile::tempdir().unwrap();
264 let wasm = dir.path().join("app.wasm");
265 std::fs::write(dir.path().join("app.toml"), "name = \"Local\"").unwrap();
266 let m = load_local_manifest(&wasm).unwrap().unwrap();
267 assert_eq!(m.name, "Local");
268
269 let no_manifest = dir.path().join("other.wasm");
270 assert!(load_local_manifest(&no_manifest).unwrap().is_none());
271
272 std::fs::write(dir.path().join("bad.toml"), "name = [broken").unwrap();
273 assert!(load_local_manifest(&dir.path().join("bad.wasm")).is_err());
274 }
275}