1use 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}