Skip to main content

microvms_core/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/// `/v1/fs/tar` with a path.
47fn tar_path(path: &str) -> String {
48    format!("/v1/fs/tar?path={}", encode(path))
49}
50
51/// Writes one file, creating parents. `mode` is octal as a string.
52pub(crate) async fn upload_file(
53    transport: &Transport,
54    path: &str,
55    data: &[u8],
56    mode: Option<&str>,
57) -> Result<(), Error> {
58    let mut request = HttpRequest::new("PUT", file_path(path, mode));
59    request
60        .headers
61        .push(("content-type".into(), "application/octet-stream".into()));
62    request.body = data.to_vec();
63    transport.send(request).await?;
64    Ok(())
65}
66
67/// Reads one file.
68pub(crate) async fn download_file(transport: &Transport, path: &str) -> Result<Vec<u8>, Error> {
69    let response = transport
70        .send(HttpRequest::new("GET", file_path(path, None)))
71        .await?;
72    Ok(response.body)
73}
74
75/// Whether a path exists.
76///
77/// Only a 404 answers `false`. Every other refusal propagates, because a 400 from a
78/// malformed query is not the same fact as an absent file and returning `false` for it
79/// would report a client bug as a missing artifact.
80pub(crate) async fn file_exists(transport: &Transport, path: &str) -> Result<bool, Error> {
81    match download_file(transport, path).await {
82        Ok(_) => Ok(true),
83        Err(err) if err.wire_kind() == Some(WireKind::NotFound) => Ok(false),
84        Err(err) => Err(err),
85    }
86}
87
88/// Extracts pre-built tar bytes under `remote`, which must be absolute.
89///
90/// The archive is not inspected here. The daemon enforces the member rules — in-tree
91/// symlinks survive, absolute link targets are refused with 400 — and a second check on
92/// this side would be a second thing to keep in step with them, which is how the two
93/// come to disagree.
94pub(crate) async fn upload_tar(
95    transport: &Transport,
96    remote: &str,
97    archive: &[u8],
98) -> Result<(), Error> {
99    let mut request = HttpRequest::new("PUT", tar_path(remote));
100    request
101        .headers
102        .push(("content-type".into(), "application/x-tar".into()));
103    request.body = archive.to_vec();
104    transport.send(request).await?;
105    Ok(())
106}
107
108/// The raw tar bytes of a remote tree, for a caller doing its own unpacking.
109///
110/// Bytes rather than an extraction, deliberately. The Python's `download_dir` extracts
111/// with tarfile's `data` filter, which is the same contract the daemon enforces on
112/// upload — the archive describes the VM's filesystem, and the VM is where untrusted
113/// work runs, so trusting it on the way out would be the wrong direction of trust.
114/// Rust has no equivalent filter in the standard library, and this crate declines to add
115/// `tar` for it: an extraction that looked safe and was not is worse than none.
116pub(crate) async fn download_tar(transport: &Transport, remote: &str) -> Result<Vec<u8>, Error> {
117    let response = transport
118        .send(HttpRequest::new("GET", tar_path(remote)))
119        .await?;
120    Ok(response.body)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::session::testing::{Recorder, Reply, session_with};
127    use std::sync::Arc;
128
129    /// A mode is a string on the wire, and it only appears when one was given.
130    ///
131    /// Sending a default `mode` would be worse than omitting it: the daemon's own
132    /// default is what an unset mode means, and naming one here would silently change
133    /// every uploaded file's permissions.
134    #[tokio::test]
135    async fn an_upload_carries_its_octal_mode_as_a_string_only_when_given() {
136        let recorder = Recorder::with([Reply::Body(200, Vec::new()), Reply::Body(200, Vec::new())]);
137        let (session, _, _) = session_with(Arc::clone(&recorder));
138
139        session
140            .upload_file("/tmp/f", b"body", Some("0644"))
141            .await
142            .expect("uploads");
143        let request = recorder.last();
144        assert_eq!(request.method, "PUT");
145        assert!(request.path.contains("mode=0644"), "{}", request.path);
146        assert_eq!(request.body, b"body");
147
148        session
149            .upload_file("/tmp/g", b"", None)
150            .await
151            .expect("uploads");
152        assert!(
153            !recorder.last().path.contains("mode="),
154            "an unset mode must not become a default one: {}",
155            recorder.last().path
156        );
157    }
158
159    /// A path with characters that would truncate a query is escaped.
160    ///
161    /// `&` is the one that matters: unescaped, `/tmp/a&b` makes `b` look like a second
162    /// query parameter and the daemon writes to `/tmp/a`.
163    #[tokio::test]
164    async fn a_path_with_query_metacharacters_is_percent_encoded() {
165        let recorder = Recorder::with([Reply::Body(200, Vec::new())]);
166        let (session, _, _) = session_with(Arc::clone(&recorder));
167
168        session
169            .upload_file("/tmp/a&b c#d", b"x", None)
170            .await
171            .expect("uploads");
172        let path = recorder.last().path;
173        assert_eq!(path, "/v1/fs/file?path=%2Ftmp%2Fa%26b%20c%23d");
174        assert!(!path.contains("&b"), "the path truncated the query: {path}");
175    }
176
177    /// A download hands back the bytes untouched, including non-UTF-8 ones.
178    #[tokio::test]
179    async fn a_download_returns_raw_bytes_including_invalid_utf8() {
180        let bytes = vec![0xff, 0x00, 0xfe, b'a'];
181        let recorder = Recorder::with([Reply::Body(200, bytes.clone())]);
182        let (session, _, _) = session_with(Arc::clone(&recorder));
183
184        let got = session.download_file("/tmp/bin").await.expect("downloads");
185        assert_eq!(got, bytes);
186        assert_eq!(recorder.last().method, "GET");
187    }
188
189    /// `file_exists` answers `false` only for a 404. A 400 is a client bug and must not
190    /// be reported as an absent file — that conflation is how one defect hid for a full
191    /// review round.
192    #[tokio::test]
193    async fn file_exists_distinguishes_an_absent_path_from_a_malformed_request() {
194        let recorder = Recorder::with([
195            Reply::Body(200, b"content".to_vec()),
196            Reply::Body(404, b"not found".to_vec()),
197            Reply::Body(400, b"path is a directory".to_vec()),
198        ]);
199        let (session, _, _) = session_with(recorder);
200
201        assert!(session.file_exists("/tmp/there").await.expect("exists"));
202        assert!(!session.file_exists("/tmp/absent").await.expect("absent"));
203
204        let err = session
205            .file_exists("/tmp/dir")
206            .await
207            .expect_err("a 400 is not an absence");
208        assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
209    }
210
211    /// Tar transfer uses the tar route and carries the archive bytes unmodified.
212    #[tokio::test]
213    async fn tar_transfer_uses_the_tar_route_and_does_not_touch_the_archive() {
214        let archive = vec![b'u'; 1024];
215        let recorder = Recorder::with([
216            Reply::Body(200, Vec::new()),
217            Reply::Body(200, archive.clone()),
218        ]);
219        let (session, _, _) = session_with(Arc::clone(&recorder));
220
221        session
222            .upload_tar("/workspace", &archive)
223            .await
224            .expect("uploads");
225        let request = recorder.last();
226        assert_eq!(request.path, "/v1/fs/tar?path=%2Fworkspace");
227        assert_eq!(request.body, archive);
228
229        let got = session.download_tar("/workspace").await.expect("downloads");
230        assert_eq!(got, archive);
231    }
232
233    /// An over-cap upload is a typed 413 rather than a generic failure, so a caller can
234    /// tell "too big" from "refused".
235    #[tokio::test]
236    async fn an_over_cap_upload_surfaces_as_too_large() {
237        let recorder = Recorder::with([Reply::Body(413, b"body exceeds max_body_bytes".to_vec())]);
238        let (session, _, _) = session_with(recorder);
239
240        let err = session
241            .upload_file("/tmp/huge", b"...", None)
242            .await
243            .expect_err("413");
244        assert_eq!(err.wire_kind(), Some(WireKind::TooLarge));
245    }
246
247    /// The encoder leaves unreserved characters alone, so an ordinary path stays
248    /// readable in a log.
249    #[test]
250    fn unreserved_characters_pass_through_the_encoder() {
251        assert_eq!(encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
252        assert_eq!(encode("/"), "%2F");
253    }
254}