Skip to main content

ssh_cli/json_wire/
execution.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: exec/health/scp/sftp/tunnel JSON DTOs (extracted from json_wire monolith).
3#![forbid(unsafe_code)]
4//! Typed JSON DTOs for one-shot SSH operation results.
5
6use crate::ssh::ExecutionOutput;
7use serde::{Deserialize, Serialize};
8
9// `TargetSource`, `ExecTarget` and the process-wide resolved-target slot live in
10// `json_wire/exec_target.rs`. They are re-exported from `json_wire` so call sites
11// are unaffected by the split.
12use super::exec_target::{ExecTarget, TargetEcho, TargetSource};
13
14/// `exec` / `sudo-exec` / `su-exec` JSON stdout.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct ExecutionJson {
17    /// Canonical target fields plus their `host_*` read aliases.
18    #[serde(flatten)]
19    pub target: TargetEcho,
20    /// Whether the host was inherited from the active marker instead of argv.
21    ///
22    /// Redundant with [`TargetEcho::target_source`] on purpose: a boolean gate is what
23    /// a shell pipeline can branch on without knowing the enum spelling.
24    #[serde(default)]
25    pub active_fallback: bool,
26    /// Captured remote stdout.
27    pub stdout: String,
28    /// Captured remote stderr.
29    pub stderr: String,
30    /// Remote exit code when available.
31    pub exit_code: Option<i32>,
32    /// Whether stdout was truncated by max_output_chars.
33    pub truncated_stdout: bool,
34    /// Whether stderr was truncated by max_output_chars.
35    pub truncated_stderr: bool,
36    /// Wall-clock duration in milliseconds.
37    pub duration_ms: u64,
38}
39
40impl ExecutionJson {
41    /// Builds the envelope with the target identity attached.
42    ///
43    /// The only constructor, deliberately. A `From<&ExecutionOutput>` conversion used
44    /// to exist and filled the audit fields with empty values, because an
45    /// `ExecutionOutput` knows what happened and not where. Removing it makes an
46    /// envelope that disclaims its own target unrepresentable.
47    #[must_use]
48    pub fn with_target(o: &ExecutionOutput, target: &ExecTarget) -> Self {
49        Self {
50            target: TargetEcho::new(target),
51            active_fallback: target.source.is_ambient(),
52            stdout: o.stdout.clone(),
53            stderr: o.stderr.clone(),
54            exit_code: o.exit_code,
55            truncated_stdout: o.truncated_stdout,
56            truncated_stderr: o.truncated_stderr,
57            duration_ms: o.duration_ms,
58        }
59    }
60}
61
62// `From<&ExecutionOutput> for ExecutionJson` used to live here and filled
63// `host_resolved` with an empty string, because an `ExecutionOutput` knows what
64// happened and not where. Measured before removal: its only caller was a unit test,
65// so no production path ever emitted a host-less envelope — but the conversion was a
66// loaded gun pointing at GAP-SSH-EXEC-ENVELOPE-002. The first future `.into()` would
67// have produced an envelope that silently disclaims its own target, which is the
68// exact condition that gap exists to remove. [`ExecutionJson::with_target`] is now
69// the only constructor, so "envelope without a target" is unrepresentable rather
70// than merely discouraged.
71
72/// `health-check --json` stdout (single host).
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct HealthCheckJson {
75    /// VPS name checked.
76    pub name: String,
77    /// Resolved target under the canonical `target_*` names and the `host_*` aliases.
78    ///
79    /// The marker is reachable only through an explicit `--use-active`; a nameless
80    /// `health-check` is a usage error. Provenance is still reported, because the
81    /// opt-in is a claim about intent and the field is what makes it checkable: it
82    /// separates a host the caller typed from one `connect` last wrote, and a reader
83    /// auditing a probe after the fact cannot recover that from the name alone.
84    #[serde(flatten)]
85    pub target: TargetEcho,
86    /// Always `"ok"` on success path.
87    pub status: String,
88    /// Round-trip latency in milliseconds.
89    pub latency_ms: u64,
90}
91
92/// One entry in `health-check --all --json`.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct HealthHostJson {
95    /// VPS name.
96    pub name: String,
97    /// `"ok"` or `"error"`.
98    pub status: String,
99    /// Latency when measured.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub latency_ms: Option<u64>,
102    /// Error detail when status is error.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub error: Option<String>,
105}
106
107/// `health-check --all --json` batch envelope.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct HealthBatchJson {
110    /// Discriminator.
111    pub event: String,
112    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
113    pub batch_run_id: String,
114    /// Concurrency budget used for the fan-out.
115    pub max_concurrency: u32,
116    /// Per-host results (stable name order when possible).
117    pub results: Vec<HealthHostJson>,
118}
119
120/// One entry in multi-host `exec --all --json`.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct ExecHostJson {
123    /// VPS name.
124    pub name: String,
125    /// Whether remote exit was 0.
126    pub ok: bool,
127    /// Remote exit code.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub exit_code: Option<i32>,
130    /// Captured stdout.
131    pub stdout: String,
132    /// Captured stderr.
133    pub stderr: String,
134    /// Duration in milliseconds.
135    pub duration_ms: u64,
136    /// Error summary.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub error: Option<String>,
139}
140
141/// Multi-host exec batch envelope.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct ExecBatchJson {
144    /// Discriminator.
145    pub event: String,
146    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
147    pub batch_run_id: String,
148    /// Concurrency budget used.
149    pub max_concurrency: u32,
150    /// Per-host results.
151    pub results: Vec<ExecHostJson>,
152}
153
154/// One entry in multi-host `scp --all --json`.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct ScpHostJson {
157    /// VPS name.
158    pub name: String,
159    /// Whether transfer succeeded.
160    pub ok: bool,
161    /// Bytes transferred when ok.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub bytes: Option<u64>,
164    /// Duration ms when measured.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub duration_ms: Option<u64>,
167    /// Local path used (download may be host-suffixed).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub local: Option<String>,
170    /// Error detail.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub error: Option<String>,
173}
174
175/// Multi-host SCP batch envelope.
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177pub struct ScpBatchJson {
178    /// Discriminator.
179    pub event: String,
180    /// How the host set was designated.
181    ///
182    /// [`TargetSource::Selector`] for the fleet paths, and [`TargetSource::Argv`] for
183    /// single-host multi-file (G-PAR-37 / G-PAR-47), which reuses this envelope for a
184    /// host the caller typed. Hardcoding `selector` would make that second case lie
185    /// about its own designation.
186    ///
187    /// There is deliberately no `target_resolved` here. A fleet run resolves to a
188    /// *set*, and every member is already named in `results[].name`; collapsing that
189    /// set into one scalar would be an assertion the run cannot make.
190    #[serde(default)]
191    pub target_source: TargetSource,
192    /// Compatibility alias of [`Self::target_source`] (0.5.5 spelling).
193    #[serde(default)]
194    pub host_source: TargetSource,
195    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
196    pub batch_run_id: String,
197    /// `"upload"` or `"download"`.
198    pub direction: String,
199    /// Concurrency budget used.
200    pub max_concurrency: u32,
201    /// Per-host results.
202    pub results: Vec<ScpHostJson>,
203}
204
205/// `scp upload|download --json` success stdout.
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct ScpTransferJson {
208    /// Always `true`.
209    pub ok: bool,
210    /// Discriminator: `"scp-transfer"`.
211    pub event: String,
212    /// Resolved target under the canonical `target_*` names and the `host_*` aliases.
213    ///
214    /// Explicit Target Designation: a transfer is a side effect, so the envelope has
215    /// to name the machine it landed on and say how that machine was chosen. Without
216    /// it a fleet transfer and a single-host transfer to the same name were the same
217    /// bytes, and a misdirected upload could not be detected from stdout alone. `vps`
218    /// carries the same string, but only [`TargetEcho::target_source`] states whether
219    /// the caller typed it or a selector produced it.
220    #[serde(flatten)]
221    pub target: TargetEcho,
222    /// `"upload"` or `"download"`.
223    pub direction: String,
224    /// VPS name.
225    pub vps: String,
226    /// Local path.
227    pub local: String,
228    /// Remote path.
229    pub remote: String,
230    /// Bytes transferred.
231    pub bytes: u64,
232    /// Duration in milliseconds.
233    pub duration_ms: u64,
234    /// Whether the remote modification time landed on the local file.
235    ///
236    /// G-SCP-R01: the CLI documented mtime preservation as a guarantee while treating
237    /// it as best-effort in code, and the failure was discarded at two nesting levels.
238    /// A build pipeline that decides whether to recompile by comparing mtime could act
239    /// on a timestamp that was never applied, with the symptom surfacing far from the
240    /// cause. Additive and defaulted to `true`, so events written before this field
241    /// existed still deserialize and pre-existing consumers are unaffected.
242    #[serde(default = "default_true")]
243    pub mtime_preserved: bool,
244    /// Whether the parent directory was fsynced after the atomic rename.
245    ///
246    /// G-SCP-R02: the rename is atomic but the directory entry is not durable until it
247    /// is flushed. Success was reported unqualified, so an agent could not tell a
248    /// durable write from one that a power loss would erase.
249    #[serde(default = "default_true")]
250    pub durable: bool,
251}
252
253/// Wire default for the additive SCP durability flags.
254///
255/// `true` rather than `false`: an event that predates the fields was emitted by a
256/// build that never reported a failure, so assuming loss would invent one.
257fn default_true() -> bool {
258    true
259}
260
261/// `sftp upload|download --json` success stdout (G-SFTP-09).
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263pub struct SftpTransferJson {
264    /// Always `true`.
265    pub ok: bool,
266    /// Discriminator: `"sftp-transfer"`.
267    pub event: String,
268    /// Resolved target under the canonical `target_*` names and the `host_*` aliases.
269    ///
270    /// Same reason as [`ScpTransferJson::target`]: a write to a remote filesystem must
271    /// declare which filesystem it reached.
272    #[serde(flatten)]
273    pub target: TargetEcho,
274    /// `"upload"` or `"download"`.
275    pub direction: String,
276    /// VPS name.
277    pub vps: String,
278    /// Local path.
279    pub local: String,
280    /// Remote path.
281    pub remote: String,
282    /// Bytes transferred.
283    pub bytes: u64,
284    /// Duration in milliseconds.
285    pub duration_ms: u64,
286    /// Whether the transfer was recursive.
287    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
288    pub recursive: bool,
289}
290
291/// One entry in `sftp ls --json`.
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
293pub struct SftpListEntryJson {
294    /// Base name.
295    pub name: String,
296    /// Full remote path.
297    pub path: String,
298    /// `file` | `dir` | `symlink` | `other`.
299    pub kind: String,
300    /// Size when known.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub size: Option<u64>,
303    /// Mode bits when known.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub mode: Option<u32>,
306}
307
308/// `sftp ls --json` success stdout.
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
310pub struct SftpListJson {
311    /// Always `true`.
312    pub ok: bool,
313    /// Discriminator: `"sftp-list"`.
314    pub event: String,
315    /// VPS name.
316    pub vps: String,
317    /// Directory path listed.
318    pub path: String,
319    /// Entries.
320    pub entries: Vec<SftpListEntryJson>,
321}
322
323/// `sftp mkdir|rmdir|rm|rename|stat --json` (stat uses size/mode fields).
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325pub struct SftpFsOpJson {
326    /// Always `true`.
327    pub ok: bool,
328    /// Discriminator: `"sftp-fs-op"`.
329    pub event: String,
330    /// Operation name.
331    pub op: String,
332    /// VPS name.
333    pub vps: String,
334    /// Primary path.
335    pub path: String,
336    /// Rename target when applicable.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub to: Option<String>,
339    /// Duration ms.
340    pub duration_ms: u64,
341    /// Stat kind when op=stat.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub kind: Option<String>,
344    /// Stat size when op=stat.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub size: Option<u64>,
347    /// Stat mode when op=stat.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub mode: Option<u32>,
350    /// Stat mtime when op=stat.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub mtime: Option<u32>,
353}
354
355/// Multi-host SFTP batch (reuses scp-host shape).
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
357pub struct SftpBatchJson {
358    /// Discriminator: `"sftp-batch"`.
359    pub event: String,
360    /// Provenance of the host set (see [`ScpBatchJson::target_source`]).
361    #[serde(default)]
362    pub target_source: TargetSource,
363    /// Compatibility alias of [`Self::target_source`] (0.5.5 spelling).
364    #[serde(default)]
365    pub host_source: TargetSource,
366    /// UUID v7 batch id.
367    pub batch_run_id: String,
368    /// `"upload"` or `"download"`.
369    pub direction: String,
370    /// Concurrency budget.
371    pub max_concurrency: u32,
372    /// Per-host results.
373    pub results: Vec<ScpHostJson>,
374}
375
376/// `tunnel --json` post-bind event.
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
378pub struct TunnelListeningJson {
379    /// Always `true`.
380    pub ok: bool,
381    /// Discriminator: `"tunnel_listening"`.
382    pub event: String,
383    /// VPS name.
384    pub vps: String,
385    /// Local listen port.
386    pub local_port: u16,
387    /// Remote target host.
388    pub remote_host: String,
389    /// Remote target port.
390    pub remote_port: u16,
391    /// One-shot timeout in milliseconds.
392    pub timeout_ms: u64,
393    /// Effective local bind address.
394    ///
395    /// G-TUN-R06: without this an agent could not tell a `127.0.0.1` bind from a
396    /// `0.0.0.0` one, so it had no way to audit — from the structured contract alone —
397    /// whether it had just published a remote database to the local network. Additive
398    /// field: existing consumers are unaffected.
399    pub bind: String,
400    /// Which tunnel mode is serving: `local`, `socks5`, `streamlocal` or `reverse`.
401    ///
402    /// The other fields are read differently per mode — `local_port` is a local
403    /// listener for three of them and the *server's* port for `reverse`, and
404    /// `remote_host` is a concrete target except under SOCKS5, where the
405    /// destination is chosen per connection. Without this discriminator an agent
406    /// would have to infer the mode from the flags it passed, which stops working
407    /// the moment anything else launches the tunnel.
408    #[serde(default = "default_tunnel_mode")]
409    pub mode: String,
410}
411
412/// Wire default for [`TunnelListeningJson::mode`] / [`TunnelClosedJson::mode`].
413///
414/// Events written before the mode field existed can only have been plain local
415/// forwards, so deserializing them as `local` is a statement of fact rather than
416/// a guess.
417fn default_tunnel_mode() -> String {
418    "local".to_string()
419}
420
421/// Reason a tunnel stopped serving, reported by [`TunnelClosedJson`].
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum TunnelCloseReason {
425    /// The `--timeout-ms` deadline elapsed after a successful bind (exit 0).
426    Deadline,
427    /// SIGINT / SIGTERM arrived.
428    Signal,
429    /// The accept loop hit a fatal error and stopped early.
430    AcceptError,
431}
432
433/// `tunnel --json` shutdown event.
434///
435/// G-TUN-R07: the tunnel used to emit `tunnel_listening` and then fall silent until
436/// death, so three very different endings shared exit 0 — deadline reached, signal
437/// received, and a fatal accept error that broke the loop early. The last case was
438/// the worst: because `bound` was already true, the timeout wrapper returned `Ok(())`
439/// and the process reported success even though it had stopped accepting connections
440/// seconds into a five-minute deadline. Those are now distinguishable.
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442pub struct TunnelClosedJson {
443    /// `true` when the tunnel served its full lifetime.
444    pub ok: bool,
445    /// Discriminator: `"tunnel_closed"`.
446    pub event: String,
447    /// VPS name.
448    pub vps: String,
449    /// Why the tunnel stopped.
450    pub reason: TunnelCloseReason,
451    /// Effective local bind address.
452    pub bind: String,
453    /// Local listen port that was served.
454    pub local_port: u16,
455    /// Connections accepted and forwarded during the tunnel's lifetime.
456    ///
457    /// G-TUN-R11: answers the most basic diagnostic question — did anything ever
458    /// connect? A tunnel that bound correctly but was never used produced output
459    /// identical to one that served five hundred connections.
460    pub forwards_served: u64,
461    /// Times a new connection had to wait for a concurrency permit.
462    ///
463    /// G-TUN-R12: saturation was previously invisible, so the symptom was rising
464    /// latency with no stated cause and the operator could hunt the network instead.
465    pub capacity_waits: u64,
466    /// Wall-clock lifetime in milliseconds.
467    pub duration_ms: u64,
468    /// Which tunnel mode was serving (mirrors [`TunnelListeningJson::mode`]).
469    #[serde(default = "default_tunnel_mode")]
470    pub mode: String,
471}
472
473#[cfg(test)]
474#[path = "execution_tests.rs"]
475mod tests;