//! Shared URL-opening and scheme validation utilities. //! //! Extracted from the `OpenSubscriptionUrl` dispatch handler so that any //! code path (keyboard navigation, mouse click, action dispatch) can //! open a link safely without duplicating platform-specific logic. use crate::terminal::hyperlinks::SchemeFilter; /// Open a URL in the system's default browser/handler. /// /// Spawns the platform-native opener (`open` on macOS, `xdg-open` on /// Linux, `cmd /c start` on Windows) with fully detached stdio so it /// cannot block the pager. /// /// **Callers handling untrusted input** should call [`is_safe_to_open`] /// first, or use [`open_url_if_safe`] which combines both steps. pub fn open_url(url: &str) { // Test seam: PTY e2e must observe the open without launching a real // browser. When set, append the URL to the file and skip the OS opener. if let Ok(path) = std::env::var("KIGI_TEST_OPEN_URL_FILE") { use std::io::Write; // Surface misconfiguration: a swallowed write leaves the PTY test // failing with a generic timeout and no clue why. if let Err(e) = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .and_then(|mut f| writeln!(f, "{url}")) { tracing::warn!(error = %e, path, "KIGI_TEST_OPEN_URL_FILE write failed"); } return; } #[cfg(target_os = "macos")] let cmd = "open"; #[cfg(target_os = "windows")] let cmd = "cmd"; #[cfg(not(any(target_os = "macos", target_os = "windows")))] let cmd = "xdg-open"; let mut command = std::process::Command::new(cmd); #[cfg(target_os = "windows")] command.args(["/c", "start", ""]); command .arg(url) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); kigi_tools::util::detach_std_command(&mut command); if let Err(e) = command.spawn() { // Redact URL to avoid leaking sensitive query params to logs. let redacted = url::Url::parse(url) .map(|mut u| { u.set_query(None); u.set_fragment(None); u.to_string() }) .unwrap_or_else(|_| "".to_string()); tracing::warn!(url = %redacted, error = %e, "failed to open URL"); } } /// Build the `open`/`xdg-open` opener command (macOS / Linux / BSD). /// /// The returned command is TTY-guarded via [`kigi_tty_utils::detach_std_command`] /// (`setsid`/`setpgid`) so the spawned GUI helper and its children can't grab /// the TUI's `/dev/tty`, with stdio fully redirected to null. Split from /// [`open_path`] so it can be unit-tested without spawning. The path is a single /// argument, never interpolated into a shell string. Windows uses /// [`reveal_in_explorer`] instead. #[cfg(not(target_os = "windows"))] fn build_open_path_command(path: &std::path::Path) -> std::process::Command { #[cfg(target_os = "macos")] let mut command = std::process::Command::new("open"); #[cfg(not(target_os = "macos"))] let mut command = std::process::Command::new("xdg-open"); command .arg(path) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); kigi_tty_utils::detach_std_command(&mut command); command } /// Reveal/open a local file in the OS file manager or default application. /// /// Returns `true` on success. Takes a trusted filesystem path (no scheme /// validation, unlike [`open_url`]). /// /// - **Windows**: `explorer.exe /select,` reveals + highlights the file /// in Explorer. We deliberately avoid `cmd /c start`, whose `%VAR%` /// expansion corrupts the percent-encoded session-directory segment in /// media paths (e.g. `…\C%3A%5CUsers…`). /// - **macOS / Linux**: `open` / `xdg-open` open the file in its default app. pub fn open_path(path: &std::path::Path) -> bool { // Never launch a real GUI app in tests. #[cfg(test)] { !path.as_os_str().is_empty() } #[cfg(all(not(test), target_os = "windows"))] { reveal_in_explorer(path) } #[cfg(all(not(test), not(target_os = "windows")))] { match build_open_path_command(path).spawn() { Ok(_) => true, Err(e) => { tracing::warn!(path = %path.display(), error = %e, "failed to open file natively"); false } } } } /// Reveal `path` in a new Explorer window with the file selected. /// /// Uses `raw_arg` so Explorer's required `/select,""` quoting is passed /// verbatim — the default arg quoting wraps the whole token and breaks the /// switch. Launched directly (not via `cmd`), so percent characters in the /// path are not expanded by the shell. Session dirs embed a urlencoded cwd /// segment (`C%3A%5CUsers…`); those `%` chars must reach Explorer intact. /// /// Prefer the on-disk path as-is. When the file is missing, open the parent /// folder (no `/select`) so the user lands near the media instead of Home. #[cfg(all(not(test), target_os = "windows"))] fn reveal_in_explorer(path: &std::path::Path) -> bool { use std::os::windows::process::CommandExt; // Prefer the real on-disk location (absolute). Fall back to parent when // the file was deleted so Explorer does not dump the user in Home. let target = if path.is_file() || path.is_dir() { path.to_path_buf() } else if let Some(parent) = path.parent().filter(|p| p.is_dir()) { tracing::debug!( path = %path.display(), parent = %parent.display(), "media path missing; opening parent folder in Explorer" ); parent.to_path_buf() } else { path.to_path_buf() }; let select_file = target.is_file(); let mut command = std::process::Command::new("explorer"); // Escape embedded double-quotes in the path so the `/select,""` // quoting does not break. Windows file-system paths cannot legally contain // `"`, but percent-decoded display paths or future user-chosen filenames // could, so be defensive. let escaped = target.display().to_string().replace('"', "\"\""); if select_file { command.raw_arg(format!("/select,\"{}\"", escaped)); } else { // Open the folder itself (no /select) — works for dirs and as a // fallback when we only have a parent path. command.raw_arg(format!("\"{}\"", escaped)); } command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); kigi_tty_utils::detach_std_command(&mut command); // explorer.exe returns exit code 1 even on success, so a successful spawn // is the best signal we have. match command.spawn() { Ok(_) => true, Err(e) => { tracing::warn!(path = %target.display(), error = %e, "failed to reveal file in Explorer"); false } } } /// Check if a URL's scheme is safe to open. /// /// Uses the `url` crate for robust scheme extraction. Falls back to /// prefix matching for non-standard URLs that `url::Url::parse` rejects. pub fn is_safe_to_open(url: &str, filter: SchemeFilter) -> bool { let url = url.trim(); if let Ok(parsed) = url::Url::parse(url) { return filter.allows(parsed.scheme()); } // Fallback: check for scheme via "://" prefix, lowercasing for // case-insensitive comparison (SchemeFilter matches lowercase literals). if let Some((scheme, _)) = url.split_once("://") { return filter.allows(&scheme.to_ascii_lowercase()); } // Defensive: url::Url::parse handles well-formed mailto, but guard // against edge cases where the parser rejects a mailto-like string. if let Some((scheme, _)) = url.split_once(':') && scheme.eq_ignore_ascii_case("mailto") { return filter.allows(&scheme.to_ascii_lowercase()); } false } /// Validate scheme and open a URL if permitted. Returns `true` if opened. pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool { if is_safe_to_open(url, filter) { open_url(url); true } else { tracing::debug!(url, "URL scheme not permitted"); false } } #[cfg(test)] mod tests { use super::*; #[test] fn open_path_command_passes_path_as_a_single_arg() { // Path with spaces must be one argument, never shell-interpolated. let path = std::path::Path::new("/tmp/kigi session/image 1.jpg"); let command = build_open_path_command(path); let args: Vec<_> = command.get_args().map(|a| a.to_os_string()).collect(); assert!(args.contains(&path.as_os_str().to_os_string())); } #[test] fn standard_http_schemes_allowed() { assert!(is_safe_to_open( "http://example.com", SchemeFilter::Standard )); assert!(is_safe_to_open( "https://example.com/path?q=1", SchemeFilter::Standard )); } #[test] fn mailto_allowed() { assert!(is_safe_to_open( "mailto:user@example.com", SchemeFilter::Standard )); } #[test] fn file_scheme_blocked_by_standard() { // file:// removed from Standard to prevent local file / SSRF attacks. assert!(!is_safe_to_open( "file:///home/user/doc.pdf", SchemeFilter::Standard )); // But allowed under EditorExtended. assert!(is_safe_to_open( "file:///home/user/doc.pdf", SchemeFilter::EditorExtended )); } #[test] fn javascript_scheme_blocked() { assert!(!is_safe_to_open( "javascript:alert(1)", SchemeFilter::Standard )); } #[test] fn data_scheme_blocked() { assert!(!is_safe_to_open( "data:text/html,

