Skip to main content

ssh_cli/vps/exec_ops/
elevation.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `sudo-exec` and `su-exec` entry points (A7 split).
3#![forbid(unsafe_code)]
4#![allow(unused_imports)]
5use super::*;
6
7/// Runs a command with `sudo` (packed via `sh -c`).
8///
9/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
10pub async fn run_sudo_exec(
11    selection: HostSelection,
12    command: &str,
13    config_override: Option<PathBuf>,
14    format: OutputFormat,
15    json: bool,
16    mut opts: ExecOptions,
17) -> Result<()> {
18    if crate::signals::should_stop() {
19        return Err(cancelled_err());
20    }
21    if selection.is_batch() {
22        return run_exec_all(
23            &selection,
24            command,
25            config_override,
26            format,
27            json,
28            opts,
29            ExecKind::Sudo,
30        )
31        .await;
32    }
33    let vps_name = expect_single(selection)?;
34    let path = resolve_config_path(config_override.as_deref())?;
35    let mut file = load(&path)?;
36    let mut vps = file
37        .hosts
38        .remove(&vps_name)
39        .ok_or(SshCliError::VpsNotFound(vps_name))?;
40
41    apply_overrides(&mut vps, opts.take_auth_overrides());
42    if opts.disable_sudo || vps.disable_sudo {
43        return Err(SshCliError::SudoDisabled.into());
44    }
45    let cmd = append_description(command, opts.description.as_deref());
46    validate_command_length(&cmd, vps.max_command_chars.wire())?;
47    for s in &opts.steps {
48        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
49    }
50    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
51    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
52    run_sudo_exec_with_client_steps(&vps, &cmd, &opts.steps, client, format, json).await
53}
54
55/// Testable version of sudo-exec.
56pub async fn run_sudo_exec_with_client(
57    vps: &VpsRecord,
58    command: &str,
59    client: Box<dyn SshClientTrait>,
60    format: OutputFormat,
61    json: bool,
62) -> Result<()> {
63    run_sudo_exec_with_client_steps(vps, command, &[], client, format, json).await
64}
65
66/// G-O3 parity: `sudo-exec` primary command plus `--step` commands on one session.
67///
68/// Each step is packed on its own because `sudo -S` consumes the password from the
69/// channel stdin per command; reusing a single [`PackedCommand`] would leave the
70/// later steps without credentials.
71pub async fn run_sudo_exec_with_client_steps(
72    vps: &VpsRecord,
73    command: &str,
74    steps: &[crate::domain::RemoteCommand],
75    client: Box<dyn SshClientTrait>,
76    format: OutputFormat,
77    json: bool,
78) -> Result<()> {
79    if crate::signals::should_stop() {
80        return Err(cancelled_err());
81    }
82    if vps.disable_sudo {
83        return Err(SshCliError::SudoDisabled.into());
84    }
85    let prepared = step_labels(command, steps)
86        .iter()
87        .map(|c| PreparedStep::packed(c, pack_sudo(c, vps.sudo_password.as_ref())))
88        .collect();
89    run_prepared_steps(vps, prepared, client, format, json).await
90}
91
92/// Runs a command via `su -` one-shot (consumes `su_password`).
93///
94/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
95pub async fn run_su_exec(
96    selection: HostSelection,
97    command: &str,
98    config_override: Option<PathBuf>,
99    format: OutputFormat,
100    json: bool,
101    mut opts: ExecOptions,
102) -> Result<()> {
103    if crate::signals::should_stop() {
104        return Err(cancelled_err());
105    }
106    if selection.is_batch() {
107        return run_exec_all(
108            &selection,
109            command,
110            config_override,
111            format,
112            json,
113            opts,
114            ExecKind::Su,
115        )
116        .await;
117    }
118    let vps_name = expect_single(selection)?;
119    let path = resolve_config_path(config_override.as_deref())?;
120    let mut file = load(&path)?;
121    let mut vps = file
122        .hosts
123        .remove(&vps_name)
124        .ok_or(SshCliError::VpsNotFound(vps_name))?;
125
126    apply_overrides(&mut vps, opts.take_auth_overrides());
127    if opts.disable_sudo || vps.disable_sudo {
128        return Err(SshCliError::SudoDisabled.into());
129    }
130    // `take` moves the secret out of the record (no clone of SecretString).
131    let su_password = vps
132        .su_password
133        .take()
134        .ok_or(SshCliError::SuPasswordMissing)?;
135    let cmd = append_description(command, opts.description.as_deref());
136    validate_command_length(&cmd, vps.max_command_chars.wire())?;
137    for s in &opts.steps {
138        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
139    }
140    // G-O3 parity: every step gets its own `su - -c` pack so each one receives the
141    // password on stdin; previously `--step` was silently dropped on this path.
142    let prepared = step_labels(&cmd, &opts.steps)
143        .iter()
144        .map(|c| PreparedStep::packed(c, pack_su(c, &su_password)))
145        .collect();
146    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
147    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
148    run_prepared_steps(&vps, prepared, client, format, json).await
149}