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