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    // GAP-SSH-EXEC-ENVELOPE-002: capture the identity before the name is consumed by
36    // the registry lookup, so every emitted event can name its own target.
37    let target = crate::json_wire::ExecTarget::new(vps_name.clone(), opts.target_source);
38    let path = resolve_config_path(config_override.as_deref())?;
39    let mut file = load(&path)?;
40    // Move the record out of the local map (file is discarded after connect setup).
41    let mut vps = file
42        .hosts
43        .remove(&vps_name)
44        .ok_or(SshCliError::VpsNotFound(vps_name))?;
45    // GAP-SSH-EXEC-ENVELOPE-002: publish the target only once the lookup succeeded,
46    // so every failure from here on names its host. Publishing before the `?` above
47    // would make a `VpsNotFound` envelope claim a host it never resolved.
48    crate::json_wire::set_resolved_target(&target);
49
50    apply_overrides(&mut vps, opts.take_auth_overrides());
51    let cmd = append_description(command, opts.description.as_deref());
52    validate_command_length(&cmd, vps.max_command_chars.wire())?;
53    for s in &opts.steps {
54        validate_command_length(s.as_str(), vps.max_command_chars.wire())?;
55    }
56    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
57    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
58    run_exec_with_client_steps(&vps, &cmd, &opts.steps, client, format, json, &target).await
59}
60
61/// Testable version of run_exec.
62pub async fn run_exec_with_client(
63    vps: &VpsRecord,
64    command: &str,
65    client: Box<dyn SshClientTrait>,
66    format: OutputFormat,
67    json: bool,
68) -> Result<()> {
69    let target =
70        crate::json_wire::ExecTarget::new(vps.name.as_str(), crate::json_wire::TargetSource::Argv);
71    run_exec_with_client_steps(vps, command, &[], client, format, json, &target).await
72}
73
74/// One remote step ready for the wire.
75///
76/// `label` is the raw command the caller typed; `packed` is what actually goes over
77/// the channel (plain for `exec`, `sudo -S … sh -c` / `su - -c` for the elevated
78/// paths, with the password on stdin). Keeping both apart lets every elevation kind
79/// share one step loop while output still shows the command the user wrote.
80pub(crate) struct PreparedStep {
81    /// Raw command echoed in JSON (`command`) and in the text step header.
82    label: String,
83    /// Wire form plus optional stdin payload (zeroized on drop).
84    packed: PackedCommand,
85}
86
87impl PreparedStep {
88    /// Step with no elevation wrapper (`exec`).
89    pub(crate) fn plain(command: &str) -> Self {
90        Self {
91            label: command.to_owned(),
92            packed: PackedCommand {
93                command: command.to_owned(),
94                stdin: None,
95            },
96        }
97    }
98
99    /// Step from an already packed elevation command (`sudo-exec` / `su-exec`).
100    pub(crate) fn packed(label: &str, packed: PackedCommand) -> Self {
101        Self {
102            label: label.to_owned(),
103            packed,
104        }
105    }
106}
107
108/// Builds `primary + --step …` as raw command strings (description only on the primary).
109pub(crate) fn step_labels(command: &str, steps: &[crate::domain::RemoteCommand]) -> Vec<String> {
110    let mut out = Vec::with_capacity(1 + steps.len());
111    out.push(command.to_owned());
112    out.extend(steps.iter().map(|s| s.as_str().to_owned()));
113    out
114}
115
116/// G-O3: runs every prepared step on **one** SSH session, then disconnects.
117///
118/// Shared by `exec`, `sudo-exec` and `su-exec`: the elevated paths used to ignore
119/// `--step` entirely and still exit 0, which reported a partial run as a full one.
120/// Every step is executed here, the first non-zero exit is remembered, and the
121/// remaining steps still run so their output is not lost.
122///
123/// One exception, and it is a security one: exit **127 on step zero** aborts the
124/// batch immediately (GAP-SSH-EXEC-ARGC-001 rule 6). That code on the first step is
125/// the fingerprint of a host name having been promoted to a command, so continuing
126/// would run the remaining steps against a machine the caller never designated.
127/// Every other non-zero exit, at any index, keeps the run-everything contract.
128pub(crate) async fn run_prepared_steps(
129    vps: &VpsRecord,
130    steps: Vec<PreparedStep>,
131    mut client: Box<dyn SshClientTrait>,
132    format: OutputFormat,
133    json: bool,
134    target: &crate::json_wire::ExecTarget,
135) -> Result<()> {
136    if crate::signals::should_stop() {
137        return Err(cancelled_err());
138    }
139    let max_out = effective_limit(vps.max_output_chars.wire());
140    let as_json = format == OutputFormat::Json || json;
141    let multi = steps.len() > 1;
142    let mut last_output: Option<ExecutionOutput> = None;
143    let mut failed: Option<(i32, String)> = None;
144    for (i, mut step) in steps.into_iter().enumerate() {
145        if crate::signals::should_stop() {
146            let _ = client.disconnect().await;
147            return Err(cancelled_err());
148        }
149        tracing::debug!(step = i, "exec multi-cmd step");
150        // Move stdin out so the password is written once and zeroized by `run_command`.
151        let stdin = step.packed.take_stdin();
152        match client
153            .run_command(&step.packed.command, max_out, stdin)
154            .await
155        {
156            Ok(output) => {
157                if let Some(code) = output.exit_code {
158                    if code != 0 && failed.is_none() {
159                        failed = Some((code, output.stderr.clone()));
160                    }
161                    // GAP-SSH-EXEC-ARGC-001 rule 6: 127 on step *zero* is the
162                    // signature of the misdirection itself. When a host name is
163                    // promoted to a command, the shell answers `command not found`
164                    // and every later `--step` then runs on a machine nobody named,
165                    // which is exactly how one incident aggregated to exit 0.
166                    //
167                    // Scoped to index zero on purpose. A later step returning 127 is
168                    // an ordinary missing binary and keeps the existing contract:
169                    // remaining steps run so their output is not lost. Index zero is
170                    // different because nothing has been established yet — there is
171                    // no evidence the session is aimed where the caller believes.
172                    if i == 0 && code == 127 {
173                        let _ = client.disconnect().await;
174                        return Err(SshCliError::CommandFailed {
175                            exit_code: code,
176                            stderr: output.stderr,
177                        }
178                        .into());
179                    }
180                }
181                // G8: multi-step emits one document per step (JSON and text alike);
182                // single-step must emit exactly one object, printed after the loop.
183                if multi && as_json {
184                    let mut v = serde_json::to_value(crate::json_wire::ExecutionJson::with_target(
185                        &output, target,
186                    ))
187                    .unwrap_or_else(|_| serde_json::json!({}));
188                    if let Some(obj) = v.as_object_mut() {
189                        obj.insert("step".into(), serde_json::json!(i));
190                        obj.insert("command".into(), serde_json::json!(step.label));
191                    }
192                    crate::output::print_json_value(&v)?;
193                } else if multi {
194                    crate::output::write_line_fmt(format_args!(
195                        "--- step {i}: {} ---",
196                        step.label
197                    ))?;
198                    crate::output::print_execution_output(&output);
199                } else {
200                    last_output = Some(output);
201                }
202            }
203            Err(e) => {
204                let _ = client.disconnect().await;
205                return Err(e.into());
206            }
207        }
208    }
209    let _ = client.disconnect().await;
210    // Print before failing: a non-zero remote exit still produced output the caller
211    // needs (parity with the previous single-command elevated path).
212    if let Some(output) = last_output {
213        if as_json {
214            crate::output::print_execution_output_json(&output, target)?;
215        } else {
216            crate::output::print_execution_output(&output);
217        }
218    }
219    if let Some((code, stderr)) = failed {
220        return Err(SshCliError::CommandFailed {
221            exit_code: code,
222            stderr,
223        }
224        .into());
225    }
226    Ok(())
227}
228
229/// G-O3: one SSH session, primary command + optional extra `--step` commands.
230pub async fn run_exec_with_client_steps(
231    vps: &VpsRecord,
232    command: &str,
233    steps: &[crate::domain::RemoteCommand],
234    client: Box<dyn SshClientTrait>,
235    format: OutputFormat,
236    json: bool,
237    target: &crate::json_wire::ExecTarget,
238) -> Result<()> {
239    let prepared = step_labels(command, steps)
240        .iter()
241        .map(|c| PreparedStep::plain(c))
242        .collect();
243    run_prepared_steps(vps, prepared, client, format, json, target).await
244}