Skip to main content

ssh_cli/vps/exec_ops/
single.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Single-host exec and the shared multi-step engine (A7 split).
3#![forbid(unsafe_code)]
4#![allow(unused_imports)]
5use super::*;
6
7/// Runs a shell command on one VPS or a multi-host selection (bounded).
8///
9/// Workload: **I/O-bound** SSH. Multi-host (`All` / `Named`) uses
10/// [`crate::concurrency::map_bounded`]. Batch JSON when [`HostSelection::is_batch`].
11pub async fn run_exec(
12    selection: HostSelection,
13    command: &str,
14    config_override: Option<PathBuf>,
15    format: OutputFormat,
16    json: bool,
17    mut opts: ExecOptions,
18) -> Result<()> {
19    if crate::signals::should_stop() {
20        return Err(cancelled_err());
21    }
22    if selection.is_batch() {
23        return run_exec_all(
24            &selection,
25            command,
26            config_override,
27            format,
28            json,
29            opts,
30            ExecKind::Plain,
31        )
32        .await;
33    }
34    let vps_name = expect_single(selection)?;
35    let path = resolve_config_path(config_override.as_deref())?;
36    let mut file = load(&path)?;
37    // Move the record out of the local map (file is discarded after connect setup).
38    let mut vps = file
39        .hosts
40        .remove(&vps_name)
41        .ok_or(SshCliError::VpsNotFound(vps_name))?;
42
43    apply_overrides(&mut vps, opts.take_auth_overrides());
44    let cmd = append_description(command, opts.description.as_deref());
45    validate_command_length(&cmd, vps.max_command_chars.wire())?;
46    for s in &opts.steps {
47        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
48    }
49    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
50    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
51    run_exec_with_client_steps(&vps, &cmd, &opts.steps, client, format, json).await
52}
53
54/// Testable version of run_exec.
55pub async fn run_exec_with_client(
56    vps: &VpsRecord,
57    command: &str,
58    client: Box<dyn SshClientTrait>,
59    format: OutputFormat,
60    json: bool,
61) -> Result<()> {
62    run_exec_with_client_steps(vps, command, &[], client, format, json).await
63}
64
65/// One remote step ready for the wire.
66///
67/// `label` is the raw command the caller typed; `packed` is what actually goes over
68/// the channel (plain for `exec`, `sudo -S … sh -c` / `su - -c` for the elevated
69/// paths, with the password on stdin). Keeping both apart lets every elevation kind
70/// share one step loop while output still shows the command the user wrote.
71pub(crate) struct PreparedStep {
72    /// Raw command echoed in JSON (`command`) and in the text step header.
73    label: String,
74    /// Wire form plus optional stdin payload (zeroized on drop).
75    packed: PackedCommand,
76}
77
78impl PreparedStep {
79    /// Step with no elevation wrapper (`exec`).
80    pub(crate) fn plain(command: &str) -> Self {
81        Self {
82            label: command.to_owned(),
83            packed: PackedCommand {
84                command: command.to_owned(),
85                stdin: None,
86            },
87        }
88    }
89
90    /// Step from an already packed elevation command (`sudo-exec` / `su-exec`).
91    pub(crate) fn packed(label: &str, packed: PackedCommand) -> Self {
92        Self {
93            label: label.to_owned(),
94            packed,
95        }
96    }
97}
98
99/// Builds `primary + --step …` as raw command strings (description only on the primary).
100pub(crate) fn step_labels(command: &str, steps: &[crate::domain::RemoteCommand]) -> Vec<String> {
101    let mut out = Vec::with_capacity(1 + steps.len());
102    out.push(command.to_owned());
103    out.extend(steps.iter().map(|s| s.as_str().to_owned()));
104    out
105}
106
107/// G-O3: runs every prepared step on **one** SSH session, then disconnects.
108///
109/// Shared by `exec`, `sudo-exec` and `su-exec`: the elevated paths used to ignore
110/// `--step` entirely and still exit 0, which reported a partial run as a full one.
111/// Every step is executed here, the first non-zero exit is remembered, and the
112/// remaining steps still run so their output is not lost.
113pub(crate) async fn run_prepared_steps(
114    vps: &VpsRecord,
115    steps: Vec<PreparedStep>,
116    mut client: Box<dyn SshClientTrait>,
117    format: OutputFormat,
118    json: bool,
119) -> Result<()> {
120    if crate::signals::should_stop() {
121        return Err(cancelled_err());
122    }
123    let max_out = effective_limit(vps.max_output_chars.wire());
124    let as_json = format == OutputFormat::Json || json;
125    let multi = steps.len() > 1;
126    let mut last_output: Option<ExecutionOutput> = None;
127    let mut failed: Option<(i32, String)> = None;
128    for (i, mut step) in steps.into_iter().enumerate() {
129        if crate::signals::should_stop() {
130            let _ = client.disconnect().await;
131            return Err(cancelled_err());
132        }
133        tracing::debug!(step = i, "exec multi-cmd step");
134        // Move stdin out so the password is written once and zeroized by `run_command`.
135        let stdin = step.packed.take_stdin();
136        match client
137            .run_command(&step.packed.command, max_out, stdin)
138            .await
139        {
140            Ok(output) => {
141                if let Some(code) = output.exit_code {
142                    if code != 0 && failed.is_none() {
143                        failed = Some((code, output.stderr.clone()));
144                    }
145                }
146                // G8: multi-step emits one document per step (JSON and text alike);
147                // single-step must emit exactly one object, printed after the loop.
148                if multi && as_json {
149                    let mut v =
150                        serde_json::to_value(crate::json_wire::ExecutionJson::from(&output))
151                            .unwrap_or_else(|_| serde_json::json!({}));
152                    if let Some(obj) = v.as_object_mut() {
153                        obj.insert("step".into(), serde_json::json!(i));
154                        obj.insert("command".into(), serde_json::json!(step.label));
155                    }
156                    crate::output::print_json_value(&v)?;
157                } else if multi {
158                    crate::output::write_line_fmt(format_args!(
159                        "--- step {i}: {} ---",
160                        step.label
161                    ))?;
162                    crate::output::print_execution_output(&output);
163                } else {
164                    last_output = Some(output);
165                }
166            }
167            Err(e) => {
168                let _ = client.disconnect().await;
169                return Err(e.into());
170            }
171        }
172    }
173    let _ = client.disconnect().await;
174    // Print before failing: a non-zero remote exit still produced output the caller
175    // needs (parity with the previous single-command elevated path).
176    if let Some(output) = last_output {
177        if as_json {
178            crate::output::print_execution_output_json(&output)?;
179        } else {
180            crate::output::print_execution_output(&output);
181        }
182    }
183    if let Some((code, stderr)) = failed {
184        return Err(SshCliError::CommandFailed {
185            exit_code: code,
186            stderr,
187        }
188        .into());
189    }
190    Ok(())
191}
192
193/// G-O3: one SSH session, primary command + optional extra `--step` commands.
194pub async fn run_exec_with_client_steps(
195    vps: &VpsRecord,
196    command: &str,
197    steps: &[crate::domain::RemoteCommand],
198    client: Box<dyn SshClientTrait>,
199    format: OutputFormat,
200    json: bool,
201) -> Result<()> {
202    let prepared = step_labels(command, steps)
203        .iter()
204        .map(|c| PreparedStep::plain(c))
205        .collect();
206    run_prepared_steps(vps, prepared, client, format, json).await
207}