Skip to main content

victauri_browser/
installer.rs

1use std::path::PathBuf;
2
3const HOST_NAME: &str = "com.victauri.browser";
4
5/// Platform-specific native messaging host manifest location.
6///
7/// # Errors
8///
9/// Returns an error if the home directory cannot be determined.
10pub fn host_manifest_path() -> Result<PathBuf, InstallerError> {
11    let home = home_dir()?;
12
13    #[cfg(target_os = "windows")]
14    {
15        Ok(home.join(".victauri").join("native-host-manifest.json"))
16    }
17
18    #[cfg(target_os = "macos")]
19    {
20        Ok(home
21            .join("Library")
22            .join("Application Support")
23            .join("Google")
24            .join("Chrome")
25            .join("NativeMessagingHosts")
26            .join(format!("{HOST_NAME}.json")))
27    }
28
29    #[cfg(target_os = "linux")]
30    {
31        Ok(home
32            .join(".config")
33            .join("google-chrome")
34            .join("NativeMessagingHosts")
35            .join(format!("{HOST_NAME}.json")))
36    }
37}
38
39/// Generate the native messaging host manifest JSON.
40#[must_use]
41pub fn host_manifest(binary_path: &str, extension_id: &str) -> serde_json::Value {
42    serde_json::json!({
43        "name": HOST_NAME,
44        "description": "Victauri Browser — MCP inspection for web pages",
45        "path": binary_path,
46        "type": "stdio",
47        "allowed_origins": [
48            format!("chrome-extension://{extension_id}/")
49        ]
50    })
51}
52
53/// Directory where the native host binary should be installed.
54///
55/// # Errors
56///
57/// Returns an error if the home directory cannot be determined.
58#[allow(dead_code)]
59pub fn install_dir() -> Result<PathBuf, InstallerError> {
60    let home = home_dir()?;
61    Ok(home.join(".victauri").join("bin"))
62}
63
64/// Install the native messaging host manifest for all supported Chromium browsers.
65///
66/// Writes the manifest JSON to Chrome, Edge, and Brave locations.
67/// On Windows, also creates registry keys for all browsers.
68///
69/// # Errors
70///
71/// Returns an error if file I/O or registry operations fail.
72pub fn install(binary_path: &str, extension_id: &str) -> Result<String, InstallerError> {
73    if !is_valid_extension_id(extension_id) {
74        return Err(InstallerError::InvalidExtensionId(extension_id.to_string()));
75    }
76    let manifest = host_manifest(binary_path, extension_id);
77    let json = serde_json::to_string_pretty(&manifest).map_err(InstallerError::Json)?;
78
79    let primary_path = host_manifest_path()?;
80
81    for path in all_manifest_paths()? {
82        if let Some(parent) = path.parent() {
83            let _ = std::fs::create_dir_all(parent);
84        }
85        let _ = std::fs::write(&path, &json);
86    }
87
88    #[cfg(target_os = "windows")]
89    {
90        register_windows_host(&primary_path)?;
91    }
92
93    Ok(primary_path.to_string_lossy().to_string())
94}
95
96fn all_manifest_paths() -> Result<Vec<PathBuf>, InstallerError> {
97    let home = home_dir()?;
98    let mut paths = vec![];
99
100    #[cfg(target_os = "windows")]
101    {
102        paths.push(home.join(".victauri").join("native-host-manifest.json"));
103    }
104
105    #[cfg(target_os = "macos")]
106    {
107        let app_support = home.join("Library").join("Application Support");
108        let manifest_file = format!("{HOST_NAME}.json");
109        for browser_dir in [
110            "Google/Chrome",
111            "Microsoft Edge",
112            "BraveSoftware/Brave-Browser",
113            "Arc/User Data",
114        ] {
115            paths.push(
116                app_support
117                    .join(browser_dir)
118                    .join("NativeMessagingHosts")
119                    .join(&manifest_file),
120            );
121        }
122    }
123
124    #[cfg(target_os = "linux")]
125    {
126        let manifest_file = format!("{HOST_NAME}.json");
127        let config = home.join(".config");
128        for browser_dir in [
129            "google-chrome",
130            "microsoft-edge",
131            "BraveSoftware/Brave-Browser",
132            "chromium",
133        ] {
134            paths.push(
135                config
136                    .join(browser_dir)
137                    .join("NativeMessagingHosts")
138                    .join(&manifest_file),
139            );
140        }
141    }
142
143    Ok(paths)
144}
145
146/// Uninstall the native messaging host manifest.
147///
148/// # Errors
149///
150/// Returns an error if file I/O or registry operations fail.
151pub fn uninstall() -> Result<(), InstallerError> {
152    let manifest_path = host_manifest_path()?;
153    if manifest_path.exists() {
154        std::fs::remove_file(&manifest_path).map_err(InstallerError::Io)?;
155    }
156
157    #[cfg(target_os = "windows")]
158    {
159        unregister_windows_host();
160    }
161
162    Ok(())
163}
164
165#[cfg(target_os = "windows")]
166const WINDOWS_REGISTRY_PATHS: &[&str] = &[
167    r"HKCU\Software\Google\Chrome\NativeMessagingHosts",
168    r"HKCU\Software\Microsoft\Edge\NativeMessagingHosts",
169    r"HKCU\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts",
170];
171
172#[cfg(target_os = "windows")]
173fn register_windows_host(manifest_path: &std::path::Path) -> Result<(), InstallerError> {
174    use std::process::Command;
175
176    let value = manifest_path.to_string_lossy();
177
178    for base_key in WINDOWS_REGISTRY_PATHS {
179        let key = format!(r"{base_key}\{HOST_NAME}");
180        let _ = Command::new("reg")
181            .args(["add", &key, "/ve", "/t", "REG_SZ", "/d", &value, "/f"])
182            .output();
183    }
184
185    Ok(())
186}
187
188#[cfg(target_os = "windows")]
189fn unregister_windows_host() {
190    use std::process::Command;
191
192    for base_key in WINDOWS_REGISTRY_PATHS {
193        let key = format!(r"{base_key}\{HOST_NAME}");
194        let _ = Command::new("reg").args(["delete", &key, "/f"]).output();
195    }
196}
197
198fn home_dir() -> Result<PathBuf, InstallerError> {
199    #[cfg(target_os = "windows")]
200    {
201        std::env::var("USERPROFILE")
202            .map(PathBuf::from)
203            .map_err(|_| InstallerError::NoHomeDir)
204    }
205
206    #[cfg(not(target_os = "windows"))]
207    {
208        std::env::var("HOME")
209            .map(PathBuf::from)
210            .map_err(|_| InstallerError::NoHomeDir)
211    }
212}
213
214#[derive(Debug, thiserror::Error)]
215pub enum InstallerError {
216    #[error("cannot determine home directory")]
217    NoHomeDir,
218
219    #[error("IO error: {0}")]
220    Io(#[from] std::io::Error),
221
222    #[error("JSON error: {0}")]
223    Json(#[from] serde_json::Error),
224
225    #[error("invalid Chrome extension id: must be 32 chars a-p, got {0:?}")]
226    InvalidExtensionId(String),
227}
228
229/// Chrome/Chromium extension IDs are exactly 32 characters, each in `a`–`p`
230/// (a base-16 mapping of the key hash). Validating this before embedding the id in
231/// a native-host manifest's `allowed_origins` prevents a malformed/placeholder id
232/// (e.g. the literal `"EXTENSION_ID"`) from being registered (audit #13).
233#[must_use]
234pub fn is_valid_extension_id(id: &str) -> bool {
235    id.len() == 32 && id.bytes().all(|b| (b'a'..=b'p').contains(&b))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn manifest_has_correct_name() {
244        let manifest = host_manifest("/usr/local/bin/victauri-browser-host", "abcdef123456");
245        assert_eq!(manifest["name"], HOST_NAME);
246        assert_eq!(manifest["type"], "stdio");
247    }
248
249    #[test]
250    fn extension_id_validation() {
251        // Valid: 32 chars, all a-p.
252        assert!(is_valid_extension_id("abcdefghijklmnopabcdefghijklmnop"));
253        // Invalid: placeholder, wrong length, out-of-range chars.
254        assert!(!is_valid_extension_id("EXTENSION_ID"));
255        assert!(!is_valid_extension_id("abcdef123456"));
256        assert!(!is_valid_extension_id("abcdefghijklmnopabcdefghijklmno")); // 31
257        assert!(!is_valid_extension_id("abcdefghijklmnopabcdefghijklmnqz")); // q,z out of range
258        assert!(!is_valid_extension_id(""));
259    }
260
261    #[test]
262    fn install_rejects_invalid_extension_id() {
263        let err = install("/tmp/victauri-browser-host", "EXTENSION_ID").unwrap_err();
264        assert!(matches!(err, InstallerError::InvalidExtensionId(_)));
265    }
266
267    #[test]
268    fn manifest_has_allowed_origin() {
269        let manifest = host_manifest("/path/to/binary", "test_extension_id");
270        let origins = manifest["allowed_origins"].as_array().unwrap();
271        assert_eq!(origins.len(), 1);
272        assert!(origins[0].as_str().unwrap().contains("test_extension_id"));
273    }
274
275    #[test]
276    fn manifest_path_is_deterministic() {
277        let p1 = host_manifest_path();
278        let p2 = host_manifest_path();
279        assert!(p1.is_ok());
280        assert_eq!(p1.unwrap(), p2.unwrap());
281    }
282
283    #[test]
284    fn install_dir_is_in_home() {
285        let dir = install_dir().unwrap();
286        assert!(dir.to_string_lossy().contains(".victauri"));
287        assert!(dir.to_string_lossy().contains("bin"));
288    }
289
290    #[test]
291    fn manifest_binary_path_preserved() {
292        let path = "/some/deeply/nested/path/to/victauri-browser-host";
293        let manifest = host_manifest(path, "abc");
294        assert_eq!(manifest["path"], path);
295    }
296
297    #[test]
298    fn manifest_extension_id_in_origin() {
299        let id = "abcdefghijklmnopqrstuvwxyz012345";
300        let manifest = host_manifest("/bin/host", id);
301        let origin = manifest["allowed_origins"][0].as_str().unwrap();
302        assert_eq!(origin, format!("chrome-extension://{id}/"));
303    }
304
305    #[test]
306    fn manifest_type_is_stdio() {
307        let manifest = host_manifest("/bin/host", "ext");
308        assert_eq!(manifest["type"], "stdio");
309    }
310
311    #[test]
312    fn manifest_description_present() {
313        let manifest = host_manifest("/bin/host", "ext");
314        assert!(manifest["description"].as_str().unwrap().len() > 5);
315    }
316
317    #[test]
318    fn manifest_path_components_are_valid() {
319        let path = host_manifest_path().unwrap();
320        let path_str = path.to_string_lossy();
321        assert!(path_str.contains("victauri") || path_str.contains(HOST_NAME));
322        assert!(path_str.ends_with(".json"));
323    }
324
325    #[test]
326    fn all_manifest_paths_non_empty() {
327        let paths = all_manifest_paths().unwrap();
328        assert!(!paths.is_empty());
329        for p in &paths {
330            assert!(p.to_string_lossy().ends_with(".json"));
331        }
332    }
333
334    // --- Deep challenger: Chrome manifest spec compliance ---
335
336    #[test]
337    fn manifest_is_valid_json_object() {
338        let manifest = host_manifest("/bin/host", "ext");
339        assert!(manifest.is_object());
340        let obj = manifest.as_object().unwrap();
341        // Chrome requires exactly these fields
342        assert!(obj.contains_key("name"));
343        assert!(obj.contains_key("description"));
344        assert!(obj.contains_key("path"));
345        assert!(obj.contains_key("type"));
346        assert!(obj.contains_key("allowed_origins"));
347    }
348
349    #[test]
350    fn manifest_name_follows_chrome_spec() {
351        // Chrome: name must match regex [a-z][a-z0-9._]*
352        let manifest = host_manifest("/bin/host", "ext");
353        let name = manifest["name"].as_str().unwrap();
354        assert!(name.chars().next().unwrap().is_ascii_lowercase());
355        assert!(
356            name.chars()
357                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_')
358        );
359        assert!(name.len() <= 255);
360    }
361
362    #[test]
363    fn manifest_origin_format_correct() {
364        // Chrome requires "chrome-extension://<id>/" format with trailing slash
365        let manifest = host_manifest("/bin/host", "abcdefghijklmnopqrstuvwxyz012345");
366        let origin = manifest["allowed_origins"][0].as_str().unwrap();
367        assert!(origin.starts_with("chrome-extension://"));
368        assert!(origin.ends_with('/'));
369    }
370
371    #[test]
372    fn manifest_handles_windows_path() {
373        let manifest = host_manifest(
374            r"C:\Program Files\Victauri\victauri-browser-host.exe",
375            "ext",
376        );
377        let path = manifest["path"].as_str().unwrap();
378        assert!(path.contains("victauri-browser-host"));
379        assert!(path.contains(r"C:\Program Files"));
380    }
381
382    #[test]
383    fn manifest_handles_path_with_spaces() {
384        let manifest = host_manifest("/Users/My User/apps/victauri", "ext");
385        assert_eq!(manifest["path"], "/Users/My User/apps/victauri");
386    }
387
388    #[test]
389    fn manifest_handles_unicode_path() {
390        let manifest = host_manifest("/Users/用户/victauri", "ext");
391        assert_eq!(manifest["path"], "/Users/用户/victauri");
392    }
393
394    #[test]
395    fn manifest_serializes_to_valid_json() {
396        let manifest = host_manifest("/bin/host", "ext123");
397        let json_str = serde_json::to_string_pretty(&manifest).unwrap();
398        // Should be parseable back
399        let reparsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
400        assert_eq!(reparsed, manifest);
401    }
402
403    #[cfg(target_os = "windows")]
404    #[test]
405    fn all_manifest_paths_in_victauri_dir() {
406        let paths = all_manifest_paths().unwrap();
407        for p in &paths {
408            assert!(p.to_string_lossy().contains(".victauri"));
409        }
410    }
411
412    #[cfg(target_os = "macos")]
413    #[test]
414    fn all_manifest_paths_cover_browsers() {
415        let paths = all_manifest_paths().unwrap();
416        let path_strs: Vec<String> = paths
417            .iter()
418            .map(|p| p.to_string_lossy().to_string())
419            .collect();
420        assert!(path_strs.iter().any(|p| p.contains("Chrome")));
421        assert!(path_strs.iter().any(|p| p.contains("Edge")));
422        assert!(path_strs.iter().any(|p| p.contains("Brave")));
423        assert!(path_strs.iter().any(|p| p.contains("Arc")));
424    }
425
426    #[cfg(target_os = "linux")]
427    #[test]
428    fn all_manifest_paths_cover_browsers() {
429        let paths = all_manifest_paths().unwrap();
430        let path_strs: Vec<String> = paths
431            .iter()
432            .map(|p| p.to_string_lossy().to_string())
433            .collect();
434        assert!(path_strs.iter().any(|p| p.contains("google-chrome")));
435        assert!(path_strs.iter().any(|p| p.contains("microsoft-edge")));
436        assert!(path_strs.iter().any(|p| p.contains("Brave")));
437        assert!(path_strs.iter().any(|p| p.contains("chromium")));
438    }
439}