shell_tunnel/api/types.rs
1//! API request and response types.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8use crate::session::{SessionId, SessionState};
9
10/// Request to create a new session.
11///
12/// **It carries nothing.** A session is an id the audit trail records against
13/// and a place for streaming to attach; where a command runs and what it runs
14/// with are decided per execute, by `ExecuteCommandRequest`.
15///
16/// This type once held `shell`, `working_dir` and `env`, and none of the three
17/// ever reached a command — the execute path consults the session only for
18/// whether it may run. Two of them were documented as taking effect. The type
19/// stays (rather than the route dropping its body) so that sending them again
20/// is refused by name instead of dropped: see `ExecuteCommandRequest` for why
21/// every request type here is strict about field names.
22#[derive(Debug, Clone, Deserialize, Default)]
23#[serde(deny_unknown_fields)]
24pub struct CreateSessionRequest {}
25
26/// Response for session creation.
27#[derive(Debug, Clone, Serialize)]
28pub struct CreateSessionResponse {
29 /// The assigned session ID.
30 pub session_id: u64,
31 /// Human-readable session ID string.
32 pub session_id_str: String,
33}
34
35impl CreateSessionResponse {
36 pub fn new(id: SessionId) -> Self {
37 Self {
38 session_id: id.as_u64(),
39 session_id_str: id.to_string(),
40 }
41 }
42}
43
44/// Response for session status query.
45#[derive(Debug, Clone, Serialize)]
46pub struct SessionStatusResponse {
47 /// Session ID.
48 pub session_id: u64,
49 /// Whether a command is running in this session right now.
50 ///
51 /// The one thing `idle_seconds` cannot say: the executor touches the
52 /// session when a command starts as well as when it ends, so a session two
53 /// seconds into a command and one idle for two seconds report the same
54 /// `idle_seconds`. The internal state machine is not published — it holds
55 /// two more values this API can never return, and keeping it out means a
56 /// state can be added without breaking a caller.
57 pub running: bool,
58 /// Last exit code (if available).
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub last_exit_code: Option<i32>,
61 /// Total commands executed.
62 pub execution_count: u64,
63 /// Idle duration in seconds.
64 pub idle_seconds: f64,
65}
66
67impl SessionStatusResponse {
68 pub fn from_session(session: &crate::session::Session) -> Self {
69 Self {
70 session_id: session.id.as_u64(),
71 running: session.state == SessionState::Active,
72 last_exit_code: session.context.last_exit_code(),
73 execution_count: session.context.execution_count(),
74 idle_seconds: session.idle_duration().as_secs_f64(),
75 }
76 }
77}
78
79/// Request to execute a command.
80///
81/// A misspelled field is refused rather than ignored, and that is a safety
82/// property rather than pedantry. Every optional field on this API either asks
83/// for something *safer* (`timeout_secs`, `dry_run` on the delete route) or
84/// says *where* to act (`working_dir`). Serde's default is to drop a field it
85/// does not recognise, which leaves the less safe default in place and reports
86/// success — a caller who wrote `timeoutSecs` got no timeout and a
87/// `timed_out: false` that looked like the command finished within one, and a
88/// caller who wrote `workingDir` had their command run somewhere else entirely.
89/// The same slip on `?dryRun=true` deleted the file it was asked to preview.
90///
91/// The refusal names the offending field and lists the accepted ones, so the
92/// caller can fix it from the response alone.
93///
94/// Note for callers coming from `spawn`-style APIs: `command` is the whole
95/// command line, and there is no `args` array. Sending one used to start a
96/// bare shell and report `success: true` without ever running the command.
97#[derive(Debug, Clone, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ExecuteCommandRequest {
100 /// The command line to execute.
101 pub command: String,
102 /// Optional working directory override.
103 #[serde(default)]
104 pub working_dir: Option<String>,
105 /// Optional environment variables.
106 #[serde(default)]
107 pub env: HashMap<String, String>,
108 /// Timeout in seconds.
109 #[serde(default)]
110 pub timeout_secs: Option<u64>,
111 /// Cap on the output this response carries, in bytes.
112 ///
113 /// Omitted means the server default. A value above the server's ceiling is
114 /// clamped rather than refused: the caller asked for "as much as possible",
115 /// and `total_bytes` reports what the command actually produced either way.
116 #[serde(default)]
117 pub max_output_bytes: Option<u64>,
118}
119
120impl ExecuteCommandRequest {
121 pub fn timeout(&self) -> Option<Duration> {
122 self.timeout_secs.map(Duration::from_secs)
123 }
124}
125
126/// Response for command execution.
127#[derive(Debug, Clone, Serialize)]
128pub struct ExecuteCommandResponse {
129 /// Whether execution was successful.
130 pub success: bool,
131 /// Exit code (if process completed).
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub exit_code: Option<i32>,
134 /// Cleaned output text.
135 pub output: String,
136 /// Raw output (base64 encoded if binary content detected).
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub raw_output: Option<String>,
139 /// Execution duration in milliseconds.
140 pub duration_ms: u64,
141 /// Whether the command timed out.
142 pub timed_out: bool,
143 /// Bytes the command produced, including any this response does not carry.
144 ///
145 /// Equal to the size of `output` unless `truncated` is set.
146 pub total_bytes: u64,
147 /// Whether `output` is a prefix of what the command produced.
148 ///
149 /// Always present, including when false: a caller must not have to infer
150 /// completeness from the absence of a field.
151 pub truncated: bool,
152}
153
154impl ExecuteCommandResponse {
155 pub fn from_result(result: &crate::execution::ExecutionResult) -> Self {
156 Self {
157 success: result.exit_code.map(|c| c == 0).unwrap_or(false) && !result.timed_out,
158 exit_code: result.exit_code,
159 output: result.text_output.clone(),
160 raw_output: None, // Only include if requested
161 duration_ms: result.duration.as_millis() as u64,
162 timed_out: result.timed_out,
163 total_bytes: result.total_bytes,
164 truncated: result.truncated,
165 }
166 }
167
168 pub fn with_raw_output(mut self, include: bool, raw: &[u8]) -> Self {
169 if include {
170 // Convert to string, lossy if non-UTF8
171 self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
172 }
173 self
174 }
175}
176
177/// Generic API error response.
178#[derive(Debug, Clone, Serialize)]
179pub struct ErrorResponse {
180 /// Error code (e.g., "SESSION_NOT_FOUND").
181 pub code: String,
182 /// Human-readable error message.
183 pub message: String,
184 /// Additional details (optional).
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub details: Option<String>,
187}
188
189impl ErrorResponse {
190 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
191 Self {
192 code: code.into(),
193 message: message.into(),
194 details: None,
195 }
196 }
197
198 pub fn with_details(mut self, details: impl Into<String>) -> Self {
199 self.details = Some(details.into());
200 self
201 }
202
203 pub fn session_not_found(id: &str) -> Self {
204 Self::new("SESSION_NOT_FOUND", format!("Session '{}' not found", id))
205 }
206
207 pub fn invalid_state(state: SessionState) -> Self {
208 Self::new(
209 "INVALID_STATE",
210 format!(
211 "Session is in {:?} state and cannot execute commands",
212 state
213 ),
214 )
215 }
216
217 pub fn internal_error(message: impl Into<String>) -> Self {
218 Self::new("INTERNAL_ERROR", message)
219 }
220
221 pub fn bad_request(message: impl Into<String>) -> Self {
222 Self::new("BAD_REQUEST", message)
223 }
224}
225
226/// A message the server accepts from a WebSocket client.
227///
228/// Split from `WsServerMessage` because the two directions want opposite
229/// strictness, and one shared type could only have one. Input is refused when
230/// it carries a field this server does not know — `timeoutSecs` for
231/// `timeout_secs` otherwise runs the command with no timeout at all and reports
232/// `timed_out: false`, which reads as "finished within the limit". Output stays
233/// permissive, so a client built against an older version of this crate keeps
234/// working when a later server adds a field.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
237pub enum WsClientMessage {
238 /// Run a command and stream its output back.
239 Execute {
240 command: String,
241 #[serde(default)]
242 timeout_secs: Option<u64>,
243 },
244 /// Connection health. The server answers `Ping` with `Pong`.
245 Ping,
246 Pong,
247}
248
249/// A message the server sends to a WebSocket client.
250///
251/// Deliberately *not* `deny_unknown_fields`: this is the type a consumer
252/// deserialises the server's output with, and a new field on a later server
253/// must not make an older consumer reject the whole message. The strictness
254/// that fixes silently-dropped input belongs on `WsClientMessage` only.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum WsServerMessage {
258 /// One chunk of a running command's output.
259 Output {
260 data: String,
261 /// Whether this is the final chunk.
262 #[serde(default)]
263 is_final: bool,
264 },
265 /// Terminal result of an execution.
266 ///
267 /// Carries `total_bytes` but not `truncated`, unlike the REST response:
268 /// every chunk reaches a streaming consumer as it arrives, so there is
269 /// nothing here for a cap to discard. The figure is what a consumer needs
270 /// to confirm it received the whole stream.
271 Result {
272 success: bool,
273 exit_code: Option<i32>,
274 duration_ms: u64,
275 timed_out: bool,
276 total_bytes: u64,
277 },
278 /// Error message.
279 Error {
280 code: String,
281 message: String,
282 },
283 /// Connection health.
284 Ping,
285 Pong,
286}
287
288/// List sessions response.
289#[derive(Debug, Clone, Serialize)]
290pub struct ListSessionsResponse {
291 /// Total number of sessions.
292 pub count: usize,
293 /// Session summaries.
294 pub sessions: Vec<SessionSummary>,
295}
296
297/// Brief session summary for listing.
298#[derive(Debug, Clone, Serialize)]
299pub struct SessionSummary {
300 pub session_id: u64,
301 /// Whether a command is running in this session right now — see
302 /// [`SessionStatusResponse::running`].
303 pub running: bool,
304 pub idle_seconds: f64,
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_create_session_request_default() {
313 assert!(serde_json::from_str::<CreateSessionRequest>("{}").is_ok());
314 }
315
316 #[test]
317 fn test_execute_command_request() {
318 let json = r#"{"command": "echo hello", "timeout_secs": 30}"#;
319 let req: ExecuteCommandRequest = serde_json::from_str(json).unwrap();
320 assert_eq!(req.command, "echo hello");
321 assert_eq!(req.timeout(), Some(Duration::from_secs(30)));
322 }
323
324 #[test]
325 fn test_error_response_serialization() {
326 let err = ErrorResponse::new("TEST_ERROR", "Test message");
327 let json = serde_json::to_string(&err).unwrap();
328 assert!(json.contains("TEST_ERROR"));
329 assert!(json.contains("Test message"));
330 assert!(!json.contains("details")); // skip_serializing_if
331 }
332
333 #[test]
334 fn test_ws_message_execute() {
335 let msg = WsClientMessage::Execute {
336 command: "ls".to_string(),
337 timeout_secs: Some(10),
338 };
339 let json = serde_json::to_string(&msg).unwrap();
340 assert!(json.contains("execute"));
341 assert!(json.contains("ls"));
342 }
343
344 #[test]
345 fn test_ws_message_output() {
346 let msg = WsServerMessage::Output {
347 data: "hello\n".to_string(),
348 is_final: false,
349 };
350 let json = serde_json::to_string(&msg).unwrap();
351 assert!(json.contains("output"));
352 }
353
354 // --- Unknown fields are refused, not dropped. ---
355 //
356 // Each of these asserts the *pair*: the correct spelling parses and the
357 // near-miss is refused. Asserting only the refusal would pass just as well
358 // against a type that refuses everything.
359
360 #[test]
361 fn execute_request_refuses_an_unknown_field() {
362 let good = r#"{"command":"cd","working_dir":"/tmp","timeout_secs":1}"#;
363 let req: ExecuteCommandRequest = serde_json::from_str(good).unwrap();
364 assert_eq!(req.working_dir.as_deref(), Some("/tmp"));
365 assert_eq!(req.timeout_secs, Some(1));
366
367 // The two that ran wrong and reported success before this was strict.
368 for bad in [
369 r#"{"command":"cd","workingDir":"/tmp"}"#,
370 r#"{"command":"cd","timeoutSecs":1}"#,
371 // No `args` array exists on this API; sending one used to start a
372 // bare shell and answer `success: true`.
373 r#"{"command":"cmd","args":["/c","echo","hi"]}"#,
374 ] {
375 let err = serde_json::from_str::<ExecuteCommandRequest>(bad).unwrap_err();
376 let msg = err.to_string();
377 assert!(
378 msg.contains("unknown field"),
379 "expected a refusal naming the field, got: {msg}"
380 );
381 }
382 }
383
384 /// Create carries no fields, so every field is an unknown one — including
385 /// the three this type used to accept and drop on the floor.
386 #[test]
387 fn create_session_request_refuses_every_field() {
388 for body in [
389 r#"{"shell":"bash"}"#,
390 r#"{"working_dir":"/tmp"}"#,
391 r#"{"env":{"A":"b"}}"#,
392 r#"{"workingDir":"/tmp"}"#,
393 ] {
394 assert!(
395 serde_json::from_str::<CreateSessionRequest>(body).is_err(),
396 "{body} was accepted"
397 );
398 }
399 }
400
401 #[test]
402 fn ws_client_message_refuses_an_unknown_field() {
403 let good = r#"{"type":"execute","command":"ls","timeout_secs":5}"#;
404 assert!(serde_json::from_str::<WsClientMessage>(good).is_ok());
405
406 let typo = r#"{"type":"execute","command":"ls","timeoutSecs":5}"#;
407 let err = serde_json::from_str::<WsClientMessage>(typo).unwrap_err();
408 assert!(err.to_string().contains("unknown field"));
409 }
410
411 /// The other half of the split: server output must stay permissive so a
412 /// consumer built against this version keeps parsing a later server that
413 /// added a field. Locking this down would trade one silent failure for
414 /// another.
415 #[test]
416 fn ws_server_message_tolerates_an_unknown_field() {
417 let from_a_later_server = r#"{"type":"result","success":true,"exit_code":0,
418 "duration_ms":1,"timed_out":false,"total_bytes":0,"some_new_field":"x"}"#;
419 assert!(serde_json::from_str::<WsServerMessage>(from_a_later_server).is_ok());
420 }
421}