Skip to main content

fude/
shell.rs

1//! Opens URLs or allow-listed files in the OS default application.
2//! Registered by [`crate::App::with_shell_open`].
3//!
4//! Two classes of target are accepted:
5//!
6//! - URLs with scheme `http://`, `https://`, or `mailto:` — passed to the
7//!   OS opener as-is.
8//! - Absolute local file paths — must already be allow-listed via
9//!   `allow_path` or `allow_dir` before `shell_open` will accept them.
10//!
11//! Any other scheme (`file://`, `javascript:`, `vbscript:`, custom
12//! schemes) is refused — the goal is to prevent a compromised frontend
13//! from tricking the shell into running arbitrary handlers or reading
14//! paths outside the sandbox.
15
16use std::path::PathBuf;
17use std::process::Command;
18
19use serde_json::Value;
20
21use crate::{is_path_allowed, FsState};
22
23#[cfg_attr(test, derive(Debug))]
24pub(crate) enum Target {
25    Url(String),
26    Path(PathBuf),
27}
28
29pub(crate) fn classify(input: &str, fs: Option<&FsState>) -> Result<Target, String> {
30    if input.is_empty() {
31        return Err("shell_open target is empty".to_string());
32    }
33    let lower = input.to_ascii_lowercase();
34    if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
35    {
36        return Ok(Target::Url(input.to_string()));
37    }
38    if lower.contains("://") || lower.starts_with("javascript:") || lower.starts_with("data:") {
39        return Err(format!(
40            "shell_open refused scheme in target: {}",
41            input.split_once(':').map(|(s, _)| s).unwrap_or("unknown")
42        ));
43    }
44    let fs = fs.ok_or_else(|| {
45        "shell_open for file paths requires with_fs_sandbox on the App".to_string()
46    })?;
47    let canonical = is_path_allowed(input, &fs.allowed_paths, &fs.allowed_dirs)?;
48    Ok(Target::Path(PathBuf::from(canonical)))
49}
50
51pub(crate) fn open(args: &Value, fs: Option<&FsState>) -> Result<Value, String> {
52    let target = args
53        .get("target")
54        .and_then(|v| v.as_str())
55        .ok_or("missing target")?;
56    let classified = classify(target, fs)?;
57    let arg = match classified {
58        Target::Url(u) => u,
59        Target::Path(p) => p.to_string_lossy().to_string(),
60    };
61    spawn_opener(&arg)
62}
63
64#[cfg(target_os = "macos")]
65fn spawn_opener(arg: &str) -> Result<Value, String> {
66    Command::new("open")
67        .arg(arg)
68        .spawn()
69        .map_err(|e| format!("open failed: {}", e))?;
70    Ok(Value::Null)
71}
72
73#[cfg(target_os = "linux")]
74fn spawn_opener(arg: &str) -> Result<Value, String> {
75    Command::new("xdg-open")
76        .arg(arg)
77        .spawn()
78        .map_err(|e| format!("xdg-open failed: {}", e))?;
79    Ok(Value::Null)
80}
81
82#[cfg(target_os = "windows")]
83fn spawn_opener(arg: &str) -> Result<Value, String> {
84    Command::new("cmd")
85        .args(["/C", "start", "", arg])
86        .spawn()
87        .map_err(|e| format!("start failed: {}", e))?;
88    Ok(Value::Null)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::{new_list, FsState};
95    use std::sync::Arc;
96
97    fn empty_fs() -> Arc<FsState> {
98        Arc::new(FsState {
99            allowed_paths: new_list(),
100            allowed_dirs: new_list(),
101        })
102    }
103
104    #[test]
105    fn empty_input_rejected() {
106        assert!(classify("", None).is_err());
107    }
108
109    #[test]
110    fn http_url_passes_without_fs() {
111        let r = classify("http://example.com", None).unwrap();
112        assert!(matches!(r, Target::Url(_)));
113    }
114
115    #[test]
116    fn https_url_passes_without_fs() {
117        assert!(matches!(
118            classify("https://example.com/foo?bar", None).unwrap(),
119            Target::Url(_)
120        ));
121    }
122
123    #[test]
124    fn mailto_passes_without_fs() {
125        assert!(matches!(
126            classify("mailto:alice@example.com", None).unwrap(),
127            Target::Url(_)
128        ));
129    }
130
131    #[test]
132    fn scheme_casing_is_ignored() {
133        assert!(matches!(
134            classify("HTTPS://example.com", None).unwrap(),
135            Target::Url(_)
136        ));
137    }
138
139    #[test]
140    fn javascript_scheme_rejected() {
141        assert!(classify("javascript:alert(1)", None).is_err());
142    }
143
144    #[test]
145    fn data_scheme_rejected() {
146        assert!(classify("data:text/html,<script>", None).is_err());
147    }
148
149    #[test]
150    fn file_scheme_rejected() {
151        assert!(classify("file:///etc/passwd", None).is_err());
152    }
153
154    #[test]
155    fn custom_scheme_rejected() {
156        assert!(classify("vscode://path/to/file", None).is_err());
157    }
158
159    #[test]
160    fn file_path_requires_fs_sandbox() {
161        let err = classify("/tmp/x.md", None).unwrap_err();
162        assert!(err.contains("with_fs_sandbox"));
163    }
164
165    #[test]
166    fn file_path_not_on_allow_list_rejected() {
167        let fs = empty_fs();
168        assert!(classify("/etc/passwd", Some(&fs)).is_err());
169    }
170}