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 target = crate::json_wire::ExecTarget::new(vps_name.clone(), opts.target_source);
35    let path = resolve_config_path(config_override.as_deref())?;
36    let mut file = load(&path)?;
37    let mut vps = file
38        .hosts
39        .remove(&vps_name)
40        .ok_or(SshCliError::VpsNotFound(vps_name))?;
41    // GAP-SSH-EXEC-ENVELOPE-002: see `single.rs` — published after the lookup so a
42    // `VpsNotFound` never claims a resolved host. Elevation needs this more than the
43    // plain path does: a misdirected `sudo` step writes root-owned state.
44    crate::json_wire::set_resolved_target(&target);
45
46    apply_overrides(&mut vps, opts.take_auth_overrides());
47    if opts.disable_sudo || vps.disable_sudo {
48        return Err(SshCliError::SudoDisabled.into());
49    }
50    let cmd = append_description(command, opts.description.as_deref());
51    validate_command_length(&cmd, vps.max_command_chars.wire())?;
52    for s in &opts.steps {
53        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
54    }
55    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
56    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
57    run_sudo_exec_with_client_steps(&vps, &cmd, &opts.steps, client, format, json, &target).await
58}
59
60/// Testable version of sudo-exec.
61pub async fn run_sudo_exec_with_client(
62    vps: &VpsRecord,
63    command: &str,
64    client: Box<dyn SshClientTrait>,
65    format: OutputFormat,
66    json: bool,
67) -> Result<()> {
68    let target =
69        crate::json_wire::ExecTarget::new(vps.name.as_str(), crate::json_wire::TargetSource::Argv);
70    run_sudo_exec_with_client_steps(vps, command, &[], client, format, json, &target).await
71}
72
73/// G-O3 parity: `sudo-exec` primary command plus `--step` commands on one session.
74///
75/// Each step is packed on its own because `sudo -S` consumes the password from the
76/// channel stdin per command; reusing a single [`PackedCommand`] would leave the
77/// later steps without credentials.
78pub async fn run_sudo_exec_with_client_steps(
79    vps: &VpsRecord,
80    command: &str,
81    steps: &[crate::domain::RemoteCommand],
82    client: Box<dyn SshClientTrait>,
83    format: OutputFormat,
84    json: bool,
85    target: &crate::json_wire::ExecTarget,
86) -> Result<()> {
87    if crate::signals::should_stop() {
88        return Err(cancelled_err());
89    }
90    if vps.disable_sudo {
91        return Err(SshCliError::SudoDisabled.into());
92    }
93    let prepared = step_labels(command, steps)
94        .iter()
95        .map(|c| PreparedStep::packed(c, pack_sudo(c, vps.sudo_password.as_ref())))
96        .collect();
97    run_prepared_steps(vps, prepared, client, format, json, target).await
98}
99
100/// Runs a command via `su -` one-shot (consumes `su_password`).
101///
102/// Workload: **I/O-bound** SSH. Multi-host uses [`crate::concurrency::map_bounded`].
103pub async fn run_su_exec(
104    selection: HostSelection,
105    command: &str,
106    config_override: Option<PathBuf>,
107    format: OutputFormat,
108    json: bool,
109    mut opts: ExecOptions,
110) -> Result<()> {
111    if crate::signals::should_stop() {
112        return Err(cancelled_err());
113    }
114    if selection.is_batch() {
115        return run_exec_all(
116            &selection,
117            command,
118            config_override,
119            format,
120            json,
121            opts,
122            ExecKind::Su,
123        )
124        .await;
125    }
126    let vps_name = expect_single(selection)?;
127    let target = crate::json_wire::ExecTarget::new(vps_name.clone(), opts.target_source);
128    let path = resolve_config_path(config_override.as_deref())?;
129    let mut file = load(&path)?;
130    let mut vps = file
131        .hosts
132        .remove(&vps_name)
133        .ok_or(SshCliError::VpsNotFound(vps_name))?;
134    // GAP-SSH-EXEC-ENVELOPE-002: see `single.rs` — published after the lookup so a
135    // `VpsNotFound` never claims a resolved host. Elevation needs this more than the
136    // plain path does: a misdirected `sudo` step writes root-owned state.
137    crate::json_wire::set_resolved_target(&target);
138
139    apply_overrides(&mut vps, opts.take_auth_overrides());
140    if opts.disable_sudo || vps.disable_sudo {
141        return Err(SshCliError::SudoDisabled.into());
142    }
143    // `take` moves the secret out of the record (no clone of SecretString).
144    let su_password = vps
145        .su_password
146        .take()
147        .ok_or(SshCliError::SuPasswordMissing)?;
148    let cmd = append_description(command, opts.description.as_deref());
149    validate_command_length(&cmd, vps.max_command_chars.wire())?;
150    for s in &opts.steps {
151        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
152    }
153    // G-O3 parity: every step gets its own `su - -c` pack so each one receives the
154    // password on stdin; previously `--step` was silently dropped on this path.
155    let prepared = step_labels(&cmd, &opts.steps)
156        .iter()
157        .map(|c| PreparedStep::packed(c, pack_su(c, &su_password)))
158        .collect();
159    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
160    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
161    run_prepared_steps(&vps, prepared, client, format, json, &target).await
162}