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}