Skip to main content

microsandbox_agentd/
fs.rs

1//! Guest-side filesystem operation handlers.
2//!
3//! Handles `core.fs.*` protocol messages by performing filesystem operations
4//! using `std::fs` and `tokio::fs`, then sending responses back to the host.
5
6use std::os::unix::fs::{MetadataExt, PermissionsExt};
7
8use microsandbox_protocol::{
9    codec::encode_to_buf,
10    fs::{FS_CHUNK_SIZE, FsData, FsEntryInfo, FsOp, FsRequest, FsResponse, FsResponseData},
11    message::{Message, MessageType},
12};
13use tokio::{
14    io::{AsyncReadExt, AsyncWriteExt},
15    sync::mpsc,
16};
17
18use crate::session::SessionOutput;
19
20//--------------------------------------------------------------------------------------------------
21// Types
22//--------------------------------------------------------------------------------------------------
23
24/// Tracks an in-progress streaming write operation.
25pub struct FsWriteSession {
26    file: tokio::fs::File,
27}
28
29//--------------------------------------------------------------------------------------------------
30// Functions
31//--------------------------------------------------------------------------------------------------
32
33/// Handles an incoming `FsRequest` message.
34///
35/// For simple request/response ops (stat, list, mkdir, remove, copy, rename),
36/// the response is encoded directly into `out_buf`.
37///
38/// For streaming read, a background task is spawned that sends `FsData` chunks
39/// via `session_tx`, followed by a terminal `FsResponse`.
40///
41/// For streaming write, a `FsWriteSession` is created and returned for the
42/// caller to insert into the write sessions map.
43pub async fn handle_fs_request(
44    id: u32,
45    req: FsRequest,
46    out_buf: &mut Vec<u8>,
47    session_tx: &mpsc::UnboundedSender<(u32, SessionOutput)>,
48) -> Result<Option<FsWriteSession>, String> {
49    match req.op {
50        FsOp::Stat { path } => {
51            let resp = handle_stat(&path).await;
52            encode_response(id, resp, out_buf)?;
53            Ok(None)
54        }
55        FsOp::List { path } => {
56            let resp = handle_list(&path).await;
57            encode_response(id, resp, out_buf)?;
58            Ok(None)
59        }
60        FsOp::Read { path } => {
61            let tx = session_tx.clone();
62            tokio::spawn(async move {
63                handle_read_stream(id, &path, &tx).await;
64            });
65            Ok(None)
66        }
67        FsOp::Write { path, mode } => match handle_write_open(&path, mode).await {
68            Ok(session) => Ok(Some(session)),
69            Err(e) => {
70                let resp = FsResponse {
71                    ok: false,
72                    error: Some(e),
73                    data: None,
74                };
75                encode_response(id, resp, out_buf)?;
76                Ok(None)
77            }
78        },
79        FsOp::Mkdir { path } => {
80            let resp = handle_mkdir(&path).await;
81            encode_response(id, resp, out_buf)?;
82            Ok(None)
83        }
84        FsOp::Remove { path } => {
85            let resp = handle_remove(&path).await;
86            encode_response(id, resp, out_buf)?;
87            Ok(None)
88        }
89        FsOp::RemoveDir { path } => {
90            let resp = handle_remove_dir(&path).await;
91            encode_response(id, resp, out_buf)?;
92            Ok(None)
93        }
94        FsOp::Copy { src, dst } => {
95            let resp = handle_copy(&src, &dst).await;
96            encode_response(id, resp, out_buf)?;
97            Ok(None)
98        }
99        FsOp::Rename { src, dst } => {
100            let resp = handle_rename(&src, &dst).await;
101            encode_response(id, resp, out_buf)?;
102            Ok(None)
103        }
104    }
105}
106
107/// Handles an incoming `FsData` message for a streaming write session.
108///
109/// If `data` is empty, the file is closed and a terminal `FsResponse` is sent.
110/// Returns `true` if the session should be removed (EOF received).
111pub async fn handle_fs_data(
112    id: u32,
113    data: FsData,
114    session: &mut FsWriteSession,
115    out_buf: &mut Vec<u8>,
116) -> Result<bool, String> {
117    if data.data.is_empty() {
118        // EOF — flush and close the file.
119        if let Err(e) = session.file.flush().await {
120            let resp = FsResponse {
121                ok: false,
122                error: Some(format!("flush: {e}")),
123                data: None,
124            };
125            encode_response(id, resp, out_buf)?;
126            return Ok(true);
127        }
128
129        let resp = FsResponse {
130            ok: true,
131            error: None,
132            data: None,
133        };
134        encode_response(id, resp, out_buf)?;
135        Ok(true)
136    } else {
137        // Write chunk to file.
138        if let Err(e) = session.file.write_all(&data.data).await {
139            let resp = FsResponse {
140                ok: false,
141                error: Some(format!("write: {e}")),
142                data: None,
143            };
144            encode_response(id, resp, out_buf)?;
145            return Ok(true);
146        }
147        Ok(false)
148    }
149}
150
151//--------------------------------------------------------------------------------------------------
152// Functions: Helpers
153//--------------------------------------------------------------------------------------------------
154
155/// Encode a `FsResponse` message into the output buffer.
156fn encode_response(id: u32, resp: FsResponse, out_buf: &mut Vec<u8>) -> Result<(), String> {
157    let msg = Message::with_payload(MessageType::FsResponse, id, &resp)
158        .map_err(|e| format!("encode fs response: {e}"))?;
159    encode_to_buf(&msg, out_buf).map_err(|e| format!("encode fs response frame: {e}"))?;
160    Ok(())
161}
162
163/// Stat a path and return the response.
164async fn handle_stat(path: &str) -> FsResponse {
165    match tokio::fs::symlink_metadata(path).await {
166        Ok(meta) => FsResponse {
167            ok: true,
168            error: None,
169            data: Some(FsResponseData::Stat(metadata_to_entry_info(path, &meta))),
170        },
171        Err(e) => FsResponse {
172            ok: false,
173            error: Some(format!("stat: {e}")),
174            data: None,
175        },
176    }
177}
178
179/// List directory contents and return the response.
180async fn handle_list(path: &str) -> FsResponse {
181    match tokio::fs::read_dir(path).await {
182        Ok(mut dir) => {
183            let mut entries = Vec::new();
184            loop {
185                match dir.next_entry().await {
186                    Ok(Some(entry)) => {
187                        let entry_path = entry.path();
188                        let path_str = entry_path.to_string_lossy().to_string();
189                        match tokio::fs::symlink_metadata(&entry_path).await {
190                            Ok(meta) => {
191                                entries.push(metadata_to_entry_info(&path_str, &meta));
192                            }
193                            Err(_) => {
194                                // Skip entries we can't stat.
195                                entries.push(FsEntryInfo {
196                                    path: path_str,
197                                    kind: "other".to_string(),
198                                    size: 0,
199                                    mode: 0,
200                                    modified: None,
201                                });
202                            }
203                        }
204                    }
205                    Ok(None) => break,
206                    Err(e) => {
207                        return FsResponse {
208                            ok: false,
209                            error: Some(format!("readdir: {e}")),
210                            data: None,
211                        };
212                    }
213                }
214            }
215            FsResponse {
216                ok: true,
217                error: None,
218                data: Some(FsResponseData::List(entries)),
219            }
220        }
221        Err(e) => FsResponse {
222            ok: false,
223            error: Some(format!("opendir: {e}")),
224            data: None,
225        },
226    }
227}
228
229/// Stream file contents as `FsData` chunks, then send terminal `FsResponse`.
230async fn handle_read_stream(id: u32, path: &str, tx: &mpsc::UnboundedSender<(u32, SessionOutput)>) {
231    let file = match tokio::fs::File::open(path).await {
232        Ok(f) => f,
233        Err(e) => {
234            send_raw_response(id, false, Some(format!("open: {e}")), None, tx);
235            return;
236        }
237    };
238
239    let mut reader = tokio::io::BufReader::new(file);
240    let mut chunk = vec![0u8; FS_CHUNK_SIZE];
241    let mut buf = Vec::new();
242
243    loop {
244        match reader.read(&mut chunk).await {
245            Ok(0) => break,
246            Ok(n) => {
247                let data = FsData {
248                    data: chunk[..n].to_vec(),
249                };
250                let msg = match Message::with_payload(MessageType::FsData, id, &data) {
251                    Ok(msg) => msg,
252                    Err(e) => {
253                        send_raw_response(id, false, Some(format!("encode chunk: {e}")), None, tx);
254                        return;
255                    }
256                };
257                buf.clear();
258                if let Err(e) = encode_to_buf(&msg, &mut buf) {
259                    send_raw_response(
260                        id,
261                        false,
262                        Some(format!("encode chunk frame: {e}")),
263                        None,
264                        tx,
265                    );
266                    return;
267                }
268                if tx.send((id, SessionOutput::Raw(buf.clone()))).is_err() {
269                    return;
270                }
271            }
272            Err(e) => {
273                send_raw_response(id, false, Some(format!("read: {e}")), None, tx);
274                return;
275            }
276        }
277    }
278
279    // Terminal success response.
280    send_raw_response(id, true, None, None, tx);
281}
282
283/// Encode and send a `FsResponse` as a raw pre-encoded frame via the session channel.
284fn send_raw_response(
285    id: u32,
286    ok: bool,
287    error: Option<String>,
288    data: Option<FsResponseData>,
289    tx: &mpsc::UnboundedSender<(u32, SessionOutput)>,
290) {
291    let resp = FsResponse { ok, error, data };
292    match Message::with_payload(MessageType::FsResponse, id, &resp) {
293        Ok(msg) => {
294            let mut buf = Vec::new();
295            match encode_to_buf(&msg, &mut buf) {
296                Ok(()) => {
297                    let _ = tx.send((id, SessionOutput::Raw(buf)));
298                }
299                Err(e) => {
300                    eprintln!("failed to encode fs response frame for {id}: {e}");
301                }
302            }
303        }
304        Err(e) => {
305            eprintln!("failed to encode fs response for {id}: {e}");
306        }
307    }
308}
309
310/// Open a file for writing and return a write session.
311async fn handle_write_open(path: &str, mode: Option<u32>) -> Result<FsWriteSession, String> {
312    // Ensure parent directory exists.
313    if let Some(parent) = std::path::Path::new(path).parent()
314        && !parent.as_os_str().is_empty()
315    {
316        tokio::fs::create_dir_all(parent)
317            .await
318            .map_err(|e| format!("mkdir parent: {e}"))?;
319    }
320
321    let file = tokio::fs::OpenOptions::new()
322        .write(true)
323        .create(true)
324        .truncate(true)
325        .open(path)
326        .await
327        .map_err(|e| format!("open for write: {e}"))?;
328
329    // Set permissions if specified.
330    if let Some(mode) = mode {
331        let perms = std::fs::Permissions::from_mode(mode);
332        file.set_permissions(perms)
333            .await
334            .map_err(|e| format!("set permissions: {e}"))?;
335    }
336
337    Ok(FsWriteSession { file })
338}
339
340/// Create a directory (and parents).
341async fn handle_mkdir(path: &str) -> FsResponse {
342    match tokio::fs::create_dir_all(path).await {
343        Ok(()) => FsResponse {
344            ok: true,
345            error: None,
346            data: None,
347        },
348        Err(e) => FsResponse {
349            ok: false,
350            error: Some(format!("mkdir: {e}")),
351            data: None,
352        },
353    }
354}
355
356/// Remove a file.
357async fn handle_remove(path: &str) -> FsResponse {
358    match tokio::fs::remove_file(path).await {
359        Ok(()) => FsResponse {
360            ok: true,
361            error: None,
362            data: None,
363        },
364        Err(e) => FsResponse {
365            ok: false,
366            error: Some(format!("remove: {e}")),
367            data: None,
368        },
369    }
370}
371
372/// Remove a directory recursively.
373async fn handle_remove_dir(path: &str) -> FsResponse {
374    match tokio::fs::remove_dir_all(path).await {
375        Ok(()) => FsResponse {
376            ok: true,
377            error: None,
378            data: None,
379        },
380        Err(e) => FsResponse {
381            ok: false,
382            error: Some(format!("remove_dir: {e}")),
383            data: None,
384        },
385    }
386}
387
388/// Copy a file within the guest.
389async fn handle_copy(src: &str, dst: &str) -> FsResponse {
390    // Ensure parent directory of destination exists.
391    if let Some(parent) = std::path::Path::new(dst).parent()
392        && !parent.as_os_str().is_empty()
393        && let Err(e) = tokio::fs::create_dir_all(parent).await
394    {
395        return FsResponse {
396            ok: false,
397            error: Some(format!("mkdir parent: {e}")),
398            data: None,
399        };
400    }
401
402    match tokio::fs::copy(src, dst).await {
403        Ok(_) => FsResponse {
404            ok: true,
405            error: None,
406            data: None,
407        },
408        Err(e) => FsResponse {
409            ok: false,
410            error: Some(format!("copy: {e}")),
411            data: None,
412        },
413    }
414}
415
416/// Rename/move a file or directory.
417async fn handle_rename(src: &str, dst: &str) -> FsResponse {
418    // Ensure parent directory of destination exists.
419    if let Some(parent) = std::path::Path::new(dst).parent()
420        && !parent.as_os_str().is_empty()
421        && let Err(e) = tokio::fs::create_dir_all(parent).await
422    {
423        return FsResponse {
424            ok: false,
425            error: Some(format!("mkdir parent: {e}")),
426            data: None,
427        };
428    }
429
430    match tokio::fs::rename(src, dst).await {
431        Ok(()) => FsResponse {
432            ok: true,
433            error: None,
434            data: None,
435        },
436        Err(e) => FsResponse {
437            ok: false,
438            error: Some(format!("rename: {e}")),
439            data: None,
440        },
441    }
442}
443
444/// Convert `std::fs::Metadata` to `FsEntryInfo`.
445fn metadata_to_entry_info(path: &str, meta: &std::fs::Metadata) -> FsEntryInfo {
446    let kind = if meta.is_file() {
447        "file"
448    } else if meta.is_dir() {
449        "dir"
450    } else if meta.is_symlink() {
451        "symlink"
452    } else {
453        "other"
454    };
455
456    let modified = meta
457        .modified()
458        .ok()
459        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
460        .map(|d| d.as_secs() as i64);
461
462    FsEntryInfo {
463        path: path.to_string(),
464        kind: kind.to_string(),
465        size: meta.len(),
466        mode: meta.mode(),
467        modified,
468    }
469}