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 count_lf request (count newlines in a file range)
153pub fn count_lf_params(path: &str, offset: u64, len: usize) -> serde_json::Value {
154    serde_json::json!({"path": path, "off": offset, "len": len})
155}
156
157/// Build params for write request
158pub fn write_params(path: &str, data: &[u8]) -> serde_json::Value {
159    serde_json::json!({
160        "path": path,
161        "data": encode_base64(data)
162    })
163}
164
165/// Build params for sudo_write request (write file as root)
166pub fn sudo_write_params(
167    path: &str,
168    data: &[u8],
169    mode: u32,
170    uid: u32,
171    gid: u32,
172) -> serde_json::Value {
173    serde_json::json!({
174        "path": path,
175        "data": encode_base64(data),
176        "mode": mode,
177        "uid": uid,
178        "gid": gid
179    })
180}
181
182/// Build params for stat request
183pub fn stat_params(path: &str, follow_symlinks: bool) -> serde_json::Value {
184    serde_json::json!({
185        "path": path,
186        "link": follow_symlinks
187    })
188}
189
190/// Build params for ls request
191pub fn ls_params(path: &str) -> serde_json::Value {
192    serde_json::json!({"path": path})
193}
194
195/// Build params for exec request
196///
197/// NOTE: Used by RemoteProcessSpawner, appears unused until spawner integration.
198pub fn exec_params(cmd: &str, args: &[String], cwd: Option<&str>) -> serde_json::Value {
199    let mut params = serde_json::json!({
200        "cmd": cmd,
201        "args": args
202    });
203    if let Some(dir) = cwd {
204        params["cwd"] = serde_json::json!(dir);
205    }
206    params
207}
208
209/// Build params for cancel request
210pub fn cancel_params(request_id: u64) -> serde_json::Value {
211    serde_json::json!({"id": request_id})
212}
213
214/// Build params for append request
215pub fn append_params(path: &str, data: &[u8]) -> serde_json::Value {
216    serde_json::json!({
217        "path": path,
218        "data": encode_base64(data)
219    })
220}
221
222/// Build params for truncate request
223pub fn truncate_params(path: &str, len: u64) -> serde_json::Value {
224    serde_json::json!({
225        "path": path,
226        "len": len
227    })
228}
229
230/// A single operation in a patch recipe
231#[derive(Debug, Clone, Serialize)]
232#[serde(untagged)]
233pub enum PatchOp {
234    /// Copy a range from the original file
235    Copy { copy: CopyRange },
236    /// Insert new content
237    Insert { insert: InsertData },
238}
239
240/// Range to copy from original file
241#[derive(Debug, Clone, Serialize)]
242pub struct CopyRange {
243    pub off: u64,
244    pub len: u64,
245}
246
247/// Data to insert
248#[derive(Debug, Clone, Serialize)]
249pub struct InsertData {
250    pub data: String, // base64 encoded
251}
252
253impl PatchOp {
254    /// Create a copy operation
255    pub fn copy(offset: u64, len: u64) -> Self {
256        PatchOp::Copy {
257            copy: CopyRange { off: offset, len },
258        }
259    }
260
261    /// Create an insert operation
262    pub fn insert(data: &[u8]) -> Self {
263        PatchOp::Insert {
264            insert: InsertData {
265                data: encode_base64(data),
266            },
267        }
268    }
269}
270
271/// Build params for patch request
272pub fn patch_params(src: &str, dst: Option<&str>, ops: &[PatchOp]) -> serde_json::Value {
273    let mut params = serde_json::json!({
274        "src": src,
275        "ops": ops
276    });
277    if let Some(d) = dst {
278        params["dst"] = serde_json::json!(d);
279    }
280    params
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_request_serialization() {
289        let req = AgentRequest::new(1, "read", serde_json::json!({"path": "/test.txt"}));
290        let json = req.to_json_line();
291        assert!(json.contains("\"id\":1"));
292        assert!(json.contains("\"m\":\"read\""));
293        assert!(json.contains("\"p\":{\"path\":\"/test.txt\"}"));
294    }
295
296    #[test]
297    fn test_response_parsing() {
298        let ready = r#"{"id":0,"ok":true,"v":1}"#;
299        let resp: AgentResponse = serde_json::from_str(ready).unwrap();
300        assert!(resp.is_ready());
301        assert_eq!(resp.version, Some(1));
302
303        let data = r#"{"id":1,"d":{"data":"SGVsbG8="}}"#;
304        let resp: AgentResponse = serde_json::from_str(data).unwrap();
305        assert!(resp.is_data());
306        assert!(!resp.is_final());
307
308        let result = r#"{"id":1,"r":{"size":5}}"#;
309        let resp: AgentResponse = serde_json::from_str(result).unwrap();
310        assert!(resp.is_final());
311        assert!(resp.result.is_some());
312
313        let error = r#"{"id":1,"e":"not found"}"#;
314        let resp: AgentResponse = serde_json::from_str(error).unwrap();
315        assert!(resp.is_final());
316        assert_eq!(resp.error, Some("not found".to_string()));
317    }
318
319    #[test]
320    fn test_base64_roundtrip() {
321        let data = b"Hello, World!";
322        let encoded = encode_base64(data);
323        let decoded = decode_base64(&encoded).unwrap();
324        assert_eq!(data.as_slice(), decoded.as_slice());
325    }
326
327    #[test]
328    fn test_patch_op_copy_serialization() {
329        let op = PatchOp::copy(100, 500);
330        let json = serde_json::to_string(&op).unwrap();
331        assert!(json.contains("\"copy\""));
332        assert!(json.contains("\"off\":100"));
333        assert!(json.contains("\"len\":500"));
334        // Should NOT contain "insert"
335        assert!(!json.contains("\"insert\""));
336    }
337
338    #[test]
339    fn test_patch_op_insert_serialization() {
340        let op = PatchOp::insert(b"hello");
341        let json = serde_json::to_string(&op).unwrap();
342        assert!(json.contains("\"insert\""));
343        assert!(json.contains("\"data\":\"aGVsbG8=\"")); // base64 of "hello"
344                                                         // Should NOT contain "copy"
345        assert!(!json.contains("\"copy\""));
346    }
347
348    #[test]
349    fn test_patch_params() {
350        let ops = vec![
351            PatchOp::copy(0, 100),
352            PatchOp::insert(b"new content"),
353            PatchOp::copy(200, 300),
354        ];
355
356        // Same src and dst
357        let params = patch_params("/path/to/file", None, &ops);
358        assert_eq!(params["src"], "/path/to/file");
359        assert!(params.get("dst").is_none() || params["dst"].is_null());
360        assert!(params["ops"].is_array());
361        assert_eq!(params["ops"].as_array().unwrap().len(), 3);
362
363        // Different dst
364        let params = patch_params("/src/file", Some("/dst/file"), &ops);
365        assert_eq!(params["src"], "/src/file");
366        assert_eq!(params["dst"], "/dst/file");
367    }
368}