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