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 monólito).
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/// `exec` / `sudo-exec` / `su-exec` JSON stdout.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ExecutionJson {
12    /// Captured remote stdout.
13    pub stdout: String,
14    /// Captured remote stderr.
15    pub stderr: String,
16    /// Remote exit code when available.
17    pub exit_code: Option<i32>,
18    /// Whether stdout was truncated by max_output_chars.
19    pub truncated_stdout: bool,
20    /// Whether stderr was truncated by max_output_chars.
21    pub truncated_stderr: bool,
22    /// Wall-clock duration in milliseconds.
23    pub duration_ms: u64,
24}
25
26impl From<&ExecutionOutput> for ExecutionJson {
27    fn from(o: &ExecutionOutput) -> Self {
28        Self {
29            stdout: o.stdout.clone(),
30            stderr: o.stderr.clone(),
31            exit_code: o.exit_code,
32            truncated_stdout: o.truncated_stdout,
33            truncated_stderr: o.truncated_stderr,
34            duration_ms: o.duration_ms,
35        }
36    }
37}
38
39/// `health-check --json` stdout (single host).
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct HealthCheckJson {
42    /// VPS name checked.
43    pub name: String,
44    /// Always `"ok"` on success path.
45    pub status: String,
46    /// Round-trip latency in milliseconds.
47    pub latency_ms: u64,
48}
49
50/// One entry in `health-check --all --json`.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct HealthHostJson {
53    /// VPS name.
54    pub name: String,
55    /// `"ok"` or `"error"`.
56    pub status: String,
57    /// Latency when measured.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub latency_ms: Option<u64>,
60    /// Error detail when status is error.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub error: Option<String>,
63}
64
65/// `health-check --all --json` batch envelope.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct HealthBatchJson {
68    /// Discriminator.
69    pub event: String,
70    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
71    pub batch_run_id: String,
72    /// Concurrency budget used for the fan-out.
73    pub max_concurrency: u32,
74    /// Per-host results (stable name order when possible).
75    pub results: Vec<HealthHostJson>,
76}
77
78/// One entry in multi-host `exec --all --json`.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct ExecHostJson {
81    /// VPS name.
82    pub name: String,
83    /// Whether remote exit was 0.
84    pub ok: bool,
85    /// Remote exit code.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub exit_code: Option<i32>,
88    /// Captured stdout.
89    pub stdout: String,
90    /// Captured stderr.
91    pub stderr: String,
92    /// Duration in milliseconds.
93    pub duration_ms: u64,
94    /// Error summary.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub error: Option<String>,
97}
98
99/// Multi-host exec batch envelope.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct ExecBatchJson {
102    /// Discriminator.
103    pub event: String,
104    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
105    pub batch_run_id: String,
106    /// Concurrency budget used.
107    pub max_concurrency: u32,
108    /// Per-host results.
109    pub results: Vec<ExecHostJson>,
110}
111
112/// One entry in multi-host `scp --all --json`.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct ScpHostJson {
115    /// VPS name.
116    pub name: String,
117    /// Whether transfer succeeded.
118    pub ok: bool,
119    /// Bytes transferred when ok.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub bytes: Option<u64>,
122    /// Duration ms when measured.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub duration_ms: Option<u64>,
125    /// Local path used (download may be host-suffixed).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub local: Option<String>,
128    /// Error detail.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub error: Option<String>,
131}
132
133/// Multi-host SCP batch envelope.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct ScpBatchJson {
136    /// Discriminator.
137    pub event: String,
138    /// Time-ordered UUID v7 correlating this multi-host run (G-DOM-05).
139    pub batch_run_id: String,
140    /// `"upload"` or `"download"`.
141    pub direction: String,
142    /// Concurrency budget used.
143    pub max_concurrency: u32,
144    /// Per-host results.
145    pub results: Vec<ScpHostJson>,
146}
147
148/// `scp upload|download --json` success stdout.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150pub struct ScpTransferJson {
151    /// Always `true`.
152    pub ok: bool,
153    /// Discriminator: `"scp-transfer"`.
154    pub event: String,
155    /// `"upload"` or `"download"`.
156    pub direction: String,
157    /// VPS name.
158    pub vps: String,
159    /// Local path.
160    pub local: String,
161    /// Remote path.
162    pub remote: String,
163    /// Bytes transferred.
164    pub bytes: u64,
165    /// Duration in milliseconds.
166    pub duration_ms: u64,
167    /// Whether the remote modification time landed on the local file.
168    ///
169    /// G-SCP-R01: the CLI documented mtime preservation as a guarantee while treating
170    /// it as best-effort in code, and the failure was discarded at two nesting levels.
171    /// A build pipeline that decides whether to recompile by comparing mtime could act
172    /// on a timestamp that was never applied, with the symptom surfacing far from the
173    /// cause. Additive and defaulted to `true`, so events written before this field
174    /// existed still deserialize and pre-existing consumers are unaffected.
175    #[serde(default = "default_true")]
176    pub mtime_preserved: bool,
177    /// Whether the parent directory was fsynced after the atomic rename.
178    ///
179    /// G-SCP-R02: the rename is atomic but the directory entry is not durable until it
180    /// is flushed. Success was reported unqualified, so an agent could not tell a
181    /// durable write from one that a power loss would erase.
182    #[serde(default = "default_true")]
183    pub durable: bool,
184}
185
186/// Wire default for the additive SCP durability flags.
187///
188/// `true` rather than `false`: an event that predates the fields was emitted by a
189/// build that never reported a failure, so assuming loss would invent one.
190fn default_true() -> bool {
191    true
192}
193
194/// `sftp upload|download --json` success stdout (G-SFTP-09).
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct SftpTransferJson {
197    /// Always `true`.
198    pub ok: bool,
199    /// Discriminator: `"sftp-transfer"`.
200    pub event: String,
201    /// `"upload"` or `"download"`.
202    pub direction: String,
203    /// VPS name.
204    pub vps: String,
205    /// Local path.
206    pub local: String,
207    /// Remote path.
208    pub remote: String,
209    /// Bytes transferred.
210    pub bytes: u64,
211    /// Duration in milliseconds.
212    pub duration_ms: u64,
213    /// Whether the transfer was recursive.
214    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
215    pub recursive: bool,
216}
217
218/// One entry in `sftp ls --json`.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct SftpListEntryJson {
221    /// Base name.
222    pub name: String,
223    /// Full remote path.
224    pub path: String,
225    /// `file` | `dir` | `symlink` | `other`.
226    pub kind: String,
227    /// Size when known.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub size: Option<u64>,
230    /// Mode bits when known.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub mode: Option<u32>,
233}
234
235/// `sftp ls --json` success stdout.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct SftpListJson {
238    /// Always `true`.
239    pub ok: bool,
240    /// Discriminator: `"sftp-list"`.
241    pub event: String,
242    /// VPS name.
243    pub vps: String,
244    /// Directory path listed.
245    pub path: String,
246    /// Entries.
247    pub entries: Vec<SftpListEntryJson>,
248}
249
250/// `sftp mkdir|rmdir|rm|rename|stat --json` (stat uses size/mode fields).
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252pub struct SftpFsOpJson {
253    /// Always `true`.
254    pub ok: bool,
255    /// Discriminator: `"sftp-fs-op"`.
256    pub event: String,
257    /// Operation name.
258    pub op: String,
259    /// VPS name.
260    pub vps: String,
261    /// Primary path.
262    pub path: String,
263    /// Rename target when applicable.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub to: Option<String>,
266    /// Duration ms.
267    pub duration_ms: u64,
268    /// Stat kind when op=stat.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub kind: Option<String>,
271    /// Stat size when op=stat.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub size: Option<u64>,
274    /// Stat mode when op=stat.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub mode: Option<u32>,
277    /// Stat mtime when op=stat.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub mtime: Option<u32>,
280}
281
282/// Multi-host SFTP batch (reuses scp-host shape).
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284pub struct SftpBatchJson {
285    /// Discriminator: `"sftp-batch"`.
286    pub event: String,
287    /// UUID v7 batch id.
288    pub batch_run_id: String,
289    /// `"upload"` or `"download"`.
290    pub direction: String,
291    /// Concurrency budget.
292    pub max_concurrency: u32,
293    /// Per-host results.
294    pub results: Vec<ScpHostJson>,
295}
296
297/// `tunnel --json` post-bind event.
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299pub struct TunnelListeningJson {
300    /// Always `true`.
301    pub ok: bool,
302    /// Discriminator: `"tunnel_listening"`.
303    pub event: String,
304    /// VPS name.
305    pub vps: String,
306    /// Local listen port.
307    pub local_port: u16,
308    /// Remote target host.
309    pub remote_host: String,
310    /// Remote target port.
311    pub remote_port: u16,
312    /// One-shot timeout in milliseconds.
313    pub timeout_ms: u64,
314    /// Effective local bind address.
315    ///
316    /// G-TUN-R06: without this an agent could not tell a `127.0.0.1` bind from a
317    /// `0.0.0.0` one, so it had no way to audit — from the structured contract alone —
318    /// whether it had just published a remote database to the local network. Additive
319    /// field: existing consumers are unaffected.
320    pub bind: String,
321    /// Which tunnel mode is serving: `local`, `socks5`, `streamlocal` or `reverse`.
322    ///
323    /// The other fields are read differently per mode — `local_port` is a local
324    /// listener for three of them and the *server's* port for `reverse`, and
325    /// `remote_host` is a concrete target except under SOCKS5, where the
326    /// destination is chosen per connection. Without this discriminator an agent
327    /// would have to infer the mode from the flags it passed, which stops working
328    /// the moment anything else launches the tunnel.
329    #[serde(default = "default_tunnel_mode")]
330    pub mode: String,
331}
332
333/// Wire default for [`TunnelListeningJson::mode`] / [`TunnelClosedJson::mode`].
334///
335/// Events written before the mode field existed can only have been plain local
336/// forwards, so deserializing them as `local` is a statement of fact rather than
337/// a guess.
338fn default_tunnel_mode() -> String {
339    "local".to_string()
340}
341
342/// Reason a tunnel stopped serving, reported by [`TunnelClosedJson`].
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "snake_case")]
345pub enum TunnelCloseReason {
346    /// The `--timeout-ms` deadline elapsed after a successful bind (exit 0).
347    Deadline,
348    /// SIGINT / SIGTERM arrived.
349    Signal,
350    /// The accept loop hit a fatal error and stopped early.
351    AcceptError,
352}
353
354/// `tunnel --json` shutdown event.
355///
356/// G-TUN-R07: the tunnel used to emit `tunnel_listening` and then fall silent until
357/// death, so three very different endings shared exit 0 — deadline reached, signal
358/// received, and a fatal accept error that broke the loop early. The last case was
359/// the worst: because `bound` was already true, the timeout wrapper returned `Ok(())`
360/// and the process reported success even though it had stopped accepting connections
361/// seconds into a five-minute deadline. Those are now distinguishable.
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363pub struct TunnelClosedJson {
364    /// `true` when the tunnel served its full lifetime.
365    pub ok: bool,
366    /// Discriminator: `"tunnel_closed"`.
367    pub event: String,
368    /// VPS name.
369    pub vps: String,
370    /// Why the tunnel stopped.
371    pub reason: TunnelCloseReason,
372    /// Effective local bind address.
373    pub bind: String,
374    /// Local listen port that was served.
375    pub local_port: u16,
376    /// Connections accepted and forwarded during the tunnel's lifetime.
377    ///
378    /// G-TUN-R11: answers the most basic diagnostic question — did anything ever
379    /// connect? A tunnel that bound correctly but was never used produced output
380    /// identical to one that served five hundred connections.
381    pub forwards_served: u64,
382    /// Times a new connection had to wait for a concurrency permit.
383    ///
384    /// G-TUN-R12: saturation was previously invisible, so the symptom was rising
385    /// latency with no stated cause and the operator could hunt the network instead.
386    pub capacity_waits: u64,
387    /// Wall-clock lifetime in milliseconds.
388    pub duration_ms: u64,
389    /// Which tunnel mode was serving (mirrors [`TunnelListeningJson::mode`]).
390    #[serde(default = "default_tunnel_mode")]
391    pub mode: String,
392}