par_term/url_detection/render.rs
1//! URL and file path opening/action utilities.
2//!
3//! # Error Handling Convention
4//!
5//! Public functions in this module return `Result<(), String>` (simple string
6//! errors for UI display) rather than `anyhow::Error`. New helper functions
7//! added to this module should follow the same `Result<T, String>` pattern so
8//! callers can surface the error message directly to the user without conversion.
9
10/// Ensure a URL has a scheme prefix, adding `https://` if missing.
11///
12/// # Examples
13/// - `"www.example.com"` -> `"https://www.example.com"`
14/// - `"https://example.com"` -> `"https://example.com"` (unchanged)
15pub fn ensure_url_scheme(url: &str) -> String {
16 if !url.contains("://") {
17 format!("https://{}", url)
18 } else {
19 url.to_string()
20 }
21}
22
23/// Expand a link handler command template by replacing `{url}` with the given URL.
24///
25/// Returns the command split into program + arguments, ready for spawning.
26/// The command template is parsed using shell-word splitting BEFORE URL substitution
27/// so that the URL remains a single argument regardless of its content (preventing
28/// argument injection via crafted URLs containing spaces or shell metacharacters).
29///
30/// Returns an error if the expanded command is empty (whitespace-only or blank).
31pub fn expand_link_handler(command: &str, url: &str) -> Result<Vec<String>, String> {
32 // Parse the command template into tokens FIRST, before substitution.
33 // This ensures that {url} occupies exactly one token position,
34 // and the substituted URL cannot inject additional arguments.
35 let tokens = shell_words::split(command)
36 .map_err(|e| format!("Failed to parse link handler command: {}", e))?;
37 if tokens.is_empty() {
38 return Err("Link handler command is empty after expansion".to_string());
39 }
40 // Replace {url} placeholder within each token (the URL stays as one argument)
41 let parts: Vec<String> = tokens
42 .into_iter()
43 .map(|token| token.replace("{url}", url))
44 .collect();
45 Ok(parts)
46}
47
48/// Open a URL in the configured browser or system default.
49///
50/// `allow_file_scheme` (SEC-009 opt-in): when `true`, `file://` URLs are also
51/// forwarded to the OS handler (browser for `.html`, Finder for directories).
52/// Defaults to `false`; `file://` is otherwise blocked as a security measure.
53pub fn open_url(
54 url: &str,
55 link_handler_command: &str,
56 allow_file_scheme: bool,
57) -> Result<(), String> {
58 // SEC-009: validate the URL scheme before handing it to the OS handler.
59 // A remote program can emit an OSC 8 hyperlink with an arbitrary scheme
60 // (e.g. `file:///etc/cron.d/evil`); without this gate, `open::that`
61 // forwards it to the OS default handler, which happily opens `file://`,
62 // `ftp://`, etc. Only http(s)/mailto (and file:// when opted in) reach the OS.
63 validate_url_scheme(url, allow_file_scheme)?;
64
65 let url_with_scheme = ensure_url_scheme(url);
66
67 if link_handler_command.is_empty() {
68 // Use system default
69 open::that(&url_with_scheme).map_err(|e| format!("Failed to open URL: {}", e))
70 } else {
71 // Use custom command with {url} placeholder
72 let parts = expand_link_handler(link_handler_command, &url_with_scheme)?;
73 std::process::Command::new(&parts[0])
74 .args(&parts[1..])
75 .spawn()
76 .map(|_| ())
77 .map_err(|e| format!("Failed to run link handler '{}': {}", parts[0], e))
78 }
79}
80
81/// URL schemes the OS link handler is allowed to open (SEC-009).
82///
83/// `http`/`https` cover the common browser case; `mailto` is the only
84/// non-`://` scheme explicitly allowed. `file://`, `ftp://`, `data:`, and
85/// anything else are rejected because the OS handler would dispatch them to
86/// their default app with no further validation.
87const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
88
89/// Validate that `url` uses an allowlisted scheme (SEC-009).
90///
91/// - URLs with no `://` and no `mailto:` prefix are accepted unchanged and
92/// later normalized by [`ensure_url_scheme`] (e.g. `www.example.com`,
93/// `localhost:3000` which is `host:port`, not a scheme).
94/// - URLs with an explicit `scheme://` must use `http`, `https`. `mailto:`
95/// is the only allowed non-`://` scheme.
96/// - `file://` is rejected unless `allow_file_scheme` is `true` (opt-in), since
97/// the OS handler will open an arbitrary local path. All other `://` schemes
98/// (`ftp://`, `data:`, ...) are always rejected.
99fn validate_url_scheme(url: &str, allow_file_scheme: bool) -> Result<(), String> {
100 if let Some((scheme, _rest)) = url.split_once("://") {
101 let lower = scheme.to_ascii_lowercase();
102 if ALLOWED_URL_SCHEMES.contains(&lower.as_str()) {
103 return Ok(());
104 }
105 // SEC-009: file:// is blocked by default; the OS handler opens arbitrary
106 // local paths. Allow it only when the user explicitly opts in.
107 if allow_file_scheme && lower == "file" {
108 return Ok(());
109 }
110 let hint = if lower == "file" && !allow_file_scheme {
111 " (enable 'allow_file_scheme_urls' to open file:// links)"
112 } else {
113 ""
114 };
115 return Err(format!(
116 "Refusing to open URL with scheme '{lower}://' — only http, https, and mailto are allowed{hint}"
117 ));
118 }
119 // No `://`. Allow `mailto:` explicitly; treat everything else (including
120 // bare `host:port`) as scheme-less so `ensure_url_scheme` can prepend https.
121 if let Some((scheme, _)) = url.split_once(':')
122 && scheme.eq_ignore_ascii_case("mailto")
123 {
124 return Ok(());
125 }
126 Ok(())
127}
128
129/// Open a file path in the configured editor, or a directory in the file manager
130///
131/// # Arguments
132/// * `path` - The file or directory path to open
133/// * `line` - Optional line number to jump to (ignored for directories)
134/// * `column` - Optional column number to jump to (ignored for directories)
135/// * `editor_mode` - How to select the editor (Custom, EnvironmentVariable, or SystemDefault)
136/// * `editor_cmd` - Editor command template with placeholders: `{file}`, `{line}`, `{col}`.
137/// Only used when mode is `Custom`.
138/// * `cwd` - Optional working directory for resolving relative paths
139///
140/// # Security Note
141///
142/// The `path` argument originates from terminal output (e.g. a URL or filename detected
143/// in the scrollback buffer). It is **user-supplied and not sanitized beyond shell escaping**.
144/// The function applies [`shell_escape`] to all substituted values before constructing the
145/// shell command, which prevents typical shell metacharacter injection (backticks, `$()`,
146/// semicolons, etc.) via a maliciously crafted filename.
147///
148/// **Trust assumption**: this function trusts that the path was identified by the URL/semantic
149/// detector from the user's own terminal session. It does not validate that the path points to
150/// a benign file — opening a path in an editor is the intended action. If this assumption
151/// changes (e.g. paths arrive from an untrusted external source), additional validation should
152/// be applied before calling this function.
153pub fn open_file_in_editor(
154 path: &str,
155 line: Option<usize>,
156 column: Option<usize>,
157 editor_mode: crate::config::SemanticHistoryEditorMode,
158 editor_cmd: &str,
159 cwd: Option<&str>,
160) -> Result<(), String> {
161 // Expand ~ to home directory
162 let resolved_path = if path.starts_with("~/") {
163 if let Some(home) = dirs::home_dir() {
164 path.replacen("~", &home.to_string_lossy(), 1)
165 } else {
166 path.to_string()
167 }
168 } else {
169 path.to_string()
170 };
171
172 // Resolve relative paths using CWD
173 let resolved_path = if resolved_path.starts_with("./") || resolved_path.starts_with("../") {
174 if let Some(working_dir) = cwd {
175 // Expand ~ in CWD as well
176 let expanded_cwd = if working_dir.starts_with("~/") {
177 if let Some(home) = dirs::home_dir() {
178 working_dir.replacen("~", &home.to_string_lossy(), 1)
179 } else {
180 working_dir.to_string()
181 }
182 } else {
183 working_dir.to_string()
184 };
185
186 let cwd_path = std::path::Path::new(&expanded_cwd);
187 let full_path = cwd_path.join(&resolved_path);
188 crate::debug_info!(
189 "SEMANTIC",
190 "Resolved relative path: {:?} + {:?} = {:?}",
191 expanded_cwd,
192 resolved_path,
193 full_path
194 );
195 // Canonicalize to resolve . and .. components
196 full_path
197 .canonicalize()
198 .map(|p| p.to_string_lossy().to_string())
199 .unwrap_or_else(|_| full_path.to_string_lossy().to_string())
200 } else {
201 resolved_path.clone()
202 }
203 } else {
204 resolved_path.clone()
205 };
206
207 // Verify the path exists
208 let path_obj = std::path::Path::new(&resolved_path);
209 if !path_obj.exists() {
210 return Err(format!("Path not found: {}", resolved_path));
211 }
212
213 // If it's a directory, always open in the system file manager
214 if path_obj.is_dir() {
215 crate::debug_info!(
216 "SEMANTIC",
217 "Opening directory in file manager: {}",
218 resolved_path
219 );
220 return open::that(&resolved_path).map_err(|e| format!("Failed to open directory: {}", e));
221 }
222
223 // Determine the editor command based on mode
224 use crate::config::SemanticHistoryEditorMode;
225 let cmd = match editor_mode {
226 SemanticHistoryEditorMode::Custom => {
227 if editor_cmd.is_empty() {
228 // Custom mode but no command configured - fall back to system default
229 crate::debug_info!(
230 "SEMANTIC",
231 "Custom mode but no editor configured, using system default for: {}",
232 resolved_path
233 );
234 return open::that(&resolved_path)
235 .map_err(|e| format!("Failed to open file: {}", e));
236 }
237 crate::debug_info!("SEMANTIC", "Using custom editor: {:?}", editor_cmd);
238 editor_cmd.to_string()
239 }
240 SemanticHistoryEditorMode::EnvironmentVariable => {
241 // Try $EDITOR, then $VISUAL, then fall back to system default
242 let env_editor = std::env::var("EDITOR")
243 .or_else(|_| std::env::var("VISUAL"))
244 .ok();
245 crate::debug_info!(
246 "SEMANTIC",
247 "Environment variable mode: EDITOR={:?}, VISUAL={:?}",
248 std::env::var("EDITOR").ok(),
249 std::env::var("VISUAL").ok()
250 );
251 if let Some(editor) = env_editor {
252 editor
253 } else {
254 crate::debug_info!(
255 "SEMANTIC",
256 "No $EDITOR/$VISUAL set, using system default for: {}",
257 resolved_path
258 );
259 return open::that(&resolved_path)
260 .map_err(|e| format!("Failed to open file: {}", e));
261 }
262 }
263 SemanticHistoryEditorMode::SystemDefault => {
264 crate::debug_info!(
265 "SEMANTIC",
266 "System default mode, opening with default app: {}",
267 resolved_path
268 );
269 return open::that(&resolved_path).map_err(|e| format!("Failed to open file: {}", e));
270 }
271 };
272
273 // Replace placeholders in command template.
274 //
275 // SEC-003: When the command contains only {file} (and optionally {line}/{col})
276 // placeholders and no other shell features, use direct process spawning instead
277 // of routing through the login shell. This eliminates the shell as an attack
278 // surface for crafted filenames that might bypass shell_escape in edge cases.
279 //
280 // We detect "direct spawn eligible" when:
281 // 1. The cmd does NOT contain shell metacharacters (|, &, ;, $, `, (, ), {, })
282 // outside the known {file}, {line}, {col} placeholders.
283 // 2. The cmd DOES contain at least the {file} placeholder (so the path value
284 // occupies a controlled argument position, not a shell-interpolated string).
285 //
286 // When not eligible (complex command, no placeholder, or Windows), fall through
287 // to the existing shell invocation path.
288
289 let line_str = line
290 .map(|l| l.to_string())
291 .unwrap_or_else(|| "1".to_string());
292 let col_str = column
293 .map(|c| c.to_string())
294 .unwrap_or_else(|| "1".to_string());
295
296 /// Return true if the template contains shell metacharacters beyond the
297 /// known {file}/{line}/{col} placeholders. We strip those placeholders
298 /// first so their braces don't trigger the `{`/`}` check.
299 fn has_shell_metacharacters(template: &str) -> bool {
300 let stripped = template
301 .replace("{file}", "")
302 .replace("{line}", "")
303 .replace("{col}", "");
304 stripped.chars().any(|c| {
305 matches!(
306 c,
307 '|' | '&' | ';' | '$' | '`' | '(' | ')' | '{' | '}' | '>' | '<' | '~' | '\\' | '\''
308 )
309 })
310 }
311
312 let can_direct_spawn = cmd.contains("{file}") && !has_shell_metacharacters(&cmd);
313
314 crate::debug_info!(
315 "SEMANTIC",
316 "Executing editor command: {:?} for file: {} (line: {:?}, col: {:?}) direct_spawn={}",
317 cmd,
318 resolved_path,
319 line,
320 column,
321 can_direct_spawn
322 );
323
324 if can_direct_spawn {
325 // Direct spawn: parse the template into tokens using shell-word splitting
326 // BEFORE substitution (so placeholders land at exact argument positions),
327 // then substitute the literal values without any shell escaping.
328 let tokens = shell_words::split(&cmd)
329 .map_err(|e| format!("Failed to parse editor command: {}", e))?;
330 if tokens.is_empty() {
331 return Err("Editor command is empty".to_string());
332 }
333
334 // Append file to token list if no {file} placeholder found in that token
335 // (already guaranteed to exist since can_direct_spawn requires it)
336 let args: Vec<String> = tokens
337 .into_iter()
338 .map(|t| {
339 t.replace("{file}", &resolved_path)
340 .replace("{line}", &line_str)
341 .replace("{col}", &col_str)
342 })
343 .collect();
344
345 crate::debug_info!("SEMANTIC", "Direct spawn: {:?}", args);
346 std::process::Command::new(&args[0])
347 .args(&args[1..])
348 .spawn()
349 .map_err(|e| format!("Failed to launch editor '{}': {}", args[0], e))?;
350 } else {
351 // Shell invocation fallback: escape all substituted values and route through
352 // the login shell to handle complex commands (pipes, env vars, etc.).
353 let escaped_path = shell_escape(&resolved_path);
354 let escaped_line = shell_escape(&line_str);
355 let escaped_col = shell_escape(&col_str);
356
357 let full_cmd = cmd
358 .replace("{file}", &escaped_path)
359 .replace("{line}", &escaped_line)
360 .replace("{col}", &escaped_col);
361
362 // If the template didn't have placeholders, append the file path
363 let full_cmd = if !cmd.contains("{file}") {
364 format!("{} {}", full_cmd, escaped_path)
365 } else {
366 full_cmd
367 };
368
369 crate::debug_info!("SEMANTIC", "Shell spawn: {:?}", full_cmd);
370
371 #[cfg(target_os = "windows")]
372 {
373 std::process::Command::new("cmd")
374 .args(["/C", &full_cmd])
375 .spawn()
376 .map_err(|e| format!("Failed to launch editor: {}", e))?;
377 }
378
379 #[cfg(not(target_os = "windows"))]
380 {
381 // Use login shell to ensure user's PATH is available
382 // Try user's default shell first, fall back to sh
383 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
384 std::process::Command::new(&shell)
385 .args(["-lc", &full_cmd])
386 .spawn()
387 .map_err(|e| format!("Failed to launch editor with {}: {}", shell, e))?;
388 }
389 }
390
391 Ok(())
392}
393
394/// Simple shell escape for file paths (wraps in single quotes)
395pub fn shell_escape(s: &str) -> String {
396 // Replace single quotes with escaped version and wrap in single quotes
397 format!("'{}'", s.replace('\'', "'\\''"))
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn file_scheme_blocked_by_default() {
406 // SEC-009 default posture: file:// must not reach the OS handler.
407 assert!(validate_url_scheme("file:///etc/passwd", false).is_err());
408 assert!(validate_url_scheme("file://localhost/tmp/x", false).is_err());
409 assert!(validate_url_scheme("FILE:///etc/passwd", false).is_err()); // case-insensitive
410 }
411
412 #[test]
413 fn file_scheme_allowed_when_opted_in() {
414 assert!(validate_url_scheme("file:///etc/passwd", true).is_ok());
415 assert!(validate_url_scheme("file://localhost/tmp/x", true).is_ok());
416 }
417
418 #[test]
419 fn http_https_mailto_always_allowed() {
420 for url in [
421 "https://example.com",
422 "http://example.com",
423 "mailto:foo@bar.com",
424 "www.example.com", // scheme-less, normalized later
425 "localhost:3000", // host:port, not a scheme
426 ] {
427 assert!(
428 validate_url_scheme(url, false).is_ok(),
429 "blocked by default: {url}"
430 );
431 assert!(
432 validate_url_scheme(url, true).is_ok(),
433 "blocked when opted in: {url}"
434 );
435 }
436 }
437
438 #[test]
439 fn other_colon_schemes_still_blocked_when_opted_in() {
440 // Opting into file:// must NOT weaken the gate for other :// schemes.
441 for url in [
442 "ftp://example.com",
443 "sftp://example.com",
444 "gopher://example.com",
445 ] {
446 assert!(
447 validate_url_scheme(url, true).is_err(),
448 "should stay blocked: {url}"
449 );
450 }
451 }
452}