Skip to main content

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}