1use crate::error::{Error, WireKind};
19
20use super::{HttpRequest, Transport};
21
22fn 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
38fn 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
46fn 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
59fn tar_path(path: &str) -> String {
61 format!("/v1/fs/tar?path={}", encode(path))
62}
63
64pub(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
80pub(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
88pub(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
114pub(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
127pub(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
147pub(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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
392 fn unreserved_characters_pass_through_the_encoder() {
393 assert_eq!(encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
394 assert_eq!(encode("/"), "%2F");
395 }
396}