Skip to main content

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}