use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use axum::Router;
use axum::extract::{Path as UrlPath, RawQuery, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::any;
use super::*;
use crate::storage::s3::tests::keyspace;
#[derive(Default)]
struct Store {
parts: HashMap<String, Vec<(u16, Vec<u8>)>>,
objects: HashMap<String, Vec<u8>>,
aborted: usize,
}
type Shared = Arc<Mutex<Store>>;
async fn bucket(refuse_part: Option<u16>) -> (String, Shared) {
let store: Shared = Arc::new(Mutex::new(Store::default()));
let app = Router::new()
.route(
"/{*key}",
any(
move |State(store): State<Shared>,
UrlPath(key): UrlPath<String>,
RawQuery(query): RawQuery,
method: axum::http::Method,
body: axum::body::Body| async move {
let query = query.unwrap_or_default();
let key = key.strip_prefix("assets/").unwrap_or(&key).to_owned();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
if query.contains("uploads") && !query.contains("uploadId") {
store.lock().unwrap().parts.insert(key, Vec::new());
return (
StatusCode::OK,
"<?xml version=\"1.0\"?><InitiateMultipartUploadResult \
xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><UploadId>an-upload\
</UploadId></InitiateMultipartUploadResult>",
)
.into_response();
}
match method {
axum::http::Method::PUT => {
let number = part_number(&query);
if refuse_part == Some(number) {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
store
.lock()
.unwrap()
.parts
.entry(key)
.or_default()
.push((number, bytes.to_vec()));
(
StatusCode::OK,
[(axum::http::header::ETAG, format!("\"part-{number}\""))],
)
.into_response()
}
axum::http::Method::POST => {
let mut held = store.lock().unwrap();
let mut parts = held.parts.remove(&key).unwrap_or_default();
parts.sort_by_key(|(number, _)| *number);
let object: Vec<u8> =
parts.into_iter().flat_map(|(_, bytes)| bytes).collect();
held.objects.insert(key, object);
(StatusCode::OK, "<CompleteMultipartUploadResult/>").into_response()
}
axum::http::Method::DELETE => {
let mut held = store.lock().unwrap();
held.parts.remove(&key);
held.aborted += 1;
StatusCode::NO_CONTENT.into_response()
}
_ => StatusCode::NOT_FOUND.into_response(),
}
},
),
)
.with_state(store.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{address}"), store)
}
fn part_number(query: &str) -> u16 {
query
.split('&')
.find_map(|pair| pair.strip_prefix("partNumber="))
.and_then(|number| number.parse().ok())
.unwrap_or_default()
}
async fn staged(bytes: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("staged");
tokio::fs::write(&path, bytes).await.unwrap();
(root, path)
}
const KEY: &str = ".content/aa/bb/an-object";
#[tokio::test]
async fn an_object_goes_up_in_parts_and_the_store_puts_it_back_together() {
crate::tls::install_crypto_provider();
let payload: Vec<u8> = (0..40_000u32).flat_map(u32::to_le_bytes).collect();
let (_root, path) = staged(&payload).await;
let (endpoint, store) = bucket(None).await;
let keys = keyspace(&endpoint);
let upload = begin(&keys, KEY).await.unwrap();
let etags = parts(&keys, KEY, &upload, &path, payload.len() as u64, 50_000)
.await
.unwrap();
assert_eq!(etags.len(), 4, "160000 bytes in 50000-byte parts");
finish(&keys, KEY, &upload, etags).await.unwrap();
assert_eq!(
store.lock().unwrap().objects.get(KEY),
Some(&payload),
"an object assembled out of order, or short a part, is a corrupt object under a digest that \
says otherwise"
);
}
#[tokio::test]
async fn a_failed_part_aborts_the_upload() {
crate::tls::install_crypto_provider();
let payload = vec![7u8; 160_000];
let (_root, path) = staged(&payload).await;
let (endpoint, store) = bucket(Some(3)).await;
let failed = put_in_parts(
&keyspace(&endpoint),
KEY,
&path,
payload.len() as u64,
50_000,
)
.await;
assert!(failed.is_err(), "{failed:?}");
let held = store.lock().unwrap();
assert_eq!(held.aborted, 1, "the upload has to be abandoned explicitly");
assert!(
!held.objects.contains_key(KEY),
"a key that appeared would be an object missing its third part"
);
}
#[test]
fn the_part_size_grows_so_the_count_never_exceeds_the_cap() {
for length in [
SINGLE_PUT_CEILING + 1,
100 * 1024 * 1024 * 1024,
5 * 1024 * 1024 * 1024 * 1024,
] {
let size = part_size(length);
assert!(
size >= SMALLEST_PART,
"{length} gave a part below S3's floor"
);
assert!(
length.div_ceil(size) <= MOST_PARTS,
"{length} needs {} parts, and S3 allows {MOST_PARTS}",
length.div_ceil(size)
);
}
}
#[test]
fn a_small_object_over_the_ceiling_still_uses_the_default_part_size() {
assert_eq!(part_size(6 * 1024 * 1024 * 1024), PART);
}