Skip to main content

max / makenotwork

4.8 KB · 146 lines History Blame Raw
1 //! Minimal in-process S3-compatible object store for upload tests.
2 //!
3 //! The upload handler bails with 503 when `AppState.s3` is `None`, which put
4 //! everything past that line (authz, rate limit, validation, EXIF strip, the
5 //! insert-before-upload ordering) out of reach of the integration suite. This
6 //! stub closes that gap without a container or a live bucket.
7 //!
8 //! It implements exactly what `s3_storage::S3Client` sends on the paths the
9 //! forum uses: path-style PUT, GET, and DELETE of a single object. The SDK is
10 //! built with `force_path_style(true)` and performs no connectivity probe at
11 //! construction, so pointing `endpoint` at this server is enough. Requests are
12 //! not signature-checked: the client signs them, and re-verifying SigV4 here
13 //! would test the AWS SDK rather than the forum.
14
15 use std::collections::HashMap;
16 use std::sync::{Arc, Mutex};
17
18 use axum::{
19 Router,
20 body::Bytes,
21 extract::{Path, State},
22 http::{HeaderMap, StatusCode, header},
23 response::{IntoResponse, Response},
24 routing::any,
25 };
26
27 /// An object as the stub holds it.
28 #[derive(Clone)]
29 pub(crate) struct StoredObject {
30 pub data: Vec<u8>,
31 pub content_type: String,
32 }
33
34 type Objects = Arc<Mutex<HashMap<String, StoredObject>>>;
35
36 /// A running stub. Dropping the harness drops the server task with it.
37 pub(crate) struct S3Stub {
38 pub endpoint: String,
39 pub bucket: String,
40 objects: Objects,
41 }
42
43 impl S3Stub {
44 /// Bind on an ephemeral loopback port and serve until dropped.
45 pub(crate) async fn start() -> Self {
46 let objects: Objects = Arc::new(Mutex::new(HashMap::new()));
47 let bucket = "test-bucket".to_string();
48
49 let app = Router::new()
50 .route("/{bucket}/{*key}", any(object_handler))
51 .with_state(objects.clone());
52
53 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
54 .await
55 .expect("failed to bind S3 stub");
56 let addr = listener.local_addr().expect("no local addr for S3 stub");
57
58 tokio::spawn(async move {
59 let _ = axum::serve(listener, app).await;
60 });
61
62 S3Stub {
63 endpoint: format!("http://{addr}"),
64 bucket,
65 objects,
66 }
67 }
68
69 /// Number of objects currently stored.
70 pub(crate) fn len(&self) -> usize {
71 self.objects.lock().expect("S3 stub mutex poisoned").len()
72 }
73
74 /// Fetch a stored object by key.
75 pub(crate) fn get(&self, key: &str) -> Option<StoredObject> {
76 self.objects
77 .lock()
78 .expect("S3 stub mutex poisoned")
79 .get(key)
80 .cloned()
81 }
82 }
83
84 /// One handler for the three verbs, so an unexpected method surfaces as 405
85 /// rather than a routing miss that the SDK would report as a connection error.
86 async fn object_handler(
87 State(objects): State<Objects>,
88 Path((_bucket, key)): Path<(String, String)>,
89 method: axum::http::Method,
90 headers: HeaderMap,
91 body: Bytes,
92 ) -> Response {
93 match method {
94 axum::http::Method::PUT => {
95 let content_type = headers
96 .get(header::CONTENT_TYPE)
97 .and_then(|v| v.to_str().ok())
98 .unwrap_or("application/octet-stream")
99 .to_string();
100 objects.lock().expect("S3 stub mutex poisoned").insert(
101 key,
102 StoredObject {
103 data: body.to_vec(),
104 content_type,
105 },
106 );
107 // A real PutObject answers 200 with an ETag and no body.
108 ([(header::ETAG, "\"stub\"")], StatusCode::OK).into_response()
109 }
110 axum::http::Method::GET => {
111 let found = objects
112 .lock()
113 .expect("S3 stub mutex poisoned")
114 .get(&key)
115 .cloned();
116 match found {
117 Some(obj) => (
118 [
119 (header::CONTENT_TYPE, obj.content_type),
120 (header::ETAG, "\"stub\"".to_string()),
121 ],
122 obj.data,
123 )
124 .into_response(),
125 None => not_found(),
126 }
127 }
128 axum::http::Method::DELETE => {
129 objects.lock().expect("S3 stub mutex poisoned").remove(&key);
130 StatusCode::NO_CONTENT.into_response()
131 }
132 _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),
133 }
134 }
135
136 /// S3 reports a missing key as 404 carrying a `NoSuchKey` error document; the
137 /// SDK parses the body to classify the error, so return the real shape.
138 fn not_found() -> Response {
139 (
140 StatusCode::NOT_FOUND,
141 [(header::CONTENT_TYPE, "application/xml")],
142 r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message></Error>"#,
143 )
144 .into_response()
145 }
146