Skip to main content

microvms_protocol/
fs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! File-transfer wire types: the one query string every `/v1/fs/*` route takes.
3//!
4//! There are no body types here. The fs bodies are opaque byte streams — a file's
5//! contents or an uncompressed tar — so the query is the entire typed surface, and
6//! its failures answer `text/plain` rather than [`crate::exec::ErrorBody`].
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Query string for every route in this module.
12///
13/// `path` is required. A request missing it is 400, never 404: clients map 404
14/// onto `FileNotFoundError`, so answering 404 for a protocol typo made a missing
15/// query key look like an absent artifact — that is how one defect hid for a full
16/// review round.
17#[derive(Debug, Deserialize, JsonSchema, Serialize)]
18pub struct FsQuery {
19    pub path: String,
20    /// Octal mode for a written file, carried as a string so `0644` and `644`
21    /// both parse and neither is read as decimal 644.
22    #[serde(default)]
23    pub mode: Option<String>,
24}
25
26/// Query string for `GET /v1/fs/file`, which is [`FsQuery`] plus an optional line
27/// range.
28///
29/// A separate type rather than two more fields on [`FsQuery`], because a line range
30/// means nothing on `PUT /v1/fs/file` or on either tar route and a shared type would
31/// publish it on all four. `mode` is absent here for the same reason pointing the
32/// other way: it is a property of a write.
33///
34/// The semantics are the AI SDK harness contract's, verbatim, because that is the
35/// consumer: **1-based and inclusive on both ends**, and an `end_line` past the
36/// file's last line reads through EOF without an error. A caller asking for lines
37/// 1..1000 of a 12-line file gets the 12 lines and a 200, not a 416.
38#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
39pub struct FileReadQuery {
40    pub path: String,
41    /// First line to return, 1-based inclusive. Absent means 1.
42    ///
43    /// Zero is refused with 400 rather than treated as 1. A caller who sends 0 is
44    /// working from a 0-based mental model, and silently reinterpreting it would
45    /// hand back a window one line off from the one they will compute offsets
46    /// against.
47    #[serde(default)]
48    pub start_line: Option<u64>,
49    /// Last line to return, 1-based inclusive. Absent means through EOF.
50    ///
51    /// Past the last line is **not** an error: the read returns through EOF. A range
52    /// ending before `start_line` is refused with 400, because it can only be a
53    /// caller who computed one of the two wrong — there is no file for which it is
54    /// the right question.
55    #[serde(default)]
56    pub end_line: Option<u64>,
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    /// `mode` is a string on the wire, and the reason is that `0644` in JSON is
64    /// either a syntax error or decimal 644 depending on the parser.
65    #[test]
66    fn an_fs_query_without_a_mode_deserializes_and_keeps_the_mode_a_string() {
67        let query: FsQuery = serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
68        assert_eq!(query.path, "/tmp/f");
69        assert!(query.mode.is_none());
70
71        let with_mode: FsQuery =
72            serde_json::from_str(r#"{"path":"/tmp/f","mode":"0644"}"#).expect("deserializes");
73        assert_eq!(with_mode.mode.as_deref(), Some("0644"));
74    }
75
76    /// A read query with no range deserializes to two `None`s, which is what keeps
77    /// the un-ranged read byte-identical to what it always was.
78    #[test]
79    fn a_file_read_query_without_a_range_leaves_both_bounds_absent() {
80        let query: FileReadQuery =
81            serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
82        assert_eq!(query.path, "/tmp/f");
83        assert!(query.start_line.is_none());
84        assert!(query.end_line.is_none());
85
86        let ranged: FileReadQuery =
87            serde_json::from_str(r#"{"path":"/tmp/f","start_line":3,"end_line":9}"#)
88                .expect("deserializes");
89        assert_eq!(ranged.start_line, Some(3));
90        assert_eq!(ranged.end_line, Some(9));
91    }
92}