Skip to main content

oxide_browser/
manifest.rs

1//! App manifests: optional TOML metadata shipped next to a `.wasm` module.
2//!
3//! For a module at `https://host/apps/app.wasm` the browser looks for
4//! `https://host/apps/app.toml` (same URL, `.wasm` → `.toml`). The manifest is optional —
5//! apps without one keep the legacy behavior (no metadata, every sensitive API may prompt).
6//!
7//! ```toml
8//! name = "Media Capture Demo"
9//! description = "Camera preview, mic meter, and screenshots"
10//! version = "0.1.0"
11//! permissions = ["camera", "microphone", "screen-capture"]
12//! ```
13//!
14//! When a manifest is present it acts as a capability declaration: sensitive APIs **not**
15//! listed in `permissions` are denied without prompting (the prompt is only shown for
16//! declared permissions). Valid permission names are the [`PermissionKind::name`] values:
17//! `camera`, `microphone`, `geolocation`, `screen-capture`.
18
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21
22use serde::Deserialize;
23
24use crate::permissions::PermissionKind;
25use crate::url::OxideUrl;
26
27/// Maximum size of a manifest fetched over the network.
28const MAX_MANIFEST_SIZE: usize = 64 * 1024; // 64 KiB
29
30/// Parsed app manifest. All fields except `name` are optional in the TOML.
31#[derive(Debug, Clone, Deserialize)]
32pub struct AppManifest {
33    /// Human-readable app name, shown as the tab title.
34    pub name: String,
35    /// Short description of the app.
36    #[serde(default)]
37    pub description: String,
38    /// App version string (informational).
39    #[serde(default)]
40    pub version: String,
41    /// Sensitive capabilities the app may request (see [`PermissionKind::name`]).
42    /// Anything not listed here is denied without a prompt.
43    #[serde(default)]
44    pub permissions: Vec<String>,
45}
46
47impl AppManifest {
48    /// Parse a TOML manifest.
49    pub fn parse(text: &str) -> Result<Self, String> {
50        toml::from_str(text).map_err(|e| e.to_string())
51    }
52
53    /// Whether the manifest declares `kind` in its `permissions` list.
54    pub fn allows(&self, kind: PermissionKind) -> bool {
55        self.permissions.iter().any(|p| p == kind.name())
56    }
57}
58
59/// Shared handle stored in `HostState`; `None` when the current app has no manifest.
60pub type SharedManifest = Arc<Mutex<Option<AppManifest>>>;
61
62/// Whether the current app may request `kind`.
63///
64/// `None` (no manifest) keeps the legacy prompt-on-first-use behavior; a present manifest
65/// must declare the permission explicitly.
66pub 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
73/// Manifest URL for a `.wasm` module URL (`…/app.wasm` → `…/app.toml`).
74///
75/// The query string is preserved so signed or versioned module URLs
76/// (`app.wasm?token=…`) keep working for the sibling manifest; the fragment is dropped
77/// (never sent to the server). Returns `None` when the path doesn't end in `.wasm`.
78pub 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
91/// Sibling manifest path for a local `.wasm` file path.
92pub 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
100/// Loads the sibling manifest for a local `.wasm` file.
101///
102/// Returns `Ok(None)` when no manifest exists, `Err` when one exists but is invalid.
103pub 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
117/// Fetches the manifest for a module URL (HTTP/HTTPS or `file://`).
118///
119/// Returns `Ok(None)` when the app has no manifest (missing file, HTTP 404, non-`.wasm`
120/// URL), `Err` with a human-readable message when a manifest exists but cannot be parsed.
121pub 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        // Network failure fetching an *optional* file: treat as absent.
144        Err(_) => return Ok(None),
145    };
146    if !response.status().is_success() {
147        return Ok(None);
148    }
149    // Enforce the size cap *while* downloading: reject a too-large Content-Length up
150    // front, then stream chunks with a running byte budget so a missing or lying header
151    // can't make the host buffer an arbitrarily large body.
152    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(&not_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}