diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index f2d6596bb0..da9c8a151d 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -13,7 +13,9 @@ use crate::linear::{LinearAction, LinearIssueWork}; use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg}; use crate::server::ids::ServerId; use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent}; -use crate::util::openable_file_type::{is_file_openable_in_warp, is_markdown_file}; +use crate::util::openable_file_type::{ + is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, starts_with_shebang, +}; use crate::workspace::{Workspace, WorkspaceAction, WorkspaceRegistry}; use crate::{cloud_object::ObjectType, workspace::ToastStack}; use crate::{drive::OpenWarpDriveObjectArgs, view_components::DismissibleToast}; @@ -30,7 +32,7 @@ use anyhow::{anyhow, ensure, Result}; use itertools::Itertools; use session_sharing_protocol::common::SessionId; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use url::Url; use warpui::notification::UserNotification; @@ -1004,6 +1006,39 @@ fn get_primary_window( non_quake_mode_windows.next() } +/// What `open_file` should do with an incoming `file://` URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenFileAction { + /// Open in the markdown notebook pane. + Notebook, + /// Open in Warp's code/text editor pane. + Editor, + /// Open a session at the parent directory and queue the file as the pending command, + /// or just open a session at the directory path if `path` is a directory. + ExecuteInSession, +} + +/// Pure routing decision for `open_file`. Extracted so it can be unit-tested without +/// standing up a full `AppContext`. +fn classify_open_file_action(path: &Path) -> OpenFileAction { + if is_markdown_file(path) { + return OpenFileAction::Notebook; + } + if path.is_file() { + if is_runnable_shell_script(path) { + return OpenFileAction::ExecuteInSession; + } + // Anything we can show in the editor opens there. The second branch catches + // shebang scripts that `is_file_openable_in_warp` rejects on extension alone + // (e.g. an extensionless `#!/bin/sh` file without the user-execute bit) so + // they don't fall through to the executor and produce a `permission denied`. + if is_file_openable_in_warp(path).is_some() || starts_with_shebang(path) { + return OpenFileAction::Editor; + } + } + OpenFileAction::ExecuteInSession +} + /// Handle an incoming `file://` URL. /// * Markdown files are opened as notebook panes. /// * For directories, open a new session at the directory path. @@ -1015,7 +1050,8 @@ fn open_file(window_id: Option, path: PathBuf, ctx: &mut AppContext) { .map(|view_id| (window_id, view_id)) }); - if is_markdown_file(&path) { + let action = classify_open_file_action(&path); + if action == OpenFileAction::Notebook { if let Some((primary_window_id, root_view_id)) = primary_window_and_view { ctx.dispatch_action( primary_window_id, @@ -1027,7 +1063,7 @@ fn open_file(window_id: Option, path: PathBuf, ctx: &mut AppContext) { } else { ctx.dispatch_global_action("root_view:open_new_with_file_notebook", &path); } - } else if path.is_file() && is_file_openable_in_warp(&path).is_some() { + } else if action == OpenFileAction::Editor { #[cfg(feature = "local_fs")] { use crate::code::editor_management::CodeSource; diff --git a/app/src/uri/uri_test.rs b/app/src/uri/uri_test.rs index a4d0df7805..44ba39e438 100644 --- a/app/src/uri/uri_test.rs +++ b/app/src/uri/uri_test.rs @@ -573,3 +573,89 @@ fn test_parse_tab_path_bare_tilde() { let home = dirs::home_dir().expect("HOME must be set for this test"); assert_eq!(parse_tab_path(&url), Some(home)); } + +// Regression coverage for issue #9005: shell scripts opened via `file://` should run, +// not open in the editor. Exercised through the pure routing helper to avoid standing +// up a full `AppContext`. + +#[test] +#[cfg(unix)] +fn test_open_file_executable_sh_routes_to_execute() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("run.sh"); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + classify_open_file_action(&p), + OpenFileAction::ExecuteInSession + ); +} + +#[test] +#[cfg(unix)] +fn test_open_file_non_executable_sh_routes_to_editor() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("view.sh"); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} + +#[test] +#[cfg(unix)] +fn test_open_file_executable_bash_zsh_fish_route_to_execute() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for name in ["run.bash", "run.zsh", "run.fish"] { + let p = dir.path().join(name); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + classify_open_file_action(&p), + OpenFileAction::ExecuteInSession, + "{name} should route to ExecuteInSession", + ); + } +} + +#[test] +fn test_open_file_markdown_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("README.md"); + std::fs::write(&p, b"# hi\n").unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Notebook); +} + +#[test] +#[cfg(feature = "local_fs")] +fn test_open_file_rust_source_still_opens_in_editor() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("main.rs"); + std::fs::write(&p, b"fn main() {}\n").unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} + +#[test] +fn test_open_file_directory_routes_to_session() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + classify_open_file_action(dir.path()), + OpenFileAction::ExecuteInSession + ); +} + +#[test] +#[cfg(unix)] +fn test_open_file_non_runnable_shebang_routes_to_editor() { + // Extensionless `#!/bin/sh` file without the user-execute bit. Without the + // shebang fall-through this would hit `ExecuteInSession` and the shell would + // refuse to run it; the editor is the right place to view it. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} diff --git a/app/src/util/openable_file_type.rs b/app/src/util/openable_file_type.rs index 1dce2c929e..ef1366c24e 100644 --- a/app/src/util/openable_file_type.rs +++ b/app/src/util/openable_file_type.rs @@ -81,6 +81,62 @@ pub fn is_supported_image_file(path: impl AsRef) -> bool { .unwrap_or(false) } +/// Returns true if `path` looks like a shell script the user intends to run when +/// "Open with Warp" is invoked from Finder/another app via a `file://` URL. +/// +/// Policy: extension in {sh, bash, zsh, fish, ksh} with the user-execute bit set on Unix, +/// or extension in {ps1, bat, cmd} on Windows (no x-bit concept). On Unix, files with no +/// extension but a `#!` shebang and the user-execute bit set also qualify. +/// +/// Narrow on purpose: this only affects the URI entry point, not "Open in New Tab" from +/// other UI surfaces, which still want shell scripts viewable in the editor. +/// Returns true if `path` exists and starts with a `#!` shebang. Reads only the +/// first two bytes — the URI entry point is reached from a `file://` URL, so the +/// file is attacker-controlled in size and `std::fs::read` would risk an OOM. +pub(crate) fn starts_with_shebang(path: &Path) -> bool { + use std::io::Read; + let mut prefix = [0u8; 2]; + match std::fs::File::open(path) { + Ok(mut file) => file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!'], + Err(_) => false, + } +} + +#[cfg(unix)] +pub fn is_runnable_shell_script(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + // Match the documented routing policy: only the owner's execute bit counts. + // A file `chmod 070` belongs to a group, not to the user invoking Warp. + let has_user_x_bit = std::fs::metadata(path) + .map(|m| m.permissions().mode() & 0o100 != 0) + .unwrap_or(false); + if !has_user_x_bit { + return false; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + if let Some(ext) = ext.as_deref() { + return matches!(ext, "sh" | "bash" | "zsh" | "fish" | "ksh"); + } + starts_with_shebang(path) +} + +#[cfg(windows)] +pub fn is_runnable_shell_script(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .is_some_and(|ext| matches!(ext.as_str(), "ps1" | "bat" | "cmd")) +} + +#[cfg(not(any(unix, windows)))] +pub fn is_runnable_shell_script(_path: &Path) -> bool { + false +} + /// Determines if a file can be opened in Warp and returns its type. /// Returns `None` if the file is binary and should not be opened. pub fn is_file_openable_in_warp(path: &Path) -> Option { @@ -344,4 +400,134 @@ mod tests { assert!(!is_supported_code_file(Path::new("data.txt"))); assert!(!is_supported_code_file(Path::new("image.png"))); } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_executable_sh() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hello.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&p, perms).unwrap(); + assert!(is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_non_executable_sh() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hello.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(&p, perms).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_group_only_executable_rejected() { + // Mode 0o070: group-x and group-r/w only, no user-execute. Must NOT classify + // as runnable — only the owner's execute bit drives the routing decision. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("group_only.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o070)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_other_shell_extensions() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for name in ["run.bash", "run.zsh", "run.fish", "run.ksh"] { + let p = dir.path().join(name); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(is_runnable_shell_script(&p), "{name} should be runnable"); + } + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_shebang_no_extension() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_shebang_no_extension_no_x_bit() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_plain_text_rejected() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("notes.txt"); + std::fs::write(&p, b"just some text\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_symlink_to_executable() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real.sh"); + std::fs::write(&target, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let link = dir.path().join("link.sh"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(is_runnable_shell_script(&link)); + } + + #[test] + fn test_starts_with_shebang_present() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("script"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + assert!(starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_absent() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("plain"); + std::fs::write(&p, b"echo hi\n").unwrap(); + assert!(!starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_one_byte_file() { + // `read_exact(&mut [0u8; 2])` must short-read on a single-byte file. + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("tiny"); + std::fs::write(&p, b"#").unwrap(); + assert!(!starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_missing_path() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("nope"); + assert!(!starts_with_shebang(&p)); + } }