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;