2314 lines
78 KiB
Rust
2314 lines
78 KiB
Rust
//! The `msudo` command launches Windows processes with elevated identities.
|
|
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::{
|
|
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
|
|
print_json, print_quick_help_error, print_structured, print_text,
|
|
};
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
use serde::{Deserialize, Serialize};
|
|
use windowsupport::{
|
|
LaunchIdentity, LaunchRequest, LaunchResult, PrivilegeMode, ProcessPriority, ShowWindowMode,
|
|
TokenIntegrity, TokenStatus,
|
|
};
|
|
|
|
const HELP: &str = "\
|
|
Launch Windows commands as admin, SYSTEM, or TrustedInstaller with Mercury-friendly output.
|
|
|
|
Windows only. This tool is intentionally high risk. `--user system` and `--user trustedinstaller`
|
|
require `--dangerous`.
|
|
|
|
Usage:
|
|
msudo [OPTIONS] [--] <COMMAND...>
|
|
msudo run [OPTIONS] [--] <COMMAND...>
|
|
msudo status [OPTIONS]
|
|
msudo [OPTIONS] --shell <PRESET>
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
--user <IDENTITY> Target identity: current-process, current-user, admin, system, trustedinstaller
|
|
--dangerous Required for system or trustedinstaller launches
|
|
--shell <PRESET> Shell preset: cmd, powershell, pwsh, wsl, git-bash, mingw, msys2, cygwin, yori, tcc, nu
|
|
--integrity <LEVEL> Integrity: untrusted, low, medium, medium-plus, high, system
|
|
--privileges <MODE> Privilege policy: default, enable-all, disable-all
|
|
--priority <CLASS> Priority: idle, below-normal, normal, above-normal, high, realtime
|
|
--show-window <MODE> Window mode: default, hidden, normal, minimized, maximized
|
|
--session <ID> Target session id for duplicated tokens
|
|
--current-directory <PATH> Working directory for the launched process
|
|
--same-console Reuse the current console in the foreground and wait for the child
|
|
--new-window Force a new window for the launched process
|
|
--wait Wait for the launched process and return its exit code
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
msudo status --json | ConvertFrom-Json
|
|
msudo --user admin -- cmd /d /c whoami
|
|
msudo --user system --dangerous -- cmd /d /c whoami
|
|
msudo --same-console --user system --dangerous --shell powershell
|
|
msudo --shell pwsh
|
|
";
|
|
|
|
const STATUS_HELP: &str = "\
|
|
Show the current token state, runas availability, and shell preset resolution.
|
|
|
|
Usage:
|
|
msudo status [OPTIONS]
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--quiet Suppress shell inventory in text output
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
msudo status --json | ConvertFrom-Json
|
|
";
|
|
|
|
const RUN_HELP: &str = "\
|
|
Run a command or shell with the requested elevated identity.
|
|
|
|
Usage:
|
|
msudo run [OPTIONS] [--] <COMMAND...>
|
|
msudo [OPTIONS] [--] <COMMAND...>
|
|
msudo [OPTIONS] --shell <PRESET>
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Emit JSON launch results instead of compact text output
|
|
--toon Shortcut for --format toon
|
|
--quiet Suppress non-essential text output
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--user <IDENTITY> Target identity: current-process, current-user, admin, system, trustedinstaller
|
|
--dangerous Required for system or trustedinstaller launches
|
|
--shell <PRESET> Shell preset: cmd, powershell, pwsh, wsl, git-bash, mingw, msys2, cygwin, yori, tcc, nu
|
|
--integrity <LEVEL> Integrity: untrusted, low, medium, medium-plus, high, system
|
|
--privileges <MODE> Privilege policy: default, enable-all, disable-all
|
|
--priority <CLASS> Priority: idle, below-normal, normal, above-normal, high, realtime
|
|
--show-window <MODE> Window mode: default, hidden, normal, minimized, maximized
|
|
--session <ID> Target session id for duplicated tokens
|
|
--current-directory <PATH> Working directory for the launched process
|
|
--same-console Reuse the current console in the foreground and wait for the child
|
|
--new-window Force a new window for the launched process
|
|
--wait Wait for the launched process and return its exit code
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Examples:
|
|
msudo --user admin -- cmd /d /c whoami
|
|
msudo --user system --dangerous -- cmd /d /c whoami
|
|
msudo --same-console --user system --dangerous --shell powershell
|
|
msudo --shell pwsh
|
|
|
|
Interactive shells open in a new window by default; use --same-console to keep them in the
|
|
current terminal and wait in the foreground.
|
|
";
|
|
|
|
const RELAY_SUBCOMMAND: &str = "__relay";
|
|
static EXIT_CODE_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Cli {
|
|
common: CommonArgs,
|
|
command: CommandMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum CommandMode {
|
|
Help(&'static str),
|
|
Status,
|
|
Run(RunCli),
|
|
Relay(RelayCli),
|
|
}
|
|
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Debug, Clone)]
|
|
struct RunCli {
|
|
user: LaunchIdentity,
|
|
dangerous: bool,
|
|
shell: Option<ShellPreset>,
|
|
integrity: Option<TokenIntegrity>,
|
|
privileges: PrivilegeMode,
|
|
priority: ProcessPriority,
|
|
show_window: ShowWindowMode,
|
|
session: Option<u32>,
|
|
current_directory: Option<String>,
|
|
same_console: bool,
|
|
new_window: bool,
|
|
wait: bool,
|
|
command: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RelayCli {
|
|
run: RunCli,
|
|
exit_code_path: PathBuf,
|
|
stdout_path: Option<PathBuf>,
|
|
stderr_path: Option<PathBuf>,
|
|
console_pid: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
struct RelayExitStatus {
|
|
ok: bool,
|
|
exit_code: Option<i32>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
enum ShellPreset {
|
|
#[serde(rename = "cmd")]
|
|
Cmd,
|
|
#[serde(rename = "powershell")]
|
|
PowerShell,
|
|
#[serde(rename = "pwsh")]
|
|
Pwsh,
|
|
#[serde(rename = "wsl")]
|
|
Wsl,
|
|
#[serde(rename = "git-bash")]
|
|
GitBash,
|
|
#[serde(rename = "mingw")]
|
|
Mingw,
|
|
#[serde(rename = "msys2")]
|
|
Msys2,
|
|
#[serde(rename = "cygwin")]
|
|
Cygwin,
|
|
#[serde(rename = "yori")]
|
|
Yori,
|
|
#[serde(rename = "tcc")]
|
|
Tcc,
|
|
#[serde(rename = "nu")]
|
|
Nu,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum ShellResolutionSource {
|
|
KnownLocation,
|
|
Path,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct ShellStatus {
|
|
preset: ShellPreset,
|
|
available: bool,
|
|
executable: Option<String>,
|
|
resolution_source: Option<ShellResolutionSource>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct StatusReport {
|
|
ok: bool,
|
|
host: String,
|
|
supports_runas: bool,
|
|
is_elevated: bool,
|
|
is_admin_member: bool,
|
|
integrity: TokenIntegrity,
|
|
current_user: Option<String>,
|
|
session_id: Option<u32>,
|
|
active_session_id: Option<u32>,
|
|
can_admin: bool,
|
|
can_current_user: bool,
|
|
can_system: bool,
|
|
can_trustedinstaller: bool,
|
|
trustedinstaller_installed: bool,
|
|
trustedinstaller_running: bool,
|
|
shells: Vec<ShellStatus>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RelayLaunch {
|
|
arguments: Vec<String>,
|
|
exit_path: PathBuf,
|
|
stdout_path: Option<PathBuf>,
|
|
stderr_path: Option<PathBuf>,
|
|
}
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
#[must_use]
|
|
pub fn main_entry() -> i32 {
|
|
match parse_cli_from(std::env::args_os()) {
|
|
Ok((ParseOutcome::Help, cli)) => {
|
|
if let CommandMode::Help(help) = cli.command {
|
|
print!("{help}");
|
|
} else {
|
|
print!("{HELP}");
|
|
}
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
println!("msudo {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
Ok(code) => code,
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
},
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<i32, CliError> {
|
|
let provider = WindowsSudoProvider;
|
|
run_with_provider(cli, &provider)
|
|
}
|
|
|
|
fn run_with_provider(cli: &Cli, provider: &dyn SudoProvider) -> Result<i32, CliError> {
|
|
match &cli.command {
|
|
CommandMode::Help(help) => {
|
|
print!("{help}");
|
|
Ok(ExitCode::Success.as_i32())
|
|
}
|
|
CommandMode::Status => run_status(cli.common, provider),
|
|
CommandMode::Run(run_cli) => run_launch(cli.common.render_mode(), run_cli, provider),
|
|
CommandMode::Relay(relay) => run_relay(relay, provider),
|
|
}
|
|
}
|
|
|
|
fn run_status(common: CommonArgs, provider: &dyn SudoProvider) -> Result<i32, CliError> {
|
|
let status = provider.status()?;
|
|
let report = build_status_report(status);
|
|
match common.render_mode() {
|
|
RenderMode::Json => print_json(&report)?,
|
|
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
|
RenderMode::Text => print_text(render_status_text(&report, common.quiet))?,
|
|
}
|
|
Ok(ExitCode::Success.as_i32())
|
|
}
|
|
|
|
fn run_launch(
|
|
render_mode: RenderMode,
|
|
run_cli: &RunCli,
|
|
provider: &dyn SudoProvider,
|
|
) -> Result<i32, CliError> {
|
|
validate_run_cli(run_cli)?;
|
|
let status = provider.status()?;
|
|
let prepared = prepare_launch(run_cli)?;
|
|
if !status.is_elevated && launch_requires_elevated_relay(run_cli) {
|
|
let relay = build_relay_arguments(run_cli);
|
|
let result = provider.elevate_current_process(
|
|
&relay.arguments,
|
|
effective_wait(run_cli),
|
|
run_cli.show_window,
|
|
)?;
|
|
if effective_wait(run_cli) {
|
|
let exit_code = read_relay_exit_code(&relay.exit_path)?;
|
|
replay_relay_output(relay.stdout_path.as_deref(), relay.stderr_path.as_deref())?;
|
|
return Ok(exit_code);
|
|
}
|
|
return Ok(exit_code_from_launch(result));
|
|
}
|
|
let result = provider.launch_request(&prepared)?;
|
|
if matches!(render_mode, RenderMode::Json) {
|
|
print_json(&result)?;
|
|
}
|
|
Ok(exit_code_from_launch(result))
|
|
}
|
|
|
|
const fn launch_requires_elevated_relay(run_cli: &RunCli) -> bool {
|
|
match run_cli.user {
|
|
LaunchIdentity::CurrentProcess => false,
|
|
LaunchIdentity::CurrentUser => {
|
|
run_cli.session.is_some()
|
|
|| run_cli.integrity.is_some()
|
|
|| !matches!(run_cli.privileges, PrivilegeMode::Default)
|
|
}
|
|
LaunchIdentity::Admin | LaunchIdentity::System | LaunchIdentity::TrustedInstaller => true,
|
|
}
|
|
}
|
|
|
|
fn run_relay(relay: &RelayCli, provider: &dyn SudoProvider) -> Result<i32, CliError> {
|
|
validate_run_cli(&relay.run)?;
|
|
if relay.run.same_console {
|
|
let console_pid = relay.console_pid.ok_or_else(|| {
|
|
CliError::usage("internal same-console relay requires --console-pid <PID>")
|
|
})?;
|
|
provider.attach_parent_console(console_pid)?;
|
|
}
|
|
let mut prepared = prepare_launch(&relay.run)?;
|
|
prepared.stdout_path = relay
|
|
.stdout_path
|
|
.as_ref()
|
|
.map(|path| path.to_string_lossy().into_owned());
|
|
prepared.stderr_path = relay
|
|
.stderr_path
|
|
.as_ref()
|
|
.map(|path| path.to_string_lossy().into_owned());
|
|
match provider.launch_request(&prepared) {
|
|
Ok(result) => {
|
|
let exit_code = exit_code_from_launch(result);
|
|
write_relay_exit_status(
|
|
&relay.exit_code_path,
|
|
&RelayExitStatus {
|
|
ok: true,
|
|
exit_code: Some(exit_code),
|
|
error: None,
|
|
},
|
|
)?;
|
|
Ok(exit_code)
|
|
}
|
|
Err(error) => {
|
|
write_relay_exit_status(
|
|
&relay.exit_code_path,
|
|
&RelayExitStatus {
|
|
ok: false,
|
|
exit_code: None,
|
|
error: Some(error.to_string()),
|
|
},
|
|
)?;
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn exit_code_from_launch(result: LaunchResult) -> i32 {
|
|
result
|
|
.exit_code
|
|
.unwrap_or_else(|| ExitCode::Success.as_i32())
|
|
}
|
|
|
|
fn build_status_report(status: TokenStatus) -> StatusReport {
|
|
StatusReport {
|
|
ok: true,
|
|
host: std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()),
|
|
supports_runas: true,
|
|
is_elevated: status.is_elevated,
|
|
is_admin_member: status.is_admin_member,
|
|
integrity: status.integrity,
|
|
current_user: status.current_user,
|
|
session_id: status.session_id,
|
|
active_session_id: status.active_session_id,
|
|
can_admin: status.can_admin,
|
|
can_current_user: status.can_current_user,
|
|
can_system: status.can_system,
|
|
can_trustedinstaller: status.can_trustedinstaller,
|
|
trustedinstaller_installed: status.trustedinstaller_installed,
|
|
trustedinstaller_running: status.trustedinstaller_running,
|
|
shells: ShellPreset::all()
|
|
.into_iter()
|
|
.map(resolve_shell_status)
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn render_status_text(report: &StatusReport, quiet: bool) -> String {
|
|
let mut rendered = String::new();
|
|
let _ = writeln!(
|
|
rendered,
|
|
"host={} elevated={} admin_member={} integrity={:?}",
|
|
report.host, report.is_elevated, report.is_admin_member, report.integrity
|
|
);
|
|
let _ = writeln!(
|
|
rendered,
|
|
"runas={} current_user={} system={} trustedinstaller={} session={} active_session={}",
|
|
report.supports_runas,
|
|
report.can_current_user,
|
|
report.can_system,
|
|
report.can_trustedinstaller,
|
|
format_optional_u32(report.session_id),
|
|
format_optional_u32(report.active_session_id)
|
|
);
|
|
if !quiet {
|
|
for shell in &report.shells {
|
|
let _ = writeln!(
|
|
rendered,
|
|
"shell={} available={} executable={} source={}",
|
|
shell.preset.name(),
|
|
shell.available,
|
|
shell.executable.as_deref().unwrap_or("-"),
|
|
shell
|
|
.resolution_source
|
|
.map_or("-", shell_resolution_source_name),
|
|
);
|
|
}
|
|
}
|
|
rendered
|
|
}
|
|
|
|
fn validate_run_cli(run_cli: &RunCli) -> Result<(), CliError> {
|
|
if matches!(
|
|
run_cli.user,
|
|
LaunchIdentity::System | LaunchIdentity::TrustedInstaller
|
|
) && !run_cli.dangerous
|
|
{
|
|
return Err(CliError::usage(
|
|
"--user system and --user trustedinstaller require --dangerous",
|
|
));
|
|
}
|
|
if run_cli.command.is_empty() && run_cli.shell.is_none() {
|
|
return Err(CliError::usage(
|
|
"provide a command after -- or select --shell <preset>",
|
|
));
|
|
}
|
|
if run_cli.same_console && run_cli.new_window {
|
|
return Err(CliError::usage(
|
|
"--same-console cannot be combined with --new-window",
|
|
));
|
|
}
|
|
if matches!(run_cli.user, LaunchIdentity::CurrentProcess)
|
|
&& (run_cli.integrity.is_some()
|
|
|| !matches!(run_cli.privileges, PrivilegeMode::Default)
|
|
|| run_cli.session.is_some())
|
|
{
|
|
return Err(CliError::usage(
|
|
"--user current-process cannot be combined with token shaping options",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_launch(run_cli: &RunCli) -> Result<LaunchRequest, CliError> {
|
|
let interactive_shell = is_interactive_shell(run_cli);
|
|
let current_directory = effective_current_directory(run_cli);
|
|
let prepared_command = if run_cli.command.is_empty() {
|
|
prepare_interactive_shell(run_cli.shell.expect("shell checked in validate"))?
|
|
} else if let Some(preset) = run_cli.shell {
|
|
prepare_shell_wrapped_command(preset, &run_cli.command)?
|
|
} else {
|
|
prepare_direct_command(
|
|
&run_cli.command[0],
|
|
&run_cli.command[1..],
|
|
run_cli.user,
|
|
std::env::var_os("PATH").as_deref(),
|
|
std::env::var_os("PATHEXT").as_deref(),
|
|
)?
|
|
};
|
|
let new_window = effective_new_window(run_cli, interactive_shell);
|
|
Ok(LaunchRequest {
|
|
program: prepared_command.program,
|
|
args: prepared_command.args,
|
|
current_directory,
|
|
stdout_path: None,
|
|
stderr_path: None,
|
|
identity: run_cli.user,
|
|
privileges: run_cli.privileges,
|
|
integrity: run_cli.integrity,
|
|
priority: run_cli.priority,
|
|
show_window: run_cli.show_window,
|
|
session: run_cli.session,
|
|
same_console: run_cli.same_console,
|
|
new_window,
|
|
wait: effective_wait(run_cli),
|
|
})
|
|
}
|
|
|
|
fn is_interactive_shell(run_cli: &RunCli) -> bool {
|
|
run_cli.command.is_empty() && run_cli.shell.is_some()
|
|
}
|
|
|
|
const fn effective_wait(run_cli: &RunCli) -> bool {
|
|
run_cli.wait || run_cli.same_console
|
|
}
|
|
|
|
fn effective_current_directory(run_cli: &RunCli) -> Option<String> {
|
|
run_cli.current_directory.clone().or_else(|| {
|
|
std::env::current_dir()
|
|
.ok()
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
})
|
|
}
|
|
|
|
const fn effective_new_window(run_cli: &RunCli, interactive_shell: bool) -> bool {
|
|
if run_cli.same_console {
|
|
false
|
|
} else {
|
|
run_cli.new_window || interactive_shell
|
|
}
|
|
}
|
|
|
|
fn build_relay_arguments(run_cli: &RunCli) -> RelayLaunch {
|
|
let exit_path = unique_exit_code_path();
|
|
let (stdout_path, stderr_path) = if effective_wait(run_cli) && !is_interactive_shell(run_cli) {
|
|
(Some(unique_exit_code_path()), Some(unique_exit_code_path()))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
let mut arguments = vec![
|
|
RELAY_SUBCOMMAND.to_string(),
|
|
"--user".to_string(),
|
|
launch_identity_name(run_cli.user).to_string(),
|
|
"--exit-code-path".to_string(),
|
|
exit_path.display().to_string(),
|
|
];
|
|
if run_cli.dangerous {
|
|
arguments.push("--dangerous".to_string());
|
|
}
|
|
if let Some(shell) = run_cli.shell {
|
|
arguments.push("--shell".to_string());
|
|
arguments.push(shell.name().to_string());
|
|
}
|
|
if let Some(integrity) = run_cli.integrity {
|
|
arguments.push("--integrity".to_string());
|
|
arguments.push(token_integrity_name(integrity).to_string());
|
|
}
|
|
if !matches!(run_cli.privileges, PrivilegeMode::Default) {
|
|
arguments.push("--privileges".to_string());
|
|
arguments.push(privilege_mode_name(run_cli.privileges).to_string());
|
|
}
|
|
if !matches!(run_cli.priority, ProcessPriority::Normal) {
|
|
arguments.push("--priority".to_string());
|
|
arguments.push(priority_name(run_cli.priority).to_string());
|
|
}
|
|
if !matches!(run_cli.show_window, ShowWindowMode::Default) {
|
|
arguments.push("--show-window".to_string());
|
|
arguments.push(show_window_name(run_cli.show_window).to_string());
|
|
}
|
|
if let Some(session) = run_cli.session {
|
|
arguments.push("--session".to_string());
|
|
arguments.push(session.to_string());
|
|
}
|
|
if let Some(current_directory) = effective_current_directory(run_cli) {
|
|
arguments.push("--current-directory".to_string());
|
|
arguments.push(current_directory);
|
|
}
|
|
if run_cli.same_console {
|
|
arguments.push("--same-console".to_string());
|
|
arguments.push("--console-pid".to_string());
|
|
arguments.push(std::process::id().to_string());
|
|
}
|
|
if let Some(path) = &stdout_path {
|
|
arguments.push("--stdout-path".to_string());
|
|
arguments.push(path.display().to_string());
|
|
}
|
|
if let Some(path) = &stderr_path {
|
|
arguments.push("--stderr-path".to_string());
|
|
arguments.push(path.display().to_string());
|
|
}
|
|
if run_cli.new_window {
|
|
arguments.push("--new-window".to_string());
|
|
}
|
|
if effective_wait(run_cli) {
|
|
arguments.push("--wait".to_string());
|
|
}
|
|
if !run_cli.command.is_empty() {
|
|
arguments.push("--".to_string());
|
|
arguments.extend(run_cli.command.iter().cloned());
|
|
}
|
|
RelayLaunch {
|
|
arguments,
|
|
exit_path,
|
|
stdout_path,
|
|
stderr_path,
|
|
}
|
|
}
|
|
|
|
fn unique_exit_code_path() -> PathBuf {
|
|
let temp_dir = std::env::temp_dir();
|
|
for _ in 0..32 {
|
|
let unique = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |value| value.as_nanos());
|
|
let counter = EXIT_CODE_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let candidate = temp_dir.join(format!(
|
|
"msudo-exit-{}-{unique}-{counter}.json",
|
|
std::process::id()
|
|
));
|
|
if !candidate.exists() {
|
|
return candidate;
|
|
}
|
|
}
|
|
let counter = EXIT_CODE_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
temp_dir.join(format!(
|
|
"msudo-exit-fallback-{}-{counter}.json",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
fn write_relay_exit_status(path: &Path, status: &RelayExitStatus) -> Result<(), CliError> {
|
|
let payload = serde_json::to_string(status).map_err(|error| {
|
|
CliError::runtime(format!("failed to encode relay exit status: {error}"))
|
|
})?;
|
|
let mut file = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
.map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to create relay exit status at {}: {error}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
file.write_all(payload.as_bytes()).map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to write relay exit status to {}: {error}",
|
|
path.display()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn read_relay_exit_code(path: &Path) -> Result<i32, CliError> {
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
let content = loop {
|
|
match fs::read_to_string(path) {
|
|
Ok(content) => break content,
|
|
Err(error)
|
|
if Instant::now() < deadline
|
|
&& matches!(
|
|
error.kind(),
|
|
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
|
|
) =>
|
|
{
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
Err(error) => {
|
|
return Err(CliError::runtime(format!(
|
|
"failed to read relay exit code from {}: {error}",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
};
|
|
let _ = fs::remove_file(path);
|
|
if let Ok(status) = serde_json::from_str::<RelayExitStatus>(content.trim()) {
|
|
if status.ok {
|
|
return status.exit_code.ok_or_else(|| {
|
|
CliError::runtime("relay exit status was missing an exit code".to_string())
|
|
});
|
|
}
|
|
return Err(CliError::runtime(status.error.unwrap_or_else(|| {
|
|
"privileged relay failed without an error message".to_string()
|
|
})));
|
|
}
|
|
content.trim().parse::<i32>().map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to parse relay exit status from {}: {error}",
|
|
path.display()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn replay_relay_output(
|
|
stdout_path: Option<&Path>,
|
|
stderr_path: Option<&Path>,
|
|
) -> Result<(), CliError> {
|
|
if let Some(path) = stdout_path {
|
|
replay_one_relay_output(path, false)?;
|
|
}
|
|
if let Some(path) = stderr_path {
|
|
replay_one_relay_output(path, true)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn replay_one_relay_output(path: &Path, stderr: bool) -> Result<(), CliError> {
|
|
let bytes = fs::read(path).map_err(|error| {
|
|
CliError::runtime(format!(
|
|
"failed to read relay output from {}: {error}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
let _ = fs::remove_file(path);
|
|
if stderr {
|
|
std::io::stderr().write_all(&bytes).map_err(|error| {
|
|
CliError::runtime(format!("failed to replay relay stderr: {error}"))
|
|
})?;
|
|
std::io::stderr()
|
|
.flush()
|
|
.map_err(|error| CliError::runtime(format!("failed to flush relay stderr: {error}")))?;
|
|
} else {
|
|
std::io::stdout().write_all(&bytes).map_err(|error| {
|
|
CliError::runtime(format!("failed to replay relay stdout: {error}"))
|
|
})?;
|
|
std::io::stdout()
|
|
.flush()
|
|
.map_err(|error| CliError::runtime(format!("failed to flush relay stdout: {error}")))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_interactive_shell(shell: ShellPreset) -> Result<PreparedCommand, CliError> {
|
|
let resolution = resolve_shell(shell)?;
|
|
Ok(PreparedCommand {
|
|
program: resolution.executable,
|
|
args: resolution.default_args,
|
|
})
|
|
}
|
|
|
|
fn prepare_shell_wrapped_command(
|
|
shell: ShellPreset,
|
|
command: &[String],
|
|
) -> Result<PreparedCommand, CliError> {
|
|
let resolution = resolve_shell(shell)?;
|
|
let shell_command = quote_for_shell(shell, command)?;
|
|
let args = match shell {
|
|
ShellPreset::Cmd | ShellPreset::Tcc | ShellPreset::Yori => {
|
|
let mut args = resolution.default_args;
|
|
args.push("/d".to_string());
|
|
args.push("/s".to_string());
|
|
args.push("/c".to_string());
|
|
args.push(shell_command);
|
|
args
|
|
}
|
|
ShellPreset::PowerShell | ShellPreset::Pwsh => {
|
|
let mut args = resolution.default_args;
|
|
args.push("-Command".to_string());
|
|
args.push(shell_command);
|
|
args
|
|
}
|
|
ShellPreset::Wsl => {
|
|
let mut args = resolution.default_args;
|
|
args.push("--".to_string());
|
|
args.extend(command.iter().cloned());
|
|
args
|
|
}
|
|
ShellPreset::GitBash | ShellPreset::Mingw | ShellPreset::Msys2 | ShellPreset::Cygwin => {
|
|
let mut args = resolution.default_args;
|
|
args.push("-lc".to_string());
|
|
args.push(shell_command);
|
|
args
|
|
}
|
|
ShellPreset::Nu => {
|
|
let mut args = resolution.default_args;
|
|
args.push("-c".to_string());
|
|
args.push(shell_command);
|
|
args
|
|
}
|
|
};
|
|
Ok(PreparedCommand {
|
|
program: resolution.executable,
|
|
args,
|
|
})
|
|
}
|
|
|
|
fn prepare_direct_command(
|
|
program: &str,
|
|
args: &[String],
|
|
identity: LaunchIdentity,
|
|
path: Option<&OsStr>,
|
|
pathext: Option<&OsStr>,
|
|
) -> Result<PreparedCommand, CliError> {
|
|
let resolution = resolve_direct_command_from_path(program, path, pathext);
|
|
validate_direct_command_policy(program, identity, resolution.source)?;
|
|
Ok(PreparedCommand {
|
|
program: resolution.executable,
|
|
args: args.to_vec(),
|
|
})
|
|
}
|
|
|
|
fn quote_for_shell(shell: ShellPreset, values: &[String]) -> Result<String, CliError> {
|
|
match shell {
|
|
ShellPreset::PowerShell | ShellPreset::Pwsh => Ok(quote_powershell_invocation(values)),
|
|
ShellPreset::Cmd | ShellPreset::Tcc | ShellPreset::Yori => Ok(values
|
|
.iter()
|
|
.map(|value| quote_windows_style(value))
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.join(" ")),
|
|
_ => Ok(values
|
|
.iter()
|
|
.map(|value| quote_posix_style(value))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")),
|
|
}
|
|
}
|
|
|
|
fn quote_powershell_invocation(values: &[String]) -> String {
|
|
let Some((program, args)) = values.split_first() else {
|
|
return "& ''".to_string();
|
|
};
|
|
let program = quote_powershell_string(program);
|
|
if args.is_empty() {
|
|
return format!("& {program}");
|
|
}
|
|
let args = args
|
|
.iter()
|
|
.map(|value| quote_powershell_string(value))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
format!("& {program} @({args})")
|
|
}
|
|
|
|
fn quote_powershell_string(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
fn quote_windows_style(value: &str) -> Result<String, CliError> {
|
|
if let Some(character) = value
|
|
.chars()
|
|
.find(|character| matches!(character, '%' | '`' | '\r' | '\n'))
|
|
{
|
|
return Err(CliError::usage(format!(
|
|
"unsafe shell metacharacter {character:?} is not allowed with cmd-style shells"
|
|
)));
|
|
}
|
|
if value.is_empty() {
|
|
return Ok("\"\"".to_string());
|
|
}
|
|
if !value.contains([' ', '\t', '"', '&', '|', '^', '>', '<']) {
|
|
return Ok(value.to_string());
|
|
}
|
|
Ok(format!("\"{}\"", value.replace('"', "\\\"")))
|
|
}
|
|
|
|
fn quote_posix_style(value: &str) -> String {
|
|
if value.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
if !value.contains([
|
|
' ', '\t', '\'', '"', '$', '|', '&', ';', '>', '<', '`', '\r', '\n', '%',
|
|
]) {
|
|
return value.to_string();
|
|
}
|
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
|
}
|
|
|
|
fn resolve_shell_status(preset: ShellPreset) -> ShellStatus {
|
|
match preset.try_resolve() {
|
|
Ok(resolution) => ShellStatus {
|
|
preset,
|
|
available: true,
|
|
executable: Some(resolution.executable),
|
|
resolution_source: Some(resolution.source),
|
|
error: None,
|
|
},
|
|
Err(error) => ShellStatus {
|
|
preset,
|
|
available: false,
|
|
executable: None,
|
|
resolution_source: None,
|
|
error: Some(error),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PreparedCommand {
|
|
program: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ResolvedShell {
|
|
executable: String,
|
|
default_args: Vec<String>,
|
|
source: ShellResolutionSource,
|
|
}
|
|
|
|
trait SudoProvider {
|
|
fn status(&self) -> Result<TokenStatus, CliError>;
|
|
fn elevate_current_process(
|
|
&self,
|
|
arguments: &[String],
|
|
wait: bool,
|
|
show_window: ShowWindowMode,
|
|
) -> Result<LaunchResult, CliError>;
|
|
fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError>;
|
|
fn launch_request(&self, request: &LaunchRequest) -> Result<LaunchResult, CliError>;
|
|
}
|
|
|
|
struct WindowsSudoProvider;
|
|
|
|
impl SudoProvider for WindowsSudoProvider {
|
|
fn status(&self) -> Result<TokenStatus, CliError> {
|
|
windowsupport::current_token_status().map_err(|error| CliError::runtime(error.to_string()))
|
|
}
|
|
|
|
fn elevate_current_process(
|
|
&self,
|
|
arguments: &[String],
|
|
wait: bool,
|
|
show_window: ShowWindowMode,
|
|
) -> Result<LaunchResult, CliError> {
|
|
windowsupport::elevate_current_process(arguments, wait, show_window)
|
|
.map_err(|error| CliError::runtime(error.to_string()))
|
|
}
|
|
|
|
fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError> {
|
|
windowsupport::attach_parent_console(parent_pid)
|
|
.map_err(|error| CliError::runtime(error.to_string()))
|
|
}
|
|
|
|
fn launch_request(&self, request: &LaunchRequest) -> Result<LaunchResult, CliError> {
|
|
windowsupport::launch_request(request).map_err(|error| CliError::runtime(error.to_string()))
|
|
}
|
|
}
|
|
|
|
impl ShellPreset {
|
|
const fn all() -> [Self; 11] {
|
|
[
|
|
Self::Cmd,
|
|
Self::PowerShell,
|
|
Self::Pwsh,
|
|
Self::Wsl,
|
|
Self::GitBash,
|
|
Self::Mingw,
|
|
Self::Msys2,
|
|
Self::Cygwin,
|
|
Self::Yori,
|
|
Self::Tcc,
|
|
Self::Nu,
|
|
]
|
|
}
|
|
|
|
const fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Cmd => "cmd",
|
|
Self::PowerShell => "powershell",
|
|
Self::Pwsh => "pwsh",
|
|
Self::Wsl => "wsl",
|
|
Self::GitBash => "git-bash",
|
|
Self::Mingw => "mingw",
|
|
Self::Msys2 => "msys2",
|
|
Self::Cygwin => "cygwin",
|
|
Self::Yori => "yori",
|
|
Self::Tcc => "tcc",
|
|
Self::Nu => "nu",
|
|
}
|
|
}
|
|
|
|
fn try_resolve(self) -> Result<ResolvedShell, String> {
|
|
resolve_shell_from_path(
|
|
self,
|
|
std::env::var_os("PATH").as_deref(),
|
|
std::env::var_os("PATHEXT").as_deref(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn default_shell_args(shell: ShellPreset) -> Vec<String> {
|
|
match shell {
|
|
ShellPreset::PowerShell | ShellPreset::Pwsh => vec!["-NoProfile".to_string()],
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
const fn shell_candidates(shell: ShellPreset) -> &'static [&'static str] {
|
|
match shell {
|
|
ShellPreset::Cmd => &["cmd.exe", "cmd"],
|
|
ShellPreset::PowerShell => &["powershell.exe", "powershell"],
|
|
ShellPreset::Pwsh => &["pwsh.exe", "pwsh"],
|
|
ShellPreset::Wsl => &["wsl.exe", "wsl"],
|
|
ShellPreset::GitBash => &["bash.exe", "bash", "git-bash.exe"],
|
|
ShellPreset::Mingw | ShellPreset::Msys2 | ShellPreset::Cygwin => &[],
|
|
ShellPreset::Yori => &["yori.exe", "yori"],
|
|
ShellPreset::Tcc => &["tcc.exe", "tcc"],
|
|
ShellPreset::Nu => &["nu.exe", "nu"],
|
|
}
|
|
}
|
|
|
|
const fn shell_known_locations(shell: ShellPreset) -> &'static [&'static str] {
|
|
match shell {
|
|
ShellPreset::Cmd => &["C:\\Windows\\System32\\cmd.exe"],
|
|
ShellPreset::PowerShell => {
|
|
&["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"]
|
|
}
|
|
ShellPreset::Pwsh => &[
|
|
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
|
"C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe",
|
|
],
|
|
ShellPreset::Wsl => &["C:\\Windows\\System32\\wsl.exe"],
|
|
ShellPreset::GitBash => &[
|
|
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
|
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
"C:\\Program Files\\Git\\git-bash.exe",
|
|
],
|
|
ShellPreset::Mingw | ShellPreset::Msys2 => &["C:\\msys64\\usr\\bin\\bash.exe"],
|
|
ShellPreset::Cygwin => &["C:\\cygwin64\\bin\\bash.exe"],
|
|
ShellPreset::Yori => &["C:\\Program Files\\Yori\\yori.exe"],
|
|
ShellPreset::Tcc => &["C:\\Program Files\\JPSoft\\TCC.exe"],
|
|
ShellPreset::Nu => &[
|
|
"C:\\Program Files\\Nushell\\bin\\nu.exe",
|
|
"C:\\Program Files\\nu\\bin\\nu.exe",
|
|
],
|
|
}
|
|
}
|
|
|
|
fn shell_resolution_candidates(shell: ShellPreset) -> Vec<&'static str> {
|
|
let known = shell_known_locations(shell);
|
|
let path = shell_candidates(shell);
|
|
let mut values = Vec::with_capacity(known.len() + path.len());
|
|
values.extend_from_slice(known);
|
|
values.extend_from_slice(path);
|
|
values
|
|
}
|
|
|
|
fn resolve_shell(shell: ShellPreset) -> Result<ResolvedShell, CliError> {
|
|
shell.try_resolve().map_err(CliError::runtime)
|
|
}
|
|
|
|
fn resolve_shell_from_path(
|
|
shell: ShellPreset,
|
|
path: Option<&OsStr>,
|
|
pathext: Option<&OsStr>,
|
|
) -> Result<ResolvedShell, String> {
|
|
for candidate in shell_resolution_candidates(shell) {
|
|
if let Some(resolution) = find_executable(candidate, path, pathext) {
|
|
return Ok(ResolvedShell {
|
|
executable: resolution.executable,
|
|
default_args: default_shell_args(shell),
|
|
source: resolution.source,
|
|
});
|
|
}
|
|
}
|
|
Err(format!("no executable found for {}", shell.name()))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ResolvedExecutable {
|
|
executable: String,
|
|
source: ShellResolutionSource,
|
|
}
|
|
|
|
fn find_executable(
|
|
candidate: &str,
|
|
path: Option<&OsStr>,
|
|
pathext: Option<&OsStr>,
|
|
) -> Option<ResolvedExecutable> {
|
|
let candidate_path = Path::new(candidate);
|
|
if candidate_path.components().count() > 1 && candidate_path.exists() {
|
|
return Some(ResolvedExecutable {
|
|
executable: candidate_path.display().to_string(),
|
|
source: ShellResolutionSource::KnownLocation,
|
|
});
|
|
}
|
|
find_executable_in_path(candidate, path?, pathext).map(|executable| ResolvedExecutable {
|
|
executable,
|
|
source: ShellResolutionSource::Path,
|
|
})
|
|
}
|
|
|
|
fn find_executable_in_path(
|
|
candidate: &str,
|
|
path: &OsStr,
|
|
pathext: Option<&OsStr>,
|
|
) -> Option<String> {
|
|
let pathext = pathext.map_or_else(
|
|
|| vec![".exe".to_string(), ".cmd".to_string(), ".bat".to_string()],
|
|
|value| {
|
|
value
|
|
.to_string_lossy()
|
|
.split(';')
|
|
.map(str::to_ascii_lowercase)
|
|
.collect::<Vec<_>>()
|
|
},
|
|
);
|
|
for directory in std::env::split_paths(path) {
|
|
let direct = directory.join(candidate);
|
|
if direct.exists() {
|
|
return Some(direct.display().to_string());
|
|
}
|
|
if direct.extension().is_none() {
|
|
for extension in &pathext {
|
|
let path = directory.join(format!("{candidate}{extension}"));
|
|
if path.exists() {
|
|
return Some(path.display().to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn resolve_direct_command_from_path(
|
|
program: &str,
|
|
path: Option<&OsStr>,
|
|
pathext: Option<&OsStr>,
|
|
) -> ResolvedExecutable {
|
|
let program_path = Path::new(program);
|
|
if program_path.components().count() > 1 {
|
|
return ResolvedExecutable {
|
|
executable: program.to_string(),
|
|
source: ShellResolutionSource::KnownLocation,
|
|
};
|
|
}
|
|
for candidate in direct_command_known_locations(program) {
|
|
let candidate_path = Path::new(candidate);
|
|
if candidate_path.exists() {
|
|
return ResolvedExecutable {
|
|
executable: candidate_path.display().to_string(),
|
|
source: ShellResolutionSource::KnownLocation,
|
|
};
|
|
}
|
|
}
|
|
find_executable_in_path(program, path.unwrap_or_else(|| OsStr::new("")), pathext).map_or_else(
|
|
|| ResolvedExecutable {
|
|
executable: program.to_string(),
|
|
source: ShellResolutionSource::Path,
|
|
},
|
|
|executable| ResolvedExecutable {
|
|
executable,
|
|
source: ShellResolutionSource::Path,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn direct_command_known_locations(program: &str) -> &'static [&'static str] {
|
|
match program.to_ascii_lowercase().as_str() {
|
|
"cmd" | "cmd.exe" => shell_known_locations(ShellPreset::Cmd),
|
|
"powershell" | "powershell.exe" => shell_known_locations(ShellPreset::PowerShell),
|
|
"pwsh" | "pwsh.exe" => shell_known_locations(ShellPreset::Pwsh),
|
|
"wsl" | "wsl.exe" => shell_known_locations(ShellPreset::Wsl),
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn validate_direct_command_policy(
|
|
program: &str,
|
|
identity: LaunchIdentity,
|
|
source: ShellResolutionSource,
|
|
) -> Result<(), CliError> {
|
|
if matches!(
|
|
identity,
|
|
LaunchIdentity::Admin | LaunchIdentity::System | LaunchIdentity::TrustedInstaller
|
|
) && matches!(source, ShellResolutionSource::Path)
|
|
{
|
|
return Err(CliError::usage(format!(
|
|
"{identity:?} launch refuses PATH-resolved bare command '{program}'; use an explicit path or --shell <preset>"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
const fn shell_resolution_source_name(source: ShellResolutionSource) -> &'static str {
|
|
match source {
|
|
ShellResolutionSource::KnownLocation => "known-location",
|
|
ShellResolutionSource::Path => "path",
|
|
}
|
|
}
|
|
|
|
fn format_optional_u32(value: Option<u32>) -> String {
|
|
value.map_or_else(|| "-".to_string(), |item| item.to_string())
|
|
}
|
|
|
|
const fn launch_identity_name(identity: LaunchIdentity) -> &'static str {
|
|
match identity {
|
|
LaunchIdentity::CurrentProcess => "current-process",
|
|
LaunchIdentity::CurrentUser => "current-user",
|
|
LaunchIdentity::Admin => "admin",
|
|
LaunchIdentity::System => "system",
|
|
LaunchIdentity::TrustedInstaller => "trustedinstaller",
|
|
}
|
|
}
|
|
|
|
const fn token_integrity_name(level: TokenIntegrity) -> &'static str {
|
|
match level {
|
|
TokenIntegrity::Untrusted => "untrusted",
|
|
TokenIntegrity::Low => "low",
|
|
TokenIntegrity::Medium => "medium",
|
|
TokenIntegrity::MediumPlus => "medium-plus",
|
|
TokenIntegrity::High => "high",
|
|
TokenIntegrity::System => "system",
|
|
TokenIntegrity::Unknown => "unknown",
|
|
}
|
|
}
|
|
|
|
const fn privilege_mode_name(mode: PrivilegeMode) -> &'static str {
|
|
match mode {
|
|
PrivilegeMode::Default => "default",
|
|
PrivilegeMode::EnableAll => "enable-all",
|
|
PrivilegeMode::DisableAll => "disable-all",
|
|
}
|
|
}
|
|
|
|
const fn priority_name(priority: ProcessPriority) -> &'static str {
|
|
match priority {
|
|
ProcessPriority::Idle => "idle",
|
|
ProcessPriority::BelowNormal => "below-normal",
|
|
ProcessPriority::Normal => "normal",
|
|
ProcessPriority::AboveNormal => "above-normal",
|
|
ProcessPriority::High => "high",
|
|
ProcessPriority::Realtime => "realtime",
|
|
}
|
|
}
|
|
|
|
const fn show_window_name(mode: ShowWindowMode) -> &'static str {
|
|
match mode {
|
|
ShowWindowMode::Default => "default",
|
|
ShowWindowMode::Hidden => "hidden",
|
|
ShowWindowMode::Normal => "normal",
|
|
ShowWindowMode::Minimized => "minimized",
|
|
ShowWindowMode::Maximized => "maximized",
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
let mut common = CommonArgs::default();
|
|
let mut run = RunCli {
|
|
user: LaunchIdentity::Admin,
|
|
dangerous: false,
|
|
shell: None,
|
|
integrity: None,
|
|
privileges: PrivilegeMode::Default,
|
|
priority: ProcessPriority::Normal,
|
|
show_window: ShowWindowMode::Default,
|
|
session: None,
|
|
current_directory: None,
|
|
same_console: false,
|
|
new_window: false,
|
|
wait: false,
|
|
command: Vec::new(),
|
|
};
|
|
let mut mode = None::<String>;
|
|
let mut relay_exit_code_path = None::<PathBuf>;
|
|
let mut relay_stdout_path = None::<PathBuf>;
|
|
let mut relay_stderr_path = None::<PathBuf>;
|
|
let mut relay_console_pid = None::<u32>;
|
|
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("help") | Short('h') => {
|
|
let help = match mode.as_deref() {
|
|
Some("status") => STATUS_HELP,
|
|
Some("run" | RELAY_SUBCOMMAND) => RUN_HELP,
|
|
_ => HELP,
|
|
};
|
|
return Ok((
|
|
ParseOutcome::Help,
|
|
Cli {
|
|
common,
|
|
command: CommandMode::Help(help),
|
|
},
|
|
));
|
|
}
|
|
Long("version") | Short('V') => {
|
|
return Ok((
|
|
ParseOutcome::Version,
|
|
Cli {
|
|
common,
|
|
command: CommandMode::Status,
|
|
},
|
|
));
|
|
}
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
|
}
|
|
Long("user") => {
|
|
run.user = parse_launch_identity(&parser_value_string(&mut parser, "--user")?)?;
|
|
}
|
|
Long("dangerous") => run.dangerous = true,
|
|
Long("shell") => {
|
|
run.shell = Some(parse_shell_preset(&parser_value_string(
|
|
&mut parser,
|
|
"--shell",
|
|
)?)?);
|
|
}
|
|
Long("integrity") => {
|
|
run.integrity = Some(parse_token_integrity(&parser_value_string(
|
|
&mut parser,
|
|
"--integrity",
|
|
)?)?);
|
|
}
|
|
Long("privileges") => {
|
|
run.privileges =
|
|
parse_privilege_mode(&parser_value_string(&mut parser, "--privileges")?)?;
|
|
}
|
|
Long("priority") => {
|
|
run.priority = parse_priority(&parser_value_string(&mut parser, "--priority")?)?;
|
|
}
|
|
Long("show-window") => {
|
|
run.show_window =
|
|
parse_show_window_mode(&parser_value_string(&mut parser, "--show-window")?)?;
|
|
}
|
|
Long("session") => {
|
|
run.session = Some(parse_u32_flag(
|
|
"--session",
|
|
&parser_value_string(&mut parser, "--session")?,
|
|
)?);
|
|
}
|
|
Long("current-directory") => {
|
|
run.current_directory =
|
|
Some(parser_value_string(&mut parser, "--current-directory")?);
|
|
}
|
|
Long("same-console") => run.same_console = true,
|
|
Long("new-window") => run.new_window = true,
|
|
Long("wait") => run.wait = true,
|
|
Long("exit-code-path") => {
|
|
relay_exit_code_path = Some(PathBuf::from(parser_value_string(
|
|
&mut parser,
|
|
"--exit-code-path",
|
|
)?));
|
|
}
|
|
Long("stdout-path") => {
|
|
relay_stdout_path = Some(PathBuf::from(parser_value_string(
|
|
&mut parser,
|
|
"--stdout-path",
|
|
)?));
|
|
}
|
|
Long("stderr-path") => {
|
|
relay_stderr_path = Some(PathBuf::from(parser_value_string(
|
|
&mut parser,
|
|
"--stderr-path",
|
|
)?));
|
|
}
|
|
Long("console-pid") => {
|
|
relay_console_pid = Some(parse_u32_flag(
|
|
"--console-pid",
|
|
&parser_value_string(&mut parser, "--console-pid")?,
|
|
)?);
|
|
}
|
|
ArgValue(value) => {
|
|
let token = os_to_utf8(value, "subcommand")?;
|
|
if mode.is_none() && matches!(token.as_str(), "status" | "run" | RELAY_SUBCOMMAND) {
|
|
mode = Some(token);
|
|
continue;
|
|
}
|
|
run.command = collect_command_values(&mut parser, token)?;
|
|
break;
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let command = match mode.as_deref() {
|
|
Some("status") => CommandMode::Status,
|
|
Some("run") => {
|
|
if run.command.is_empty() && run.shell.is_none() {
|
|
return Err(CliError::usage(
|
|
"provide a command after -- or select --shell <preset>",
|
|
));
|
|
}
|
|
CommandMode::Run(run)
|
|
}
|
|
None => {
|
|
if run.command.is_empty() && run.shell.is_none() {
|
|
CommandMode::Help(HELP)
|
|
} else {
|
|
CommandMode::Run(run)
|
|
}
|
|
}
|
|
Some(RELAY_SUBCOMMAND) => CommandMode::Relay(RelayCli {
|
|
run,
|
|
exit_code_path: relay_exit_code_path.ok_or_else(|| {
|
|
CliError::usage("internal relay requires --exit-code-path <PATH>")
|
|
})?,
|
|
stdout_path: relay_stdout_path,
|
|
stderr_path: relay_stderr_path,
|
|
console_pid: relay_console_pid,
|
|
}),
|
|
Some(other) => {
|
|
return Err(CliError::usage(format!(
|
|
"unknown subcommand '{other}'; expected status or run"
|
|
)));
|
|
}
|
|
};
|
|
|
|
Ok((ParseOutcome::Run, Cli { common, command }))
|
|
}
|
|
|
|
fn collect_command_values(
|
|
parser: &mut lexopt::Parser,
|
|
first: String,
|
|
) -> Result<Vec<String>, CliError> {
|
|
let mut values = vec![first];
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
ArgValue(value) => values.push(os_to_utf8(value, "command argument")?),
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"command arguments must appear after -- and cannot include extra flags",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
fn parse_launch_identity(value: &str) -> Result<LaunchIdentity, CliError> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"admin" => Ok(LaunchIdentity::Admin),
|
|
"current-process" | "self" => Ok(LaunchIdentity::CurrentProcess),
|
|
"current-user" | "user" => Ok(LaunchIdentity::CurrentUser),
|
|
"system" => Ok(LaunchIdentity::System),
|
|
"trustedinstaller" => Ok(LaunchIdentity::TrustedInstaller),
|
|
_ => Err(CliError::usage(format!(
|
|
"invalid --user value '{value}'; expected current-process, current-user, admin, system, or trustedinstaller"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_shell_preset(value: &str) -> Result<ShellPreset, CliError> {
|
|
match value {
|
|
"cmd" => Ok(ShellPreset::Cmd),
|
|
"powershell" => Ok(ShellPreset::PowerShell),
|
|
"pwsh" => Ok(ShellPreset::Pwsh),
|
|
"wsl" => Ok(ShellPreset::Wsl),
|
|
"git-bash" => Ok(ShellPreset::GitBash),
|
|
"mingw" => Ok(ShellPreset::Mingw),
|
|
"msys2" => Ok(ShellPreset::Msys2),
|
|
"cygwin" => Ok(ShellPreset::Cygwin),
|
|
"yori" => Ok(ShellPreset::Yori),
|
|
"tcc" => Ok(ShellPreset::Tcc),
|
|
"nu" => Ok(ShellPreset::Nu),
|
|
other => Err(CliError::usage(format!("invalid --shell value '{other}'"))),
|
|
}
|
|
}
|
|
|
|
fn parse_token_integrity(value: &str) -> Result<TokenIntegrity, CliError> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"untrusted" => Ok(TokenIntegrity::Untrusted),
|
|
"low" => Ok(TokenIntegrity::Low),
|
|
"medium" => Ok(TokenIntegrity::Medium),
|
|
"medium-plus" => Ok(TokenIntegrity::MediumPlus),
|
|
"high" => Ok(TokenIntegrity::High),
|
|
"system" => Ok(TokenIntegrity::System),
|
|
_ => Err(CliError::usage(format!(
|
|
"invalid --integrity value '{value}'"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_privilege_mode(value: &str) -> Result<PrivilegeMode, CliError> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"default" => Ok(PrivilegeMode::Default),
|
|
"enable-all" => Ok(PrivilegeMode::EnableAll),
|
|
"disable-all" => Ok(PrivilegeMode::DisableAll),
|
|
_ => Err(CliError::usage(format!(
|
|
"invalid --privileges value '{value}'"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_priority(value: &str) -> Result<ProcessPriority, CliError> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"idle" => Ok(ProcessPriority::Idle),
|
|
"below-normal" | "belownormal" => Ok(ProcessPriority::BelowNormal),
|
|
"normal" => Ok(ProcessPriority::Normal),
|
|
"above-normal" | "abovenormal" => Ok(ProcessPriority::AboveNormal),
|
|
"high" => Ok(ProcessPriority::High),
|
|
"realtime" | "real-time" => Ok(ProcessPriority::Realtime),
|
|
_ => Err(CliError::usage(format!(
|
|
"invalid --priority value '{value}'"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_show_window_mode(value: &str) -> Result<ShowWindowMode, CliError> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"default" => Ok(ShowWindowMode::Default),
|
|
"hidden" | "hide" => Ok(ShowWindowMode::Hidden),
|
|
"normal" | "show" => Ok(ShowWindowMode::Normal),
|
|
"minimized" | "minimize" => Ok(ShowWindowMode::Minimized),
|
|
"maximized" | "maximize" => Ok(ShowWindowMode::Maximized),
|
|
_ => Err(CliError::usage(format!(
|
|
"invalid --show-window value '{value}'"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_u32_flag(flag: &str, value: &str) -> Result<u32, CliError> {
|
|
value
|
|
.parse::<u32>()
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
}
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
os_to_utf8(value, flag)
|
|
}
|
|
|
|
fn os_to_utf8(value: OsString, context: &str) -> Result<String, CliError> {
|
|
value.into_string().map_err(|invalid| {
|
|
CliError::usage(format!(
|
|
"{context} expects UTF-8 text, got '{}'",
|
|
invalid.to_string_lossy()
|
|
))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct MockProvider {
|
|
status: TokenStatus,
|
|
elevate_result: LaunchResult,
|
|
launch_result: LaunchResult,
|
|
elevated_arguments: Vec<String>,
|
|
attached_console_pids: Vec<u32>,
|
|
launched: Vec<LaunchRequest>,
|
|
relay_exit_code_to_write: Option<i32>,
|
|
elevate_wait: Option<bool>,
|
|
}
|
|
|
|
impl MockProvider {
|
|
fn new() -> Self {
|
|
Self {
|
|
status: TokenStatus {
|
|
is_elevated: true,
|
|
is_admin_member: true,
|
|
integrity: TokenIntegrity::High,
|
|
current_user: Some("TEST\\User".to_string()),
|
|
session_id: Some(1),
|
|
active_session_id: Some(1),
|
|
can_admin: true,
|
|
can_current_user: true,
|
|
can_system: true,
|
|
can_trustedinstaller: true,
|
|
trustedinstaller_installed: true,
|
|
trustedinstaller_running: false,
|
|
},
|
|
elevate_result: LaunchResult {
|
|
pid: Some(100),
|
|
exit_code: Some(0),
|
|
identity: LaunchIdentity::Admin,
|
|
},
|
|
launch_result: LaunchResult {
|
|
pid: Some(101),
|
|
exit_code: Some(0),
|
|
identity: LaunchIdentity::Admin,
|
|
},
|
|
elevated_arguments: Vec::new(),
|
|
attached_console_pids: Vec::new(),
|
|
launched: Vec::new(),
|
|
relay_exit_code_to_write: None,
|
|
elevate_wait: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SudoProvider for std::cell::RefCell<MockProvider> {
|
|
fn status(&self) -> Result<TokenStatus, CliError> {
|
|
Ok(self.borrow().status.clone())
|
|
}
|
|
|
|
fn elevate_current_process(
|
|
&self,
|
|
arguments: &[String],
|
|
wait: bool,
|
|
_show_window: ShowWindowMode,
|
|
) -> Result<LaunchResult, CliError> {
|
|
let mut state = self.borrow_mut();
|
|
state.elevated_arguments = arguments.to_vec();
|
|
state.elevate_wait = Some(wait);
|
|
if let Some(exit_code) = state.relay_exit_code_to_write {
|
|
let exit_path = arguments
|
|
.windows(2)
|
|
.find(|pair| pair[0] == "--exit-code-path")
|
|
.map(|pair| PathBuf::from(&pair[1]))
|
|
.expect("relay exit path");
|
|
fs::write(&exit_path, exit_code.to_string())
|
|
.expect("write relay exit code for test");
|
|
for relay_path in arguments
|
|
.windows(2)
|
|
.filter(|pair| pair[0] == "--stdout-path" || pair[0] == "--stderr-path")
|
|
.map(|pair| PathBuf::from(&pair[1]))
|
|
{
|
|
fs::write(&relay_path, b"").expect("write relay output for test");
|
|
}
|
|
}
|
|
Ok(state.elevate_result)
|
|
}
|
|
|
|
fn attach_parent_console(&self, parent_pid: u32) -> Result<(), CliError> {
|
|
self.borrow_mut().attached_console_pids.push(parent_pid);
|
|
Ok(())
|
|
}
|
|
|
|
fn launch_request(&self, request: &LaunchRequest) -> Result<LaunchResult, CliError> {
|
|
self.borrow_mut().launched.push(request.clone());
|
|
Ok(self.borrow().launch_result)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_status_subcommand_and_json_flag() {
|
|
let (outcome, cli) =
|
|
parse_cli_from(["msudo", "status", "--json"]).expect("cli should parse");
|
|
assert!(matches!(outcome, ParseOutcome::Run));
|
|
assert!(cli.common.json);
|
|
assert!(matches!(cli.command, CommandMode::Status));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_default_command_mode_accepts_bare_payload() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo", "--user", "admin", "--", "cmd", "/d", "/c", "whoami",
|
|
])
|
|
.expect("cli should parse");
|
|
let CommandMode::Run(run) = cli.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
assert_eq!(run.command[0], "cmd");
|
|
assert_eq!(run.command[3], "whoami");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_shell_preset_and_window_flags() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"--shell",
|
|
"pwsh",
|
|
"--new-window",
|
|
"--priority",
|
|
"high",
|
|
])
|
|
.expect("cli should parse");
|
|
let CommandMode::Run(run) = cli.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
assert_eq!(run.shell, Some(ShellPreset::Pwsh));
|
|
assert!(run.new_window);
|
|
assert_eq!(run.priority, ProcessPriority::High);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_same_console_flag() {
|
|
let (_, cli) = parse_cli_from(["msudo", "--same-console", "--shell", "powershell"])
|
|
.expect("cli should parse");
|
|
let CommandMode::Run(run) = cli.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
assert!(run.same_console);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_current_identity_user_modes() {
|
|
let (_, current_user) = parse_cli_from(["msudo", "--user", "current-user", "--", "cmd"])
|
|
.expect("current user parses");
|
|
let CommandMode::Run(run) = current_user.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
assert_eq!(run.user, LaunchIdentity::CurrentUser);
|
|
|
|
let (_, current_process) =
|
|
parse_cli_from(["msudo", "--user", "current-process", "--", "cmd"])
|
|
.expect("current process parses");
|
|
let CommandMode::Run(run) = current_process.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
assert_eq!(run.user, LaunchIdentity::CurrentProcess);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_requires_dangerous_for_system_and_trustedinstaller() {
|
|
let mut run = sample_run();
|
|
run.user = LaunchIdentity::System;
|
|
assert!(validate_run_cli(&run).is_err());
|
|
run.user = LaunchIdentity::TrustedInstaller;
|
|
assert!(validate_run_cli(&run).is_err());
|
|
run.dangerous = true;
|
|
assert!(validate_run_cli(&run).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_conflicting_console_mode_flags() {
|
|
let mut run = sample_run();
|
|
run.same_console = true;
|
|
run.new_window = true;
|
|
let error = validate_run_cli(&run).expect_err("validation should fail");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("--same-console cannot be combined with --new-window")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn status_report_contains_required_stable_fields() {
|
|
let report = build_status_report(TokenStatus {
|
|
is_elevated: false,
|
|
is_admin_member: true,
|
|
integrity: TokenIntegrity::Medium,
|
|
current_user: Some("User".to_string()),
|
|
session_id: Some(1),
|
|
active_session_id: Some(1),
|
|
can_admin: true,
|
|
can_current_user: true,
|
|
can_system: false,
|
|
can_trustedinstaller: false,
|
|
trustedinstaller_installed: true,
|
|
trustedinstaller_running: false,
|
|
});
|
|
let json = serde_json::to_string(&report).expect("json");
|
|
assert!(json.contains("\"ok\":true"));
|
|
assert!(json.contains("\"supports_runas\":true"));
|
|
assert!(json.contains("\"host\":"));
|
|
assert!(json.contains("\"is_elevated\":false"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_status_text_uses_cli_shell_names() {
|
|
let rendered = render_status_text(
|
|
&StatusReport {
|
|
ok: true,
|
|
host: "localhost".to_string(),
|
|
supports_runas: true,
|
|
is_elevated: true,
|
|
is_admin_member: true,
|
|
integrity: TokenIntegrity::High,
|
|
current_user: Some("User".to_string()),
|
|
session_id: Some(1),
|
|
active_session_id: Some(1),
|
|
can_admin: true,
|
|
can_current_user: true,
|
|
can_system: true,
|
|
can_trustedinstaller: true,
|
|
trustedinstaller_installed: true,
|
|
trustedinstaller_running: false,
|
|
shells: vec![ShellStatus {
|
|
preset: ShellPreset::GitBash,
|
|
available: true,
|
|
executable: Some("C:\\Program Files\\Git\\usr\\bin\\bash.exe".to_string()),
|
|
resolution_source: Some(ShellResolutionSource::KnownLocation),
|
|
error: None,
|
|
}],
|
|
},
|
|
false,
|
|
);
|
|
assert!(rendered.contains("shell=git-bash"));
|
|
assert!(!rendered.contains("shell=GitBash"));
|
|
}
|
|
|
|
#[test]
|
|
fn quiet_status_text_omits_shell_inventory() {
|
|
let rendered = render_status_text(
|
|
&StatusReport {
|
|
ok: true,
|
|
host: "localhost".to_string(),
|
|
supports_runas: true,
|
|
is_elevated: true,
|
|
is_admin_member: true,
|
|
integrity: TokenIntegrity::High,
|
|
current_user: Some("User".to_string()),
|
|
session_id: Some(1),
|
|
active_session_id: Some(1),
|
|
can_admin: true,
|
|
can_current_user: true,
|
|
can_system: true,
|
|
can_trustedinstaller: true,
|
|
trustedinstaller_installed: true,
|
|
trustedinstaller_running: false,
|
|
shells: vec![ShellStatus {
|
|
preset: ShellPreset::Pwsh,
|
|
available: true,
|
|
executable: Some("pwsh.exe".to_string()),
|
|
resolution_source: Some(ShellResolutionSource::Path),
|
|
error: None,
|
|
}],
|
|
},
|
|
true,
|
|
);
|
|
assert!(!rendered.contains("shell="));
|
|
}
|
|
|
|
#[test]
|
|
fn shell_preset_json_names_match_cli_names() {
|
|
for preset in ShellPreset::all() {
|
|
assert_eq!(
|
|
serde_json::to_string(&preset).expect("preset json"),
|
|
format!("\"{}\"", preset.name())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shell_wrapping_for_pwsh_and_cmd_is_stable() {
|
|
let pwsh = prepare_shell_wrapped_command(
|
|
ShellPreset::Pwsh,
|
|
&[
|
|
"git".to_string(),
|
|
"status".to_string(),
|
|
"--short".to_string(),
|
|
],
|
|
)
|
|
.expect("pwsh shell command");
|
|
let normalized = pwsh.program.to_ascii_lowercase();
|
|
assert!(
|
|
shell_resolution_candidates(ShellPreset::Pwsh)
|
|
.iter()
|
|
.any(|candidate| { normalized.ends_with(&candidate.to_ascii_lowercase()) })
|
|
);
|
|
assert!(pwsh.args.contains(&"-Command".to_string()));
|
|
assert_eq!(
|
|
pwsh.args.last().expect("pwsh command"),
|
|
"& 'git' @('status', '--short')"
|
|
);
|
|
|
|
let cmd = prepare_shell_wrapped_command(
|
|
ShellPreset::Cmd,
|
|
&["tool.exe".to_string(), "two words".to_string()],
|
|
)
|
|
.expect("cmd shell command");
|
|
assert!(cmd.args.contains(&"/c".to_string()));
|
|
assert!(cmd.args.last().expect("last arg").contains("\"two words\""));
|
|
}
|
|
|
|
#[test]
|
|
fn shell_wrapping_rejects_or_quotes_shell_metacharacters() {
|
|
let cmd = prepare_shell_wrapped_command(
|
|
ShellPreset::Cmd,
|
|
&["tool.exe".to_string(), "%PATH%".to_string()],
|
|
)
|
|
.expect_err("cmd percent expansion should be rejected");
|
|
assert!(cmd.to_string().contains("unsafe shell metacharacter"));
|
|
|
|
let posix = quote_for_shell(
|
|
ShellPreset::GitBash,
|
|
&[
|
|
"tool".to_string(),
|
|
">".to_string(),
|
|
"<".to_string(),
|
|
"`whoami`".to_string(),
|
|
"two\nlines".to_string(),
|
|
"%PATH%".to_string(),
|
|
],
|
|
)
|
|
.expect("posix metacharacters should be quoted");
|
|
assert_eq!(posix, "tool '>' '<' '`whoami`' 'two\nlines' '%PATH%'");
|
|
}
|
|
|
|
#[test]
|
|
fn non_elevated_launch_relays_through_provider() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo", "--user", "admin", "--", "cmd", "/d", "/c", "whoami",
|
|
])
|
|
.expect("cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
provider.borrow_mut().status.is_elevated = false;
|
|
let code = run_with_provider(&cli, &provider).expect("run should succeed");
|
|
assert_eq!(code, ExitCode::Success.as_i32());
|
|
let elevated_arguments = provider.borrow().elevated_arguments.clone();
|
|
assert!(elevated_arguments.contains(&RELAY_SUBCOMMAND.to_string()));
|
|
assert!(elevated_arguments.contains(&"--exit-code-path".to_string()));
|
|
assert_eq!(provider.borrow().elevate_wait, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn non_elevated_current_process_does_not_relay() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"--user",
|
|
"current-process",
|
|
"--",
|
|
"cmd",
|
|
"/d",
|
|
"/c",
|
|
"whoami",
|
|
])
|
|
.expect("cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
provider.borrow_mut().status.is_elevated = false;
|
|
let code = run_with_provider(&cli, &provider).expect("run should succeed");
|
|
assert_eq!(code, ExitCode::Success.as_i32());
|
|
assert!(provider.borrow().elevated_arguments.is_empty());
|
|
assert_eq!(
|
|
provider.borrow().launched[0].identity,
|
|
LaunchIdentity::CurrentProcess
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_elevated_wait_uses_relay_exit_code_file() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"--user",
|
|
"system",
|
|
"--dangerous",
|
|
"--wait",
|
|
"--",
|
|
"cmd",
|
|
"/d",
|
|
"/c",
|
|
"exit 9",
|
|
])
|
|
.expect("cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
provider.borrow_mut().status.is_elevated = false;
|
|
provider.borrow_mut().relay_exit_code_to_write = Some(9);
|
|
let code = run_with_provider(&cli, &provider).expect("run should succeed");
|
|
assert_eq!(code, 9);
|
|
assert_eq!(provider.borrow().elevate_wait, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn non_elevated_same_console_forces_waiting_relay() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"--same-console",
|
|
"--user",
|
|
"system",
|
|
"--dangerous",
|
|
"--",
|
|
"cmd",
|
|
"/d",
|
|
"/c",
|
|
"exit 0",
|
|
])
|
|
.expect("cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
provider.borrow_mut().status.is_elevated = false;
|
|
provider.borrow_mut().relay_exit_code_to_write = Some(0);
|
|
let code = run_with_provider(&cli, &provider).expect("run should succeed");
|
|
assert_eq!(code, 0);
|
|
let elevated_arguments = provider.borrow().elevated_arguments.clone();
|
|
assert!(elevated_arguments.contains(&"--same-console".to_string()));
|
|
assert!(elevated_arguments.contains(&"--console-pid".to_string()));
|
|
assert_eq!(provider.borrow().elevate_wait, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn same_console_relay_attaches_parent_console_before_launch() {
|
|
let relay_path = unique_exit_code_path();
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"__relay",
|
|
"--same-console",
|
|
"--console-pid",
|
|
"4242",
|
|
"--exit-code-path",
|
|
relay_path.to_string_lossy().as_ref(),
|
|
"--user",
|
|
"system",
|
|
"--dangerous",
|
|
"--wait",
|
|
"--",
|
|
"cmd",
|
|
"/d",
|
|
"/c",
|
|
"exit 0",
|
|
])
|
|
.expect("relay cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
let code = run_with_provider(&cli, &provider).expect("relay should succeed");
|
|
assert_eq!(code, 0);
|
|
assert_eq!(provider.borrow().attached_console_pids, vec![4242]);
|
|
let _ = std::fs::remove_file(relay_path);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_allows_interactive_system_and_trustedinstaller_shells() {
|
|
let mut run = sample_run();
|
|
run.command.clear();
|
|
run.user = LaunchIdentity::System;
|
|
run.dangerous = true;
|
|
assert!(validate_run_cli(&run).is_ok());
|
|
run.user = LaunchIdentity::TrustedInstaller;
|
|
assert!(validate_run_cli(&run).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn elevated_launch_calls_provider_directly() {
|
|
let (_, cli) = parse_cli_from([
|
|
"msudo",
|
|
"--user",
|
|
"system",
|
|
"--dangerous",
|
|
"--",
|
|
"cmd",
|
|
"/d",
|
|
"/c",
|
|
"whoami",
|
|
])
|
|
.expect("cli should parse");
|
|
let provider = std::cell::RefCell::new(MockProvider::new());
|
|
let code = run_with_provider(&cli, &provider).expect("run should succeed");
|
|
assert_eq!(code, ExitCode::Success.as_i32());
|
|
assert_eq!(provider.borrow().launched.len(), 1);
|
|
assert_eq!(
|
|
provider.borrow().launched[0].identity,
|
|
LaunchIdentity::System
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_launch_resolves_bare_direct_commands_from_path() {
|
|
let temp_root = std::env::temp_dir().join(format!(
|
|
"msudo-prepare-launch-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock after epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&temp_root).expect("create temp root");
|
|
let tool = temp_root.join("tool.exe");
|
|
std::fs::write(&tool, []).expect("create tool stub");
|
|
let resolved = find_executable_in_path(
|
|
"tool",
|
|
temp_root.as_os_str(),
|
|
Some(std::ffi::OsStr::new(".EXE")),
|
|
)
|
|
.expect("tool should resolve");
|
|
let _ = std::fs::remove_file(&tool);
|
|
let _ = std::fs::remove_dir(&temp_root);
|
|
assert_eq!(resolved, tool.display().to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn elevated_direct_commands_reject_path_resolved_bare_programs() {
|
|
let temp_root = std::env::temp_dir().join(format!(
|
|
"msudo-direct-path-policy-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock after epoch")
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&temp_root).expect("create temp root");
|
|
let tool = temp_root.join("tool.exe");
|
|
fs::write(&tool, []).expect("create tool stub");
|
|
|
|
let error = prepare_direct_command(
|
|
"tool",
|
|
&["--version".to_string()],
|
|
LaunchIdentity::Admin,
|
|
Some(temp_root.as_os_str()),
|
|
Some(std::ffi::OsStr::new(".EXE")),
|
|
)
|
|
.expect_err("admin direct command should reject PATH-resolved bare programs");
|
|
|
|
let _ = fs::remove_file(&tool);
|
|
let _ = fs::remove_dir(&temp_root);
|
|
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("refuses PATH-resolved bare command")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_process_direct_commands_keep_path_resolution() {
|
|
let temp_root = std::env::temp_dir().join(format!(
|
|
"msudo-current-process-path-policy-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock after epoch")
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&temp_root).expect("create temp root");
|
|
let tool = temp_root.join("tool.exe");
|
|
fs::write(&tool, []).expect("create tool stub");
|
|
|
|
let prepared = prepare_direct_command(
|
|
"tool",
|
|
&["--version".to_string()],
|
|
LaunchIdentity::CurrentProcess,
|
|
Some(temp_root.as_os_str()),
|
|
Some(std::ffi::OsStr::new(".EXE")),
|
|
)
|
|
.expect("current-process direct command may use PATH");
|
|
|
|
let _ = fs::remove_file(&tool);
|
|
let _ = fs::remove_dir(&temp_root);
|
|
|
|
assert_eq!(prepared.program, tool.display().to_string());
|
|
assert_eq!(prepared.args, vec!["--version".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_launch_defaults_interactive_shells_to_new_window() {
|
|
let (_, cli) =
|
|
parse_cli_from(["msudo", "--shell", "powershell"]).expect("cli should parse");
|
|
let CommandMode::Run(run) = cli.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
let prepared = prepare_launch(&run).expect("launch should prepare");
|
|
assert!(prepared.new_window);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_launch_same_console_keeps_interactive_shell_in_foreground() {
|
|
let (_, cli) = parse_cli_from(["msudo", "--same-console", "--shell", "powershell"])
|
|
.expect("cli should parse");
|
|
let CommandMode::Run(run) = cli.command else {
|
|
panic!("expected run mode");
|
|
};
|
|
let prepared = prepare_launch(&run).expect("launch should prepare");
|
|
assert!(prepared.same_console);
|
|
assert!(!prepared.new_window);
|
|
assert!(prepared.wait);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_launch_resolves_direct_program_to_executable_when_available() {
|
|
let resolved_cmd = resolve_direct_command_from_path(
|
|
"cmd",
|
|
std::env::var_os("PATH").as_deref(),
|
|
std::env::var_os("PATHEXT").as_deref(),
|
|
);
|
|
assert!(
|
|
resolved_cmd
|
|
.executable
|
|
.to_ascii_lowercase()
|
|
.ends_with("cmd.exe")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_resolution_candidates_prefer_known_git_bash_locations() {
|
|
let candidates = shell_resolution_candidates(ShellPreset::GitBash);
|
|
assert_eq!(
|
|
candidates.first().copied(),
|
|
Some("C:\\Program Files\\Git\\usr\\bin\\bash.exe")
|
|
);
|
|
assert!(candidates.contains(&"git-bash.exe"));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn shell_resolution_prefers_known_locations_over_path_hijack_and_reports_source() {
|
|
let temp_root = std::env::temp_dir().join(format!(
|
|
"msudo-shell-hijack-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("clock after epoch")
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&temp_root).expect("create temp root");
|
|
let fake_cmd = temp_root.join("cmd.exe");
|
|
fs::write(&fake_cmd, []).expect("create fake cmd");
|
|
|
|
let resolved = resolve_shell_from_path(
|
|
ShellPreset::Cmd,
|
|
Some(temp_root.as_os_str()),
|
|
Some(std::ffi::OsStr::new(".EXE")),
|
|
)
|
|
.expect("cmd should resolve");
|
|
|
|
let _ = fs::remove_file(&fake_cmd);
|
|
let _ = fs::remove_dir(&temp_root);
|
|
|
|
assert_ne!(resolved.executable, fake_cmd.display().to_string());
|
|
assert_eq!(resolved.source, ShellResolutionSource::KnownLocation);
|
|
assert!(
|
|
resolved
|
|
.executable
|
|
.to_ascii_lowercase()
|
|
.ends_with("\\system32\\cmd.exe")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn msys_like_presets_do_not_fall_back_to_generic_bash() {
|
|
for preset in [ShellPreset::Mingw, ShellPreset::Msys2, ShellPreset::Cygwin] {
|
|
let candidates = shell_resolution_candidates(preset);
|
|
assert!(!candidates.contains(&"bash.exe"));
|
|
assert!(!candidates.contains(&"bash"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn read_relay_exit_code_parses_and_removes_temp_file() {
|
|
let path = unique_exit_code_path();
|
|
assert!(!path.exists());
|
|
fs::write(&path, "17").expect("write exit code");
|
|
let code = read_relay_exit_code(&path).expect("read exit code");
|
|
assert_eq!(code, 17);
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn write_relay_exit_status_refuses_replaced_path() {
|
|
let path = unique_exit_code_path();
|
|
fs::write(&path, "attacker").expect("attacker replacement");
|
|
|
|
let error = write_relay_exit_status(
|
|
&path,
|
|
&RelayExitStatus {
|
|
ok: true,
|
|
exit_code: Some(0),
|
|
error: None,
|
|
},
|
|
)
|
|
.expect_err("relay writer should refuse existing replacement");
|
|
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("failed to create relay exit status")
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&path).expect("replacement remains"),
|
|
"attacker"
|
|
);
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn read_relay_exit_code_surfaces_json_error_status() {
|
|
let path = unique_exit_code_path();
|
|
write_relay_exit_status(
|
|
&path,
|
|
&RelayExitStatus {
|
|
ok: false,
|
|
exit_code: None,
|
|
error: Some("CreateProcessWithTokenW failed with code 5".to_string()),
|
|
},
|
|
)
|
|
.expect("write relay status");
|
|
let error = read_relay_exit_code(&path).expect_err("relay should surface error");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("CreateProcessWithTokenW failed with code 5")
|
|
);
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
fn sample_run() -> RunCli {
|
|
RunCli {
|
|
user: LaunchIdentity::Admin,
|
|
dangerous: false,
|
|
shell: Some(ShellPreset::Pwsh),
|
|
integrity: None,
|
|
privileges: PrivilegeMode::Default,
|
|
priority: ProcessPriority::Normal,
|
|
show_window: ShowWindowMode::Default,
|
|
session: None,
|
|
current_directory: None,
|
|
same_console: false,
|
|
new_window: false,
|
|
wait: false,
|
|
command: vec![
|
|
"cmd".to_string(),
|
|
"/d".to_string(),
|
|
"/c".to_string(),
|
|
"whoami".to_string(),
|
|
],
|
|
}
|
|
}
|
|
}
|