blob: 6969144cd2402144a46d66e8de29d24ed7187866 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::path::PathBuf;
/// Selected once, so the advertised dialect and executed command cannot drift.
#[derive(Clone)]
pub(crate) enum ShellPlan {
#[cfg(unix)]
Posix { path: PathBuf, kind: PosixShell },
#[cfg(windows)]
Pwsh(PathBuf),
#[cfg(windows)]
WindowsPowerShell(PathBuf),
#[cfg(windows)]
Cmd(PathBuf),
}
impl ShellPlan {
pub(crate) fn command(
&self,
cwd: &std::path::Path,
source: &str,
terminal: bool,
login: bool,
) -> crate::Command {
use crate::Command;
match self {
#[cfg(unix)]
Self::Posix { path, .. } => {
let _ = terminal;
let mut command = Command::new(path, cwd);
command.args([if login { "-lc" } else { "-c" }, source]);
command
}
#[cfg(windows)]
Self::Pwsh(path) | Self::WindowsPowerShell(path) => {
let mut plan = Command::new(path, cwd);
plan.arg("-NoLogo");
if !login {
plan.arg("-NoProfile");
}
if !terminal {
plan.arg("-NonInteractive");
}
plan.args(["-Command", command::WRAPPER])
.env(command::SLOT, format!("{source}{}", command::EXIT));
plan
}
#[cfg(windows)]
Self::Cmd(path) => {
let mut command = Command::new(path, cwd);
command.raw_arg(format!("/d /s /c \"{source}\""));
command
}
}
}
pub(crate) fn interactive(&self, cwd: &std::path::Path) -> (String, crate::pty::PtyCommand) {
use crate::pty::PtyCommand;
let (source, mut command) = match self {
#[cfg(unix)]
Self::Posix { path, .. } => {
let source = "exec \"$SHELL\" -l";
let mut command = PtyCommand::new(path, cwd);
command.arg("-l").env("SHELL", path);
(source.into(), command)
}
#[cfg(windows)]
Self::Pwsh(path) | Self::WindowsPowerShell(path) => {
let source = format!("& '{}' -NoLogo", path.to_string_lossy().replace('\'', "''"));
let mut command = PtyCommand::new(path, cwd);
command.arg("-NoLogo");
(source, command)
}
#[cfg(windows)]
Self::Cmd(path) => {
let mut command = PtyCommand::new(path, cwd);
command.args(["/d", "/q"]);
("%ComSpec% /d /q".into(), command)
}
};
command
.env("DISABLE_AUTO_UPDATE", "true")
.env("DISABLE_UPDATE_PROMPT", "true");
(source, command)
}
pub(crate) fn detect() -> Self {
#[cfg(unix)]
return select_posix(account_shell().map(PathBuf::from), find_posix);
#[cfg(windows)]
{
fn find(name: &str, fallback: Option<PathBuf>) -> Option<PathBuf> {
std::env::var_os("PATH")
.into_iter()
.flat_map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.map(|directory| directory.join(name))
.chain(fallback)
.find_map(|path| {
path.is_file()
.then(|| dunce::canonicalize(path).ok())
.flatten()
})
}
if let Some(path) = find(
"pwsh.exe",
std::env::var_os("ProgramFiles")
.map(|root| PathBuf::from(root).join(r"PowerShell\7\pwsh.exe")),
) {
return Self::Pwsh(path);
}
let system = PathBuf::from(
std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()),
);
if let Some(path) = find(
"powershell.exe",
Some(system.join(r"System32\WindowsPowerShell\v1.0\powershell.exe")),
) {
return Self::WindowsPowerShell(path);
}
Self::Cmd(system.join(r"System32\cmd.exe"))
}
}
pub(crate) fn guidance(&self) -> String {
match self {
#[cfg(unix)]
Self::Posix { path, kind } => {
format!(
"Commands use {}; write {} syntax. Login profiles load by default; set login=false to skip them.",
path.display(),
kind.syntax(),
)
}
#[cfg(windows)]
Self::Pwsh(_) => {
"Commands use PowerShell 7 (pwsh); write PowerShell syntax. Set login=false to skip profiles.".into()
}
#[cfg(windows)]
Self::WindowsPowerShell(_) => {
"Commands use Windows PowerShell 5.1; write PowerShell 5.1-compatible syntax. Set login=false to skip profiles.".into()
}
#[cfg(windows)]
Self::Cmd(_) => "Commands use cmd.exe; write cmd syntax, not bash syntax.".into(),
}
}
}
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PosixShell {
Zsh,
Bash,
Sh,
}
#[cfg(unix)]
impl PosixShell {
fn from_path(path: &std::path::Path) -> Option<Self> {
match path.file_name()?.to_str()? {
"zsh" => Some(Self::Zsh),
"bash" => Some(Self::Bash),
"sh" => Some(Self::Sh),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::Zsh => "zsh",
Self::Bash => "bash",
Self::Sh => "sh",
}
}
fn syntax(self) -> &'static str {
match self {
Self::Zsh => "zsh",
Self::Bash => "Bash",
Self::Sh => "POSIX shell",
}
}
}
#[cfg(unix)]
fn executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.is_absolute()
&& std::fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(unix)]
fn find_posix(kind: PosixShell) -> Option<PathBuf> {
std::env::var_os("PATH")
.into_iter()
.flat_map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.map(|directory| directory.join(kind.name()))
.chain(["/bin", "/usr/bin"].map(|directory| PathBuf::from(directory).join(kind.name())))
.find(|path| executable(path))
}
#[cfg(unix)]
fn select_posix(
account: Option<PathBuf>,
mut find: impl FnMut(PosixShell) -> Option<PathBuf>,
) -> ShellPlan {
if let Some(path) = account.filter(|path| executable(path))
&& let Some(kind) = PosixShell::from_path(&path)
{
return ShellPlan::Posix { path, kind };
}
let order = if cfg!(target_os = "macos") {
[PosixShell::Zsh, PosixShell::Bash]
} else {
[PosixShell::Bash, PosixShell::Zsh]
};
for kind in order {
if let Some(path) = find(kind) {
return ShellPlan::Posix { path, kind };
}
}
ShellPlan::Posix {
path: "/bin/sh".into(),
kind: PosixShell::Sh,
}
}
#[cfg(unix)]
fn account_shell() -> Option<std::ffi::OsString> {
use std::{ffi::CStr, os::unix::ffi::OsStrExt};
let mut buffer = vec![0u8; 65_536];
let mut entry = std::mem::MaybeUninit::<libc::passwd>::uninit();
let mut result = std::ptr::null_mut();
// SAFETY: output pointers and scratch storage remain live through copying
// pw_shell; no borrowed libc account memory escapes this function.
unsafe {
if libc::getpwuid_r(
libc::geteuid(),
entry.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
) != 0
|| result.is_null()
{
return None;
}
let entry = entry.assume_init();
if entry.pw_shell.is_null() {
return None;
}
let bytes = CStr::from_ptr(entry.pw_shell).to_bytes();
(!bytes.is_empty()).then(|| std::ffi::OsStr::from_bytes(bytes).to_owned())
}
}
#[cfg(windows)]
pub(crate) mod command {
use std::{os::windows::ffi::OsStrExt, path::Path};
pub(super) const SLOT: &str = "__MAKA_RUNTIME_POWERSHELL_COMMAND";
// A read-only TEMP can put Windows PowerShell in ConstrainedLanguage.
// Invoke-Expression parses in the current language mode; unlike
// ScriptBlock::Create it does not require FullLanguage merely to launch a
// native command. Never override machine application-control policy.
pub(super) const WRAPPER: &str = r#"
if ($ExecutionContext.SessionState.LanguageMode -eq 'FullLanguage') {
$__makaUtf8 = [System.Text.UTF8Encoding]::new($false)
[Console]::InputEncoding = $__makaUtf8
[Console]::OutputEncoding = $__makaUtf8
$OutputEncoding = $__makaUtf8
} else {
& "$env:SystemRoot\System32\chcp.com" 65001 > $null
$OutputEncoding = [System.Text.Encoding]::UTF8
}
$__makaCommandText = $env:__MAKA_RUNTIME_POWERSHELL_COMMAND
Remove-Item Env:\__MAKA_RUNTIME_POWERSHELL_COMMAND
Invoke-Expression $__makaCommandText
"#;
pub(super) const EXIT: &str = "\n$__makaOk = $?\nif (-not $__makaOk) { if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } else { exit 1 } }";
pub(crate) fn wide(path: &Path) -> std::io::Result<Vec<u16>> {
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(std::io::Error::other("NUL in process input"));
}
value.push(0);
Ok(value)
}
pub(crate) fn quote(value: &str) -> String {
let mut quoted = String::from("\"");
let mut slashes = 0;
for ch in value.chars() {
if ch == '\\' {
slashes += 1;
continue;
}
quoted.extend(std::iter::repeat_n(
'\\',
if ch == '"' { slashes * 2 + 1 } else { slashes },
));
quoted.push(ch);
slashes = 0;
}
quoted.extend(std::iter::repeat_n('\\', slashes * 2));
quoted.push('"');
quoted
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn account_selection_and_fallback_keep_guidance_pipes_and_pty_on_one_shell() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("bash");
std::fs::write(&path, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
let selected = select_posix(Some(path.clone()), |_| panic!("account shell wins"));
assert!(selected.guidance().contains(path.to_str().unwrap()));
assert!(selected.guidance().contains("Bash syntax"));
for terminal in [false, true] {
for login in [false, true] {
let command = selected.command(directory.path(), "echo ok", terminal, login);
assert_eq!(command.executable, path);
assert_eq!(command.args[0].as_ref(), if login { "-lc" } else { "-c" });
assert_eq!(command.args[1].as_ref(), "echo ok");
}
}
let (_, interactive) = selected.interactive(directory.path());
assert_eq!(interactive.executable, path);
assert_eq!(interactive.args[0].as_ref(), "-l");
let unsupported = directory.path().join("fish");
std::fs::copy(&path, &unsupported).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
for account in [None, Some(path), Some(unsupported)] {
let mut attempted = Vec::new();
let selected = select_posix(account, |kind| {
attempted.push(kind);
None
});
let expected = if cfg!(target_os = "macos") {
[PosixShell::Zsh, PosixShell::Bash]
} else {
[PosixShell::Bash, PosixShell::Zsh]
};
assert_eq!(attempted, expected);
assert!(matches!(
selected,
ShellPlan::Posix {
kind: PosixShell::Sh,
..
}
));
}
for available in [PosixShell::Bash, PosixShell::Zsh] {
let selected = select_posix(None, |kind| {
(kind == available).then(|| PathBuf::from("/bin").join(kind.name()))
});
assert!(matches!(selected, ShellPlan::Posix { kind, .. } if kind == available));
}
}
}