hi

", SchemeFilter::Standard )); } #[test] fn empty_and_garbage_rejected() { assert!(!is_safe_to_open("", SchemeFilter::Standard)); assert!(!is_safe_to_open("not-a-url", SchemeFilter::Standard)); assert!(!is_safe_to_open( "://missing-scheme", SchemeFilter::Standard )); } #[test] fn editor_schemes_with_extended_filter() { assert!(is_safe_to_open( "vscode://file/path", SchemeFilter::EditorExtended )); assert!(is_safe_to_open( "cursor://open", SchemeFilter::EditorExtended )); assert!(is_safe_to_open("idea://open", SchemeFilter::EditorExtended)); assert!(is_safe_to_open("zed://open", SchemeFilter::EditorExtended)); } #[test] fn editor_schemes_blocked_by_standard_filter() { assert!(!is_safe_to_open( "vscode://file/path", SchemeFilter::Standard )); assert!(!is_safe_to_open("cursor://open", SchemeFilter::Standard)); } #[test] fn scheme_case_sensitivity() { // url::Url normalizes to lowercase assert!(is_safe_to_open( "HTTP://EXAMPLE.COM", SchemeFilter::Standard )); assert!(is_safe_to_open( "HTTPS://EXAMPLE.COM", SchemeFilter::Standard )); } #[test] fn url_with_fragment_and_query() { assert!(is_safe_to_open( "https://example.com/page?key=val#section", SchemeFilter::Standard )); } #[test] fn ftp_scheme_blocked() { assert!(!is_safe_to_open( "ftp://files.example.com/pub", SchemeFilter::Standard )); } #[test] fn fallback_colon_slash_slash_path() { // A custom scheme that url::Url may reject but has :// assert!(!is_safe_to_open( "custom://something", SchemeFilter::Standard )); } #[test] fn non_mailto_colon_without_slashes_rejected() { assert!(!is_safe_to_open("tel:+1234567890", SchemeFilter::Standard)); } #[test] fn whitespace_trimmed_before_parse() { assert!(is_safe_to_open( " https://example.com ", SchemeFilter::Standard )); assert!(is_safe_to_open( "\thttps://example.com\n", SchemeFilter::Standard )); } #[test] fn fallback_scheme_case_insensitive() { // Uppercase scheme that url::Url::parse rejects triggers fallback path; // the fallback must lowercase before matching SchemeFilter. assert!(!is_safe_to_open( "CUSTOM://something", SchemeFilter::Standard )); // Ensure mailto fallback is case-insensitive too. assert!(is_safe_to_open( "MAILTO:user@example.com", SchemeFilter::Standard )); } }