Skip to main content

fresh/services/remote/
protocol.rs

1//! Agent protocol types
2//!
3//! JSON-based protocol for communication with the remote agent.
4//! All binary data is base64 encoded.
5
6use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
7use serde::{Deserialize, Serialize};
8
9/// Protocol version
10pub const PROTOCOL_VERSION: u32 = 1;
11
12/// Request sent to the agent
13#[derive(Debug, Clone, Serialize)]
14pub struct AgentRequest {
15    pub id: u64,
16    #[serde(rename = "m")]
17    pub method: String,
18    #[serde(rename = "p")]
19    pub params: serde_json::Value,
20}
21
22impl AgentRequest {
23    pub fn new(id: u64, method: impl Into<String>, params: serde_json::Value) -> Self {
24        Self {
25            id,
26            method: method.into(),
27            params,
28        }
29    }
30
31    pub fn to_json_line(&self) -> String {
32        serde_json::to_string(self).unwrap() + "\n"
33    }
34}
35
36/// Response from the agent - can be one of three types
37#[derive(Debug, Clone, Deserialize)]
38pub struct AgentResponse {
39    pub id: u64,
40    /// Streaming data (intermediate)
41    #[serde(rename = "d")]
42    pub data: Option<serde_json::Value>,
43    /// Final result (success)
44    #[serde(rename = "r")]
45    pub result: Option<serde_json::Value>,
46    /// Error message (failure)
47    #[serde(rename = "e")]
48    pub error: Option<String>,
49    /// Ready message fields
50    pub ok: Option<bool>,
51    #[serde(rename = "v")]
52    pub version: Option<u32>,
53}
54
55impl AgentResponse {
56    /// Check if this is the ready message
57    pub fn is_ready(&self) -> bool {
58        self.ok == Some(true)
59    }
60
61    /// Check if this is a streaming data message
62    pub fn is_data(&self) -> bool {
63        self.data.is_some()
64    }
65
66    /// Check if this is a final message (result or error)
67    pub fn is_final(&self) -> bool {
68        self.result.is_some() || self.error.is_some()
69    }
70}
71
72/// Directory entry returned by `ls` command
73#[derive(Debug, Clone, Deserialize)]
74#[allow(dead_code)]
75pub struct RemoteDirEntry {
76    pub name: String,
77    pub path: String,
78    #[serde(default)]
79    pub dir: bool,
80    #[serde(default)]
81    pub file: bool,
82    #[serde(default)]
83    pub link: bool,
84    #[serde(default)]
85    pub link_dir: bool,
86    #[serde(default)]
87    pub size: u64,
88    #[serde(default)]
89    pub mtime: i64,
90    #[serde(default)]
91    pub mode: u32,
92}
93
94/// File metadata returned by `stat` command
95#[derive(Debug, Clone, Deserialize)]
96#[allow(dead_code)]
97pub struct RemoteMetadata {
98    pub size: u64,
99    pub mtime: i64,
100    pub mode: u32,
101    #[serde(default)]
102    pub uid: u32,
103    #[serde(default)]
104    pub gid: u32,
105    #[serde(default)]
106    pub dir: bool,
107    #[serde(default)]
108    pub file: bool,
109    #[serde(default)]
110    pub link: bool,
111}
112
113/// Process execution result
114#[derive(Debug, Clone, Deserialize)]
115#[allow(dead_code)]
116pub struct ExecResult {
117    pub code: i32,
118}
119
120/// Streaming output from exec
121#[derive(Debug, Clone, Deserialize)]
122#[allow(dead_code)]
123pub struct ExecOutput {
124    #[serde(default)]
125    pub out: Option<String>,
126    #[serde(default)]
127    pub err: Option<String>,
128}
129
130/// Helper to encode bytes to base64
131pub fn encode_base64(data: &[u8]) -> String {
132    BASE64.encode(data)
133}
134
135/// Helper to decode base64 to bytes
136pub fn decode_base64(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
137    BASE64.decode(s)
138}
139
140/// Build params for read request
141pub fn read_params(path: &str, offset: Option<u64>, len: Option<usize>) -> serde_json::Value {
142    let mut params = serde_json::json!({"path": path});
143    if let Some(off) = offset {
144        params["off"] = serde_json::json!(off);
145    }
146    if let Some(l) = len {
147        params["len"] = serde_json::json!(l);
148    }
149    params
150}
151
152/// Build params for write request
153pub fn write_params(path: &str, data: &[u8]) -> serde_json::Value {
154    serde_json::json!({
155        "path": path,
156        "data": encode_base64(data)
157    })
158}
159
160/// Build params for sudo_write request (write file as root)
161pub fn sudo_write_params(
162    path: &str,
163    data: &[u8],
164    mode: u32,
165    uid: u32,
166    gid: u32,
167) -> serde_json::Value {
168    serde_json::json!({
169        "path": path,
170        "data": encode_base64(data),
171        "mode": mode,
172        "uid": uid,
173        "gid": gid
174    })
175}
176
177/// Build params for stat request
178pub fn stat_params(path: &str, follow_symlinks: bool) -> serde_json::Value {
179    serde_json::json!({
180        "path": path,
181        "link": follow_symlinks
182    })
183}
184
185/// Build params for ls request
186pub fn ls_params(path: &str) -> serde_json::Value {
187    serde_json::json!({"path": path})
188}
189
190/// Build params for exec request
191///
192/// NOTE: Used by RemoteProcessSpawner, appears unused until spawner integration.
193pub fn exec_params(cmd: &str, args: &[String], cwd: Option<&str>) -> serde_json::Value {
194    let mut params = serde_json::json!({
195        "cmd": cmd,
196        "args": args
197    });
198    if let Some(dir) = cwd {
199        params["cwd"] = serde_json::json!(dir);
200    }
201    params
202}
203
204/// Build params for cancel request
205pub fn cancel_params(request_id: u64) -> serde_json::Value {
206    serde_json::json!({"id": request_id})
207}
208
209/// Build params for append request
210pub fn append_params(path: &str, data: &[u8]) -> serde_json::Value {
211    serde_json::json!({
212        "path": path,
213        "data": encode_base64(data)
214    })
215}
216
217/// Build params for truncate request
218pub fn truncate_params(path: &str, len: u64) -> serde_json::Value {
219    serde_json::json!({
220        "path": path,
221        "len": len
222    })
223}
224
225/// A single operation in a patch recipe
226#[derive(Debug, Clone, Serialize)]
227#[serde(untagged)]
228pub enum PatchOp {
229    /// Copy a range from the original file
230    Copy { copy: CopyRange },
231    /// Insert new content
232    Insert { insert: InsertData },
233}
234
235/// Range to copy from original file
236#[derive(Debug, Clone, Serialize)]
237pub struct CopyRange {
238    pub off: u64,
239    pub len: u64,
240}
241
242/// Data to insert
243#[derive(Debug, Clone, Serialize)]
244pub struct InsertData {
245    pub data: String, // base64 encoded
246}
247
248impl PatchOp {
249    /// Create a copy operation
250    pub fn copy(offset: u64, len: u64) -> Self {
251        PatchOp::Copy {
252            copy: CopyRange { off: offset, len },
253        }
254    }
255
256    /// Create an insert operation
257    pub fn insert(data: &[u8]) -> Self {
258        PatchOp::Insert {
259            insert: InsertData {
260                data: encode_base64(data),
261            },
262        }
263    }
264}
265
266/// Build params for patch request
267pub fn patch_params(src: &str, dst: Option<&str>, ops: &[PatchOp]) -> serde_json::Value {
268    let mut params = serde_json::json!({
269        "src": src,
270        "ops": ops
271    });
272    if let Some(d) = dst {
273        params["dst"] = serde_json::json!(d);
274    }
275    params
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_request_serialization() {
284        let req = AgentRequest::new(1, "read", serde_json::json!({"path": "/test.txt"}));
285        let json = req.to_json_line();
286        assert!(json.contains("\"id\":1"));
287        assert!(json.contains("\"m\":\"read\""));
288        assert!(json.contains("\"p\":{\"path\":\"/test.txt\"}"));
289    }
290
291    #[test]
292    fn test_response_parsing() {
293        let ready = r#"{"id":0,"ok":true,"v":1}"#;
294        let resp: AgentResponse = serde_json::from_str(ready).unwrap();
295        assert!(resp.is_ready());
296        assert_eq!(resp.version, Some(1));
297
298        let data = r#"{"id":1,"d":{"data":"SGVsbG8="}}"#;
299        let resp: AgentResponse = serde_json::from_str(data).unwrap();
300        assert!(resp.is_data());
301        assert!(!resp.is_final());
302
303        let result = r#"{"id":1,"r":{"size":5}}"#;
304        let resp: AgentResponse = serde_json::from_str(result).unwrap();
305        assert!(resp.is_final());
306        assert!(resp.result.is_some());
307
308        let error = r#"{"id":1,"e":"not found"}"#;
309        let resp: AgentResponse = serde_json::from_str(error).unwrap();
310        assert!(resp.is_final());
311        assert_eq!(resp.error, Some("not found".to_string()));
312    }
313
314    #[test]
315    fn test_base64_roundtrip() {
316        let data = b"Hello, World!";
317        let encoded = encode_base64(data);
318        let decoded = decode_base64(&encoded).unwrap();
319        assert_eq!(data.as_slice(), decoded.as_slice());
320    }
321
322    #[test]
323    fn test_patch_op_copy_serialization() {
324        let op = PatchOp::copy(100, 500);
325        let json = serde_json::to_string(&op).unwrap();
326        assert!(json.contains("\"copy\""));
327        assert!(json.contains("\"off\":100"));
328        assert!(json.contains("\"len\":500"));
329        // Should NOT contain "insert"
330        assert!(!json.contains("\"insert\""));
331    }
332
333    #[test]
334    fn test_patch_op_insert_serialization() {
335        let op = PatchOp::insert(b"hello");
336        let json = serde_json::to_string(&op).unwrap();
337        assert!(json.contains("\"insert\""));
338        assert!(json.contains("\"data\":\"aGVsbG8=\"")); // base64 of "hello"
339                                                         // Should NOT contain "copy"
340        assert!(!json.contains("\"copy\""));
341    }
342
343    #[test]
344    fn test_patch_params() {
345        let ops = vec![
346            PatchOp::copy(0, 100),
347            PatchOp::insert(b"new content"),
348            PatchOp::copy(200, 300),
349        ];
350
351        // Same src and dst
352        let params = patch_params("/path/to/file", None, &ops);
353        assert_eq!(params["src"], "/path/to/file");
354        assert!(params.get("dst").is_none() || params["dst"].is_null());
355        assert!(params["ops"].is_array());
356        assert_eq!(params["ops"].as_array().unwrap().len(), 3);
357
358        // Different dst
359        let params = patch_params("/src/file", Some("/dst/file"), &ops);
360        assert_eq!(params["src"], "/src/file");
361        assert_eq!(params["dst"], "/dst/file");
362    }
363}