use crate::error::{Error, WireKind};
use super::{HttpRequest, Transport};
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char);
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
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 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 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));
}
#[test]
fn unreserved_characters_pass_through_the_encoder() {
assert_eq!(encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
assert_eq!(encode("/"), "%2F");
}
}