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
59impl FileReadQuery {
60 /// The requested window as `(start_line, end_line)`, `None` when no range was asked for,
61 /// or why no file could satisfy it.
62 ///
63 /// The one statement of the range rule, read by the daemon before it opens the file and by
64 /// the client before it sends the request, so a range the daemon would refuse costs the
65 /// client no call and the two can't disagree on it or on its wording. `None` must stay
66 /// distinguishable from a range that covers the whole file: with no range the daemon
67 /// answers the un-sliced stream it always has, byte for byte.
68 ///
69 /// `start_line` absent is 1 and `end_line` absent is through EOF, the harness contract's
70 /// defaults. Refused: a `start_line` of 0, since lines are 1-based, and an `end_line` before
71 /// `start_line`. An `end_line` past the last line is not refused; it reads through EOF.
72 pub fn line_window(&self) -> Result<Option<(u64, Option<u64>)>, String> {
73 if self.start_line.is_none() && self.end_line.is_none() {
74 return Ok(None);
75 }
76 let start = self.start_line.unwrap_or(1);
77 if start == 0 {
78 return Err(
79 "start_line is 1-based, so 0 is not a line. Line 1 is the first line; a caller \
80 working from 0-based offsets wants start_line=1."
81 .to_string(),
82 );
83 }
84 if let Some(end) = self.end_line
85 && end < start
86 {
87 return Err(format!(
88 "end_line {end} is before start_line {start}. Both bounds are 1-based and \
89 inclusive, so end_line must be at least start_line. An end_line past the last \
90 line is fine and reads through EOF; this refusal is for an inverted range, \
91 which no file can satisfy."
92 ));
93 }
94 Ok(Some((start, self.end_line)))
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 /// `mode` is a string on the wire, and the reason is that `0644` in JSON is
103 /// either a syntax error or decimal 644 depending on the parser.
104 #[test]
105 fn an_fs_query_without_a_mode_deserializes_and_keeps_the_mode_a_string() {
106 let query: FsQuery = serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
107 assert_eq!(query.path, "/tmp/f");
108 assert!(query.mode.is_none());
109
110 let with_mode: FsQuery =
111 serde_json::from_str(r#"{"path":"/tmp/f","mode":"0644"}"#).expect("deserializes");
112 assert_eq!(with_mode.mode.as_deref(), Some("0644"));
113 }
114
115 /// A read query with no range deserializes to two `None`s, which is what keeps
116 /// the un-ranged read byte-identical to what it always was.
117 #[test]
118 fn a_file_read_query_without_a_range_leaves_both_bounds_absent() {
119 let query: FileReadQuery =
120 serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
121 assert_eq!(query.path, "/tmp/f");
122 assert!(query.start_line.is_none());
123 assert!(query.end_line.is_none());
124
125 let ranged: FileReadQuery =
126 serde_json::from_str(r#"{"path":"/tmp/f","start_line":3,"end_line":9}"#)
127 .expect("deserializes");
128 assert_eq!(ranged.start_line, Some(3));
129 assert_eq!(ranged.end_line, Some(9));
130 }
131
132 fn window(
133 start_line: Option<u64>,
134 end_line: Option<u64>,
135 ) -> Result<Option<(u64, Option<u64>)>, String> {
136 FileReadQuery {
137 path: "/tmp/f".into(),
138 start_line,
139 end_line,
140 }
141 .line_window()
142 }
143
144 /// No range is `None`, a one-sided range takes the contract's default for the other side,
145 /// and an `end_line` past any file is still a window.
146 #[test]
147 fn a_line_window_defaults_each_absent_bound() {
148 assert_eq!(window(None, None), Ok(None));
149 assert_eq!(window(Some(4), None), Ok(Some((4, None))));
150 assert_eq!(window(None, Some(2)), Ok(Some((1, Some(2)))));
151 assert_eq!(window(Some(2), Some(4)), Ok(Some((2, Some(4)))));
152 assert_eq!(window(Some(3), Some(3)), Ok(Some((3, Some(3)))));
153 assert_eq!(
154 window(Some(1), Some(u64::MAX)),
155 Ok(Some((1, Some(u64::MAX))))
156 );
157 }
158
159 /// Line 0 and an inverted range are refused, each naming the bound that's wrong.
160 #[test]
161 fn a_line_window_refuses_line_zero_and_an_inverted_range() {
162 let zero = window(Some(0), Some(4)).expect_err("line 0");
163 assert!(zero.contains("start_line is 1-based"), "{zero}");
164 let inverted = window(Some(5), Some(2)).expect_err("inverted");
165 assert!(
166 inverted.contains("end_line 2 is before start_line 5"),
167 "{inverted}"
168 );
169 let implied = window(None, Some(0)).expect_err("end before the default start");
170 assert!(
171 implied.contains("end_line 0 is before start_line 1"),
172 "{implied}"
173 );
174 }
175}