use crate::error::{Error, WireKind};
use super::{HttpRequest, Transport};
fn encode(value: &str) -> String {
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
const ESCAPED: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
utf8_percent_encode(value, ESCAPED).to_string()
}
fn file_path(path: &str, mode: Option<&str>) -> String {
match mode {
Some(mode) => format!("/v1/fs/file?path={}&mode={}", encode(path), encode(mode)),
None => format!("/v1/fs/file?path={}", encode(path)),
}
}
fn file_read_path(query: &protocol::fs::FileReadQuery) -> String {
let mut url = file_path(&query.path, None);
if let Some(start) = query.start_line {
url.push_str(&format!("&start_line={start}"));
}
if let Some(end) = query.end_line {
url.push_str(&format!("&end_line={end}"));
}
url
}
fn tar_path(path: &str) -> String {
format!("/v1/fs/tar?path={}", encode(path))
}
pub(crate) async fn upload_file(
transport: &Transport,
path: &str,
data: &[u8],
mode: Option<&str>,
) -> Result<(), Error> {
let mut request = HttpRequest::new("PUT", file_path(path, mode));
request
.headers
.push(("content-type".into(), "application/octet-stream".into()));
request.body = data.to_vec();
transport.send(request).await?;
Ok(())
}
pub(crate) async fn download_file(transport: &Transport, path: &str) -> Result<Vec<u8>, Error> {
let response = transport
.send(HttpRequest::new("GET", file_path(path, None)))
.await?;
Ok(response.body)
}
pub(crate) async fn download_file_lines(
transport: &Transport,
path: &str,
start_line: Option<u64>,
end_line: Option<u64>,
) -> Result<Vec<u8>, Error> {
let query = protocol::fs::FileReadQuery {
path: path.to_string(),
start_line,
end_line,
};
query.line_window().map_err(Error::invalid_arg)?;
let response = transport
.send(HttpRequest::new("GET", file_read_path(&query)))
.await?;
Ok(response.body)
}
pub(crate) async fn file_exists(transport: &Transport, path: &str) -> Result<bool, Error> {
match download_file(transport, path).await {
Ok(_) => Ok(true),
Err(err) if err.wire_kind() == Some(WireKind::NotFound) => Ok(false),
Err(err) => Err(err),
}
}
pub(crate) async fn upload_tar(
transport: &Transport,
remote: &str,
archive: &[u8],
) -> Result<(), Error> {
let mut request = HttpRequest::new("PUT", tar_path(remote));
request
.headers
.push(("content-type".into(), "application/x-tar".into()));
request.body = archive.to_vec();
transport.send(request).await?;
Ok(())
}
pub(crate) async fn download_tar(transport: &Transport, remote: &str) -> Result<Vec<u8>, Error> {
let response = transport
.send(HttpRequest::new("GET", tar_path(remote)))
.await?;
Ok(response.body)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::testing::{Recorder, Reply, session_with};
use std::sync::Arc;
#[tokio::test]
async fn an_upload_carries_its_octal_mode_as_a_string_only_when_given() {
let recorder = Recorder::with([Reply::Body(200, Vec::new()), Reply::Body(200, Vec::new())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session
.upload_file("/tmp/f", b"body", Some("0644"))
.await
.expect("uploads");
let request = recorder.last();
assert_eq!(request.method, "PUT");
assert!(request.path.contains("mode=0644"), "{}", request.path);
assert_eq!(request.body, b"body");
session
.upload_file("/tmp/g", b"", None)
.await
.expect("uploads");
assert!(
!recorder.last().path.contains("mode="),
"an unset mode must not become a default one: {}",
recorder.last().path
);
}
#[tokio::test]
async fn a_path_with_query_metacharacters_is_percent_encoded() {
let recorder = Recorder::with([Reply::Body(200, Vec::new())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session
.upload_file("/tmp/a&b c#d", b"x", None)
.await
.expect("uploads");
let path = recorder.last().path;
assert_eq!(path, "/v1/fs/file?path=%2Ftmp%2Fa%26b%20c%23d");
assert!(!path.contains("&b"), "the path truncated the query: {path}");
}
#[tokio::test]
async fn a_download_returns_raw_bytes_including_invalid_utf8() {
let bytes = vec![0xff, 0x00, 0xfe, b'a'];
let recorder = Recorder::with([Reply::Body(200, bytes.clone())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let got = session.download_file("/tmp/bin").await.expect("downloads");
assert_eq!(got, bytes);
assert_eq!(recorder.last().method, "GET");
}
#[tokio::test]
async fn file_exists_distinguishes_an_absent_path_from_a_malformed_request() {
let recorder = Recorder::with([
Reply::Body(200, b"content".to_vec()),
Reply::Body(404, b"not found".to_vec()),
Reply::Body(400, b"path is a directory".to_vec()),
]);
let (session, _, _) = session_with(recorder);
assert!(session.file_exists("/tmp/there").await.expect("exists"));
assert!(!session.file_exists("/tmp/absent").await.expect("absent"));
let err = session
.file_exists("/tmp/dir")
.await
.expect_err("a 400 is not an absence");
assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
}
#[tokio::test]
async fn tar_transfer_uses_the_tar_route_and_does_not_touch_the_archive() {
let archive = vec![b'u'; 1024];
let recorder = Recorder::with([
Reply::Body(200, Vec::new()),
Reply::Body(200, archive.clone()),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session
.upload_tar("/workspace", &archive)
.await
.expect("uploads");
let request = recorder.last();
assert_eq!(request.path, "/v1/fs/tar?path=%2Fworkspace");
assert_eq!(request.body, archive);
let got = session.download_tar("/workspace").await.expect("downloads");
assert_eq!(got, archive);
}
#[tokio::test]
async fn an_over_cap_upload_surfaces_as_too_large() {
let recorder = Recorder::with([Reply::Body(413, b"body exceeds max_body_bytes".to_vec())]);
let (session, _, _) = session_with(recorder);
let err = session
.upload_file("/tmp/huge", b"...", None)
.await
.expect_err("413");
assert_eq!(err.wire_kind(), Some(WireKind::TooLarge));
}
#[tokio::test]
async fn a_disk_pressure_507_upload_is_insufficient_storage_and_not_retryable() {
let body = b"refusing to write /workspace: 4096 bytes available on the target \
filesystem, below the 1048576 byte reserve";
let recorder = Recorder::with([
Reply::Body(507, body.to_vec()),
Reply::Body(507, body.to_vec()),
]);
let (session, _, _) = session_with(recorder);
let file = session
.upload_file("/workspace/big.bin", b"...", None)
.await
.expect_err("507");
let tar = session
.upload_tar("/workspace", b"...")
.await
.expect_err("507");
for err in [file, tar] {
assert_eq!(
err.wire_kind(),
Some(WireKind::InsufficientStorage),
"{err}"
);
assert_eq!(err.code(), "ERR_PLATFORM", "{err}");
assert!(!err.retryable(), "a full disk is not retryable: {err}");
assert!(err.to_string().contains("4096 bytes available"), "{err}");
}
}
#[tokio::test]
async fn a_line_range_is_sent_as_the_daemons_two_query_keys() {
let recorder = Recorder::with([
Reply::Body(200, b"two\nthree\nfour\n".to_vec()),
Reply::Body(200, b"all".to_vec()),
Reply::Body(200, b"all".to_vec()),
Reply::Body(200, b"tail".to_vec()),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let lines = session
.download_file_lines("/tmp/a b", Some(2), Some(4))
.await
.expect("reads");
assert_eq!(lines, b"two\nthree\nfour\n");
assert_eq!(
recorder.last().path,
"/v1/fs/file?path=%2Ftmp%2Fa%20b&start_line=2&end_line=4"
);
session
.download_file_lines("/tmp/a b", None, None)
.await
.expect("reads");
let unranged = recorder.last().path;
session.download_file("/tmp/a b").await.expect("reads");
assert_eq!(
unranged,
recorder.last().path,
"no range is today's request"
);
session
.download_file_lines("/tmp/f", Some(40), None)
.await
.expect("reads");
assert_eq!(
recorder.last().path,
"/v1/fs/file?path=%2Ftmp%2Ff&start_line=40"
);
}
#[tokio::test]
async fn a_range_no_file_can_satisfy_is_refused_before_any_request() {
let recorder = Recorder::with([]);
let (session, _, _) = session_with(Arc::clone(&recorder));
for (start, end, wording) in [
(Some(0), Some(3), "start_line is 1-based"),
(Some(5), Some(2), "end_line 2 is before start_line 5"),
] {
let err = session
.download_file_lines("/tmp/f", start, end)
.await
.expect_err("refused");
assert_eq!(err.code(), "ERR_INVALID_ARG", "{err}");
assert_eq!(err.wire_kind(), None, "nothing reached the daemon: {err}");
assert!(err.to_string().contains(wording), "{err}");
}
assert!(
recorder.requests().is_empty(),
"a refused range cost a request"
);
}
#[test]
fn unreserved_characters_pass_through_the_encoder() {
assert_eq!(encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
assert_eq!(encode("/"), "%2F");
}
}