Skip to main content

microvms_app/session/
files.rs

1// SPDX-License-Identifier: Apache-2.0
2//! File and tar transfer. Four routes, opaque bodies, one query type.
3//!
4//! There are no request or response *bodies* to model here — a file's contents and an
5//! uncompressed tar are byte streams — so [`protocol::fs::FsQuery`] is the entire typed
6//! surface, and its failures answer `text/plain` rather than the JSON error body.
7//!
8//! Two things the daemon's contract makes non-obvious, both of which this module has to
9//! preserve rather than improve on:
10//!
11//! * `mode` is octal **as a string**. `"644"` and `"0644"` mean the same mode, and an
12//!   integer would be read as decimal 644 by anything that stringifies it.
13//! * A missing `path` key is 400, and a directory where a file was asked for is 400.
14//!   Only a genuinely absent path is 404. That distinction is why
15//!   [`file_exists`] can be written at all: it keys on 404 and lets every other refusal
16//!   through, so a typo in the query does not read as a missing artifact.
17
18use crate::error::{Error, WireKind};
19
20use super::{HttpRequest, Transport};
21
22/// Percent-encodes a query value: everything outside RFC 3986's unreserved set.
23///
24/// Stricter than a query strictly needs and never wrong — a path with a space, a `#`,
25/// or a `&` in it is the case that silently truncates a query otherwise. The daemon
26/// decodes with the same grammar. `NON_ALPHANUMERIC` minus the four unreserved marks
27/// is exactly "escape everything but unreserved".
28fn encode(value: &str) -> String {
29    use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
30    const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
31        .remove(b'-')
32        .remove(b'_')
33        .remove(b'.')
34        .remove(b'~');
35    utf8_percent_encode(value, ESCAPED).to_string()
36}
37
38/// `/v1/fs/file` with a path and an optional mode.
39fn file_path(path: &str, mode: Option<&str>) -> String {
40    match mode {
41        Some(mode) => format!("/v1/fs/file?path={}&mode={}", encode(path), encode(mode)),
42        None => format!("/v1/fs/file?path={}", encode(path)),
43    }
44}
45
46/// `GET /v1/fs/file` for `query`: the path, then each line bound that's set, and nothing else,
47/// so a read with no range is [`file_path`]'s URL byte for byte.
48fn file_read_path(query: &protocol::fs::FileReadQuery) -> String {
49    let mut url = file_path(&query.path, None);
50    if let Some(start) = query.start_line {
51        url.push_str(&format!("&start_line={start}"));
52    }
53    if let Some(end) = query.end_line {
54        url.push_str(&format!("&end_line={end}"));
55    }
56    url
57}
58
59/// `/v1/fs/tar` with a path.
60fn tar_path(path: &str) -> String {
61    format!("/v1/fs/tar?path={}", encode(path))
62}
63
64/// Writes one file, creating parents. `mode` is octal as a string.
65pub(crate) async fn upload_file(
66    transport: &Transport,
67    path: &str,
68    data: &[u8],
69    mode: Option<&str>,
70) -> Result<(), Error> {
71    let mut request = HttpRequest::new("PUT", file_path(path, mode));
72    request
73        .headers
74        .push(("content-type".into(), "application/octet-stream".into()));
75    request.body = data.to_vec();
76    transport.send(request).await?;
77    Ok(())
78}
79
80/// Reads one file.
81pub(crate) async fn download_file(transport: &Transport, path: &str) -> Result<Vec<u8>, Error> {
82    let response = transport
83        .send(HttpRequest::new("GET", file_path(path, None)))
84        .await?;
85    Ok(response.body)
86}
87
88/// Reads lines `start_line` through `end_line` of one file, 1-based and inclusive.
89///
90/// The daemon slices the file (`GET /v1/fs/file` has taken the two bounds since v0.1.0), so a
91/// caller wanting lines 40 to 60 of a large log reads 21 lines rather than the whole file.
92/// `start_line` absent is 1 and `end_line` absent is through EOF, and an `end_line` past the
93/// last line reads through EOF. A range no file can satisfy (line 0, an end before the start)
94/// is refused here with `ERR_INVALID_ARG` and the daemon's own wording, before any request:
95/// the rule is `FileReadQuery::line_window`, which the daemon reads too.
96pub(crate) async fn download_file_lines(
97    transport: &Transport,
98    path: &str,
99    start_line: Option<u64>,
100    end_line: Option<u64>,
101) -> Result<Vec<u8>, Error> {
102    let query = protocol::fs::FileReadQuery {
103        path: path.to_string(),
104        start_line,
105        end_line,
106    };
107    query.line_window().map_err(Error::invalid_arg)?;
108    let response = transport
109        .send(HttpRequest::new("GET", file_read_path(&query)))
110        .await?;
111    Ok(response.body)
112}
113
114/// Whether a path exists.
115///
116/// Only a 404 answers `false`. Every other refusal propagates, because a 400 from a
117/// malformed query is not the same fact as an absent file and returning `false` for it
118/// would report a client bug as a missing artifact.
119pub(crate) async fn file_exists(transport: &Transport, path: &str) -> Result<bool, Error> {
120    match download_file(transport, path).await {
121        Ok(_) => Ok(true),
122        Err(err) if err.wire_kind() == Some(WireKind::NotFound) => Ok(false),
123        Err(err) => Err(err),
124    }
125}
126
127/// Extracts pre-built tar bytes under `remote`, which must be absolute.
128///
129/// The archive is not inspected here. The daemon enforces the member rules — in-tree
130/// symlinks survive, absolute link targets are refused with 400 — and a second check on
131/// this side would be a second thing to keep in step with them, which is how the two
132/// come to disagree.
133pub(crate) async fn upload_tar(
134    transport: &Transport,
135    remote: &str,
136    archive: &[u8],
137) -> Result<(), Error> {
138    let mut request = HttpRequest::new("PUT", tar_path(remote));
139    request
140        .headers
141        .push(("content-type".into(), "application/x-tar".into()));
142    request.body = archive.to_vec();
143    transport.send(request).await?;
144    Ok(())
145}
146
147/// The raw tar bytes of a remote tree, for a caller doing its own unpacking.
148///
149/// Bytes rather than an extraction: the archive describes the VM's filesystem, and the VM is
150/// where untrusted work runs, so trusting it on the way out would be the wrong direction of
151/// trust. `Session::download_dir` (`crate::workspace`) is the extraction, and it writes only
152/// the regular files a caller's globs select, never under `.git` and never outside the
153/// destination.
154pub(crate) async fn download_tar(transport: &Transport, remote: &str) -> Result<Vec<u8>, Error> {
155    let response = transport
156        .send(HttpRequest::new("GET", tar_path(remote)))
157        .await?;
158    Ok(response.body)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::session::testing::{Recorder, Reply, session_with};
165    use std::sync::Arc;
166
167    /// A mode is a string on the wire, and it only appears when one was given.
168    ///
169    /// Sending a default `mode` would be worse than omitting it: the daemon's own
170    /// default is what an unset mode means, and naming one here would silently change
171    /// every uploaded file's permissions.
172    #[tokio::test]
173    async fn an_upload_carries_its_octal_mode_as_a_string_only_when_given() {
174        let recorder = Recorder::with([Reply::Body(200, Vec::new()), Reply::Body(200, Vec::new())]);
175        let (session, _, _) = session_with(Arc::clone(&recorder));
176
177        session
178            .upload_file("/tmp/f", b"body", Some("0644"))
179            .await
180            .expect("uploads");
181        let request = recorder.last();
182        assert_eq!(request.method, "PUT");
183        assert!(request.path.contains("mode=0644"), "{}", request.path);
184        assert_eq!(request.body, b"body");
185
186        session
187            .upload_file("/tmp/g", b"", None)
188            .await
189            .expect("uploads");
190        assert!(
191            !recorder.last().path.contains("mode="),
192            "an unset mode must not become a default one: {}",
193            recorder.last().path
194        );
195    }
196
197    /// A path with characters that would truncate a query is escaped.
198    ///
199    /// `&` is the one that matters: unescaped, `/tmp/a&b` makes `b` look like a second
200    /// query parameter and the daemon writes to `/tmp/a`.
201    #[tokio::test]
202    async fn a_path_with_query_metacharacters_is_percent_encoded() {
203        let recorder = Recorder::with([Reply::Body(200, Vec::new())]);
204        let (session, _, _) = session_with(Arc::clone(&recorder));
205
206        session
207            .upload_file("/tmp/a&b c#d", b"x", None)
208            .await
209            .expect("uploads");
210        let path = recorder.last().path;
211        assert_eq!(path, "/v1/fs/file?path=%2Ftmp%2Fa%26b%20c%23d");
212        assert!(!path.contains("&b"), "the path truncated the query: {path}");
213    }
214
215    /// A download hands back the bytes untouched, including non-UTF-8 ones.
216    #[tokio::test]
217    async fn a_download_returns_raw_bytes_including_invalid_utf8() {
218        let bytes = vec![0xff, 0x00, 0xfe, b'a'];
219        let recorder = Recorder::with([Reply::Body(200, bytes.clone())]);
220        let (session, _, _) = session_with(Arc::clone(&recorder));
221
222        let got = session.download_file("/tmp/bin").await.expect("downloads");
223        assert_eq!(got, bytes);
224        assert_eq!(recorder.last().method, "GET");
225    }
226
227    /// `file_exists` answers `false` only for a 404. A 400 is a client bug and must not
228    /// be reported as an absent file — that conflation is how one defect hid for a full
229    /// review round.
230    #[tokio::test]
231    async fn file_exists_distinguishes_an_absent_path_from_a_malformed_request() {
232        let recorder = Recorder::with([
233            Reply::Body(200, b"content".to_vec()),
234            Reply::Body(404, b"not found".to_vec()),
235            Reply::Body(400, b"path is a directory".to_vec()),
236        ]);
237        let (session, _, _) = session_with(recorder);
238
239        assert!(session.file_exists("/tmp/there").await.expect("exists"));
240        assert!(!session.file_exists("/tmp/absent").await.expect("absent"));
241
242        let err = session
243            .file_exists("/tmp/dir")
244            .await
245            .expect_err("a 400 is not an absence");
246        assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
247    }
248
249    /// Tar transfer uses the tar route and carries the archive bytes unmodified.
250    #[tokio::test]
251    async fn tar_transfer_uses_the_tar_route_and_does_not_touch_the_archive() {
252        let archive = vec![b'u'; 1024];
253        let recorder = Recorder::with([
254            Reply::Body(200, Vec::new()),
255            Reply::Body(200, archive.clone()),
256        ]);
257        let (session, _, _) = session_with(Arc::clone(&recorder));
258
259        session
260            .upload_tar("/workspace", &archive)
261            .await
262            .expect("uploads");
263        let request = recorder.last();
264        assert_eq!(request.path, "/v1/fs/tar?path=%2Fworkspace");
265        assert_eq!(request.body, archive);
266
267        let got = session.download_tar("/workspace").await.expect("downloads");
268        assert_eq!(got, archive);
269    }
270
271    /// An over-cap upload is a typed 413 rather than a generic failure, so a caller can
272    /// tell "too big" from "refused".
273    #[tokio::test]
274    async fn an_over_cap_upload_surfaces_as_too_large() {
275        let recorder = Recorder::with([Reply::Body(413, b"body exceeds max_body_bytes".to_vec())]);
276        let (session, _, _) = session_with(recorder);
277
278        let err = session
279            .upload_file("/tmp/huge", b"...", None)
280            .await
281            .expect_err("413");
282        assert_eq!(err.wire_kind(), Some(WireKind::TooLarge));
283    }
284
285    /// The daemon's disk-pressure 507 on either upload route is `InsufficientStorage`,
286    /// `ERR_PLATFORM` and not retryable (#256), with the daemon's byte counts in the
287    /// message. The 5xx fallback made it a retryable `ServerError`, so a caller's retry
288    /// loop repeated a write the full disk refuses the same way each time.
289    #[tokio::test]
290    async fn a_disk_pressure_507_upload_is_insufficient_storage_and_not_retryable() {
291        let body = b"refusing to write /workspace: 4096 bytes available on the target \
292                     filesystem, below the 1048576 byte reserve";
293        let recorder = Recorder::with([
294            Reply::Body(507, body.to_vec()),
295            Reply::Body(507, body.to_vec()),
296        ]);
297        let (session, _, _) = session_with(recorder);
298
299        let file = session
300            .upload_file("/workspace/big.bin", b"...", None)
301            .await
302            .expect_err("507");
303        let tar = session
304            .upload_tar("/workspace", b"...")
305            .await
306            .expect_err("507");
307        for err in [file, tar] {
308            assert_eq!(
309                err.wire_kind(),
310                Some(WireKind::InsufficientStorage),
311                "{err}"
312            );
313            assert_eq!(err.code(), "ERR_PLATFORM", "{err}");
314            assert!(!err.retryable(), "a full disk is not retryable: {err}");
315            assert!(err.to_string().contains("4096 bytes available"), "{err}");
316        }
317    }
318
319    /// **A line range reaches the daemon as `start_line` and `end_line` after the encoded
320    /// path, and no range sends `download_file`'s URL (#265).**
321    ///
322    /// **Falsification**: leave `end_line` off the query and the ranged URL is short one key.
323    #[tokio::test]
324    async fn a_line_range_is_sent_as_the_daemons_two_query_keys() {
325        let recorder = Recorder::with([
326            Reply::Body(200, b"two\nthree\nfour\n".to_vec()),
327            Reply::Body(200, b"all".to_vec()),
328            Reply::Body(200, b"all".to_vec()),
329            Reply::Body(200, b"tail".to_vec()),
330        ]);
331        let (session, _, _) = session_with(Arc::clone(&recorder));
332
333        let lines = session
334            .download_file_lines("/tmp/a b", Some(2), Some(4))
335            .await
336            .expect("reads");
337        assert_eq!(lines, b"two\nthree\nfour\n");
338        assert_eq!(
339            recorder.last().path,
340            "/v1/fs/file?path=%2Ftmp%2Fa%20b&start_line=2&end_line=4"
341        );
342
343        session
344            .download_file_lines("/tmp/a b", None, None)
345            .await
346            .expect("reads");
347        let unranged = recorder.last().path;
348        session.download_file("/tmp/a b").await.expect("reads");
349        assert_eq!(
350            unranged,
351            recorder.last().path,
352            "no range is today's request"
353        );
354
355        session
356            .download_file_lines("/tmp/f", Some(40), None)
357            .await
358            .expect("reads");
359        assert_eq!(
360            recorder.last().path,
361            "/v1/fs/file?path=%2Ftmp%2Ff&start_line=40"
362        );
363    }
364
365    /// Line 0 and an inverted range are refused before any request, with the daemon's words.
366    #[tokio::test]
367    async fn a_range_no_file_can_satisfy_is_refused_before_any_request() {
368        let recorder = Recorder::with([]);
369        let (session, _, _) = session_with(Arc::clone(&recorder));
370
371        for (start, end, wording) in [
372            (Some(0), Some(3), "start_line is 1-based"),
373            (Some(5), Some(2), "end_line 2 is before start_line 5"),
374        ] {
375            let err = session
376                .download_file_lines("/tmp/f", start, end)
377                .await
378                .expect_err("refused");
379            assert_eq!(err.code(), "ERR_INVALID_ARG", "{err}");
380            assert_eq!(err.wire_kind(), None, "nothing reached the daemon: {err}");
381            assert!(err.to_string().contains(wording), "{err}");
382        }
383        assert!(
384            recorder.requests().is_empty(),
385            "a refused range cost a request"
386        );
387    }
388
389    /// The encoder leaves unreserved characters alone, so an ordinary path stays
390    /// readable in a log.
391    #[test]
392    fn unreserved_characters_pass_through_the_encoder() {
393        assert_eq!(encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
394        assert_eq!(encode("/"), "%2F");
395    }
396}