1173 lines · 39.6 KB · Rust
RawDownload
use schemars::JsonSchema;use serde::{Deserialize, Serialize};use std::{borrow::Cow, ffi::OsStr, path::Path, sync::LazyLock}; /// Shell configuration to open the terminal with.#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]#[serde(rename_all = "snake_case")]pub enum Shell {    /// Use the system's default terminal configuration in /etc/passwd    #[default]    System,    /// Use a specific program with no arguments.    Program(String),    /// Use a specific program with arguments.    WithArguments {        /// The program to run.        program: String,        /// The arguments to pass to the program.        args: Vec<String>,        /// An optional string to override the title of the terminal tab        title_override: Option<String>,    },} impl Shell {    pub fn program(&self) -> String {        match self {            Shell::Program(program) => program.clone(),            Shell::WithArguments { program, .. } => program.clone(),            Shell::System => get_system_shell(),        }    }     pub fn program_and_args(&self) -> (String, &[String]) {        match self {            Shell::Program(program) => (program.clone(), &[]),            Shell::WithArguments { program, args, .. } => (program.clone(), args),            Shell::System => (get_system_shell(), &[]),        }    }     pub fn shell_kind(&self) -> Option<ShellKind> {        match self {            Shell::Program(program) => ShellKind::new(program),            Shell::WithArguments { program, .. } => ShellKind::new(program),            Shell::System => ShellKind::system(),        }    }} /// Specific POSIX-compatible shell variants.#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]pub enum PosixShell {    #[default]    Sh,    Bash,    Zsh,    Dash,    Ksh,    Mksh,    Ash,} impl PosixShell {    pub fn name(&self) -> &'static str {        match self {            PosixShell::Sh => "sh",            PosixShell::Bash => "bash",            PosixShell::Zsh => "zsh",            PosixShell::Dash => "dash",            PosixShell::Ksh => "ksh",            PosixShell::Mksh => "mksh",            PosixShell::Ash => "ash",        }    }} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]pub enum ShellKind {    Posix(PosixShell),    Csh,    Tcsh,    Rc,    Fish,    /// Pre-installed "legacy" powershell for windows    PowerShell,    /// PowerShell 7.x    Pwsh,    Nushell,    Cmd,    Xonsh,    Elvish,} pub fn get_system_shell() -> String {    if cfg!(windows) {        get_windows_system_shell()    } else {        std::env::var("SHELL").unwrap_or("/bin/sh".to_string())    }} pub fn get_default_system_shell() -> String {    if cfg!(windows) {        get_windows_system_shell()    } else {        "/bin/sh".to_string()    }} /// Get the default system shell, preferring bash on Windows.pub fn get_default_system_shell_preferring_bash() -> String {    if cfg!(windows) {        get_windows_bash().unwrap_or_else(|| get_windows_system_shell())    } else {        "/bin/sh".to_string()    }} pub fn get_windows_bash() -> Option<String> {    use std::path::PathBuf;     fn find_bash_in_scoop() -> Option<PathBuf> {        let bash_exe =            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe");        bash_exe.exists().then_some(bash_exe)    }     fn find_bash_in_git() -> Option<PathBuf> {        // /path/to/git/cmd/git.exe/../../bin/bash.exe        let git = which::which("git").ok()?;        let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");        git_bash.exists().then_some(git_bash)    }     static BASH: LazyLock<Option<String>> = LazyLock::new(|| {        let bash = find_bash_in_scoop()            .or_else(|| find_bash_in_git())            .map(|p| p.to_string_lossy().into_owned());        if let Some(ref path) = bash {            log::info!("Found bash at {}", path);        }        bash    });     (*BASH).clone()} pub fn get_windows_system_shell() -> String {    use std::path::PathBuf;     fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {        #[cfg(target_pointer_width = "64")]        let env_var = if find_alternate {            "ProgramFiles(x86)"        } else {            "ProgramFiles"        };         #[cfg(target_pointer_width = "32")]        let env_var = if find_alternate {            "ProgramW6432"        } else {            "ProgramFiles"        };         let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");        install_base_dir            .read_dir()            .ok()?            .filter_map(Result::ok)            .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))            .filter_map(|entry| {                let dir_name = entry.file_name();                let dir_name = dir_name.to_string_lossy();                 let version = if find_preview {                    let dash_index = dir_name.find('-')?;                    if &dir_name[dash_index + 1..] != "preview" {                        return None;                    };                    dir_name[..dash_index].parse::<u32>().ok()?                } else {                    dir_name.parse::<u32>().ok()?                };                 let exe_path = entry.path().join("pwsh.exe");                if exe_path.exists() {                    Some((version, exe_path))                } else {                    None                }            })            .max_by_key(|(version, _)| *version)            .map(|(_, path)| path)    }     fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {        let msix_app_dir =            PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");        if !msix_app_dir.exists() {            return None;        }         let prefix = if find_preview {            "Microsoft.PowerShellPreview_"        } else {            "Microsoft.PowerShell_"        };        msix_app_dir            .read_dir()            .ok()?            .filter_map(|entry| {                let entry = entry.ok()?;                if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {                    return None;                }                 if !entry.file_name().to_string_lossy().starts_with(prefix) {                    return None;                }                 let exe_path = entry.path().join("pwsh.exe");                exe_path.exists().then_some(exe_path)            })            .next()    }     fn find_pwsh_in_scoop() -> Option<PathBuf> {        let pwsh_exe =            PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");        pwsh_exe.exists().then_some(pwsh_exe)    }     static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {        let locations = [            || find_pwsh_in_programfiles(false, false),            || find_pwsh_in_programfiles(true, false),            || find_pwsh_in_msix(false),            || find_pwsh_in_programfiles(false, true),            || find_pwsh_in_msix(true),            || find_pwsh_in_programfiles(true, true),            || find_pwsh_in_scoop(),            || which::which_global("pwsh.exe").ok(),            || which::which_global("powershell.exe").ok(),        ];         locations            .into_iter()            .find_map(|f| f())            .map(|p| p.to_string_lossy().trim().to_owned())            .inspect(|shell| log::info!("Found powershell in: {}", shell))            .unwrap_or_else(|| {                log::warn!("Powershell not found, falling back to `cmd`");                "cmd.exe".to_string()            })    });     (*SYSTEM_SHELL).clone()} impl ShellKind {    /// Returns the canonical shell kind for activation script lookups.    ///    /// This normalizes all POSIX shell variants to `Posix(Sh)` so that    /// activation scripts stored with a single POSIX key can be found    /// regardless of which specific POSIX shell the user has.    pub fn activation_script_key(&self) -> ShellKind {        match self {            ShellKind::Posix(_) => ShellKind::Posix(PosixShell::Sh),            other => *other,        }    }     /// Creates a ShellKind from a program path, with a platform-aware fallback for unknown shells.    ///    /// Unlike `new()` which returns `None` for unrecognized shells, this method    /// falls back to `PowerShell` when `is_windows` is true, or `Posix(Sh)` otherwise.    /// This is useful for remote connections where we know the target platform but    /// may not recognize the specific shell.    pub fn new_with_fallback(program: impl AsRef<Path>, is_windows: bool) -> Self {        Self::new(program).unwrap_or_else(|| {            if is_windows {                ShellKind::PowerShell            } else {                ShellKind::Posix(PosixShell::Sh)            }        })    }     /// Returns the name of the shell.    pub fn name(&self) -> &'static str {        match self {            ShellKind::Posix(posix) => posix.name(),            ShellKind::Csh => "csh",            ShellKind::Tcsh => "tcsh",            ShellKind::Fish => "fish",            ShellKind::PowerShell => "powershell",            ShellKind::Pwsh => "pwsh",            ShellKind::Nushell => "nu",            ShellKind::Cmd => "cmd",            ShellKind::Rc => "rc",            ShellKind::Xonsh => "xonsh",            ShellKind::Elvish => "elvish",        }    }     pub fn system() -> Option<Self> {        Self::new(&get_system_shell())    }     /// Returns whether this shell's command chaining syntax can be parsed by brush-parser.    ///    /// This is used to determine if we can safely parse shell commands to extract sub-commands    /// for security purposes (e.g., preventing shell injection in "always allow" patterns).    ///    /// The brush-parser handles `;` (sequential execution) and `|` (piping), which are    /// supported by all common shells. It also handles `&&` and `||` for conditional    /// execution, `$()` and backticks for command substitution, and process substitution.    ///    /// # Security Note    ///    /// Only explicitly recognized shells return `true`. Unknown shells (None) return `false` to    /// prevent security bypasses - if we don't know a shell's syntax, we can't safely    /// parse it for `always_allow` patterns.    ///    /// # Shell Notes    ///    /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean    ///   operators on values (e.g., `$true and $false`), not command chaining operators.    /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does    ///   not have `&&` or `||` operators. Its `and`/`or` are special commands that operate    ///   on values, not command chaining (e.g., `and $true $false`).    /// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not    ///   have `&&`/`||` operators for conditional chaining.    pub fn supports_posix_chaining(&self) -> bool {        match self {            ShellKind::Posix(_)            | ShellKind::Fish            | ShellKind::PowerShell            | ShellKind::Pwsh            | ShellKind::Cmd            | ShellKind::Xonsh            | ShellKind::Csh            | ShellKind::Tcsh            | ShellKind::Nushell            | ShellKind::Elvish            | ShellKind::Rc => true,        }    }     pub fn new(program: impl AsRef<Path>) -> Option<Self> {        match program.as_ref().file_stem().and_then(OsStr::to_str)? {            "powershell" => Some(ShellKind::PowerShell),            "pwsh" => Some(ShellKind::Pwsh),            "cmd" => Some(ShellKind::Cmd),            "nu" => Some(ShellKind::Nushell),            "fish" => Some(ShellKind::Fish),            "csh" => Some(ShellKind::Csh),            "tcsh" => Some(ShellKind::Tcsh),            "rc" => Some(ShellKind::Rc),            "xonsh" => Some(ShellKind::Xonsh),            "elvish" => Some(ShellKind::Elvish),            "sh" => Some(ShellKind::Posix(PosixShell::Sh)),            "bash" => Some(ShellKind::Posix(PosixShell::Bash)),            "zsh" => Some(ShellKind::Posix(PosixShell::Zsh)),            "dash" => Some(ShellKind::Posix(PosixShell::Dash)),            "ksh" => Some(ShellKind::Posix(PosixShell::Ksh)),            "mksh" => Some(ShellKind::Posix(PosixShell::Mksh)),            "ash" => Some(ShellKind::Posix(PosixShell::Ash)),            // Unrecognized shell - we cannot safely parse its syntax for            // `always_allow` patterns, so they will be disabled.            // Fall back to platform-specific behavior for non-security purposes.            _ => None,        }    }     pub fn to_shell_variable(&self, input: &str) -> String {        match self {            Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),            Self::Cmd => Self::to_cmd_variable(input),            Self::Posix(_) => input.to_owned(),            Self::Fish => input.to_owned(),            Self::Csh => input.to_owned(),            Self::Tcsh => input.to_owned(),            Self::Rc => input.to_owned(),            Self::Nushell => Self::to_nushell_variable(input),            Self::Xonsh => input.to_owned(),            Self::Elvish => input.to_owned(),        }    }     fn to_cmd_variable(input: &str) -> String {        if let Some(var_str) = input.strip_prefix("${") {            if var_str.find(':').is_none() {                // If the input starts with "${", remove the trailing "}"                format!("%{}%", &var_str[..var_str.len() - 1])            } else {                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,                // which will result in the task failing to run in such cases.                input.into()            }        } else if let Some(var_str) = input.strip_prefix('$') {            // If the input starts with "$", directly append to "$env:"            format!("%{}%", var_str)        } else {            // If no prefix is found, return the input as is            input.into()        }    }     pub fn to_powershell_variable(input: &str) -> String {        if let Some(var_str) = input.strip_prefix("${") {            if var_str.find(':').is_none() {                // If the input starts with "${", remove the trailing "}"                format!("$env:{}", &var_str[..var_str.len() - 1])            } else {                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,                // which will result in the task failing to run in such cases.                input.into()            }        } else if let Some(var_str) = input.strip_prefix('$') {            // If the input starts with "$", directly append to "$env:"            format!("$env:{}", var_str)        } else {            // If no prefix is found, return the input as is            input.into()        }    }     fn to_nushell_variable(input: &str) -> String {        let mut result = String::new();        let mut source = input;        let mut is_start = true;         loop {            match source.chars().next() {                None => return result,                Some('$') => {                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);                    is_start = false;                }                Some(_) => {                    is_start = false;                    let chunk_end = source.find('$').unwrap_or(source.len());                    let (chunk, rest) = source.split_at(chunk_end);                    result.push_str(chunk);                    source = rest;                }            }        }    }     fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {        if source.starts_with("env.") {            text.push('$');            return source;        }         match source.chars().next() {            Some('{') => {                let source = &source[1..];                if let Some(end) = source.find('}') {                    let var_name = &source[..end];                    if !var_name.is_empty() {                        if !is_start {                            text.push_str("(");                        }                        text.push_str("$env.");                        text.push_str(var_name);                        if !is_start {                            text.push_str(")");                        }                        &source[end + 1..]                    } else {                        text.push_str("${}");                        &source[end + 1..]                    }                } else {                    text.push_str("${");                    source                }            }            Some(c) if c.is_alphabetic() || c == '_' => {                let end = source                    .find(|c: char| !c.is_alphanumeric() && c != '_')                    .unwrap_or(source.len());                let var_name = &source[..end];                if !is_start {                    text.push_str("(");                }                text.push_str("$env.");                text.push_str(var_name);                if !is_start {                    text.push_str(")");                }                &source[end..]            }            _ => {                text.push('$');

Lines 1–500 of 1173.