objectstore_service/backend/
s3_compatible.rs1use std::time::SystemTime;
4use std::{fmt, io};
5
6use futures_util::{StreamExt, TryStreamExt};
7use objectstore_types::metadata::{HEADER_SIZE, Metadata};
8use objectstore_types::range::{ByteRange, ContentRange};
9use reqwest::header::{HeaderMap, HeaderName};
10use reqwest::{Body, IntoUrl, Method, RequestBuilder, Response, StatusCode};
11
12use super::extensions::{ResponseExt, SendTraced};
13use crate::backend::common::{
14 self, Backend, DeleteResponse, GetResponse, MetadataResponse, PutResponse,
15};
16use crate::error::{Error, Result};
17use crate::id::ObjectId;
18use crate::stream::ClientStream;
19
20#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
36pub struct S3CompatibleConfig {
37 pub endpoint: String,
46
47 pub bucket: String,
55}
56
57const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
61const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
66
67pub trait Token: Send + Sync {
69 fn as_str(&self) -> &str;
71}
72
73pub trait TokenProvider: Send + Sync + 'static {
75 fn get_token(&self) -> impl Future<Output = anyhow::Result<impl Token>> + Send;
77}
78
79#[derive(Debug)]
81pub struct NoToken;
82
83impl TokenProvider for NoToken {
84 #[allow(refining_impl_trait)]
85 async fn get_token(&self) -> anyhow::Result<NoToken> {
86 unimplemented!()
87 }
88}
89impl Token for NoToken {
90 fn as_str(&self) -> &str {
91 unimplemented!()
92 }
93}
94
95pub struct S3CompatibleBackend<T> {
97 client: reqwest::Client,
98
99 endpoint: String,
100 bucket: String,
101
102 token_provider: Option<T>,
103}
104
105impl<T> S3CompatibleBackend<T> {
106 pub fn new(endpoint: &str, bucket: &str, token_provider: T) -> Self {
108 Self {
109 client: common::reqwest_client(),
110 endpoint: endpoint.into(),
111 bucket: bucket.into(),
112 token_provider: Some(token_provider),
113 }
114 }
115
116 fn object_url(&self, id: &ObjectId) -> String {
118 format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
119 }
120}
121
122fn metadata_to_gcs_headers(
124 metadata: &Metadata,
125 prefix: &str,
126) -> Result<HeaderMap, objectstore_types::metadata::Error> {
127 let mut headers = metadata.to_headers(prefix)?;
128
129 let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
133 headers.remove(&size);
134
135 if let Some(expires_at) = metadata.time_expires {
137 let expires_at = humantime::format_rfc3339_seconds(expires_at);
138 headers.append(GCS_CUSTOM_TIME, expires_at.to_string().parse()?);
139 }
140 Ok(headers)
141}
142
143impl<T> S3CompatibleBackend<T>
144where
145 T: TokenProvider,
146{
147 async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
149 let mut builder = self.client.request(method, url);
150 if let Some(provider) = &self.token_provider {
151 builder = builder.bearer_auth(
152 provider
153 .get_token()
154 .await
155 .map_err(|err| Error::Generic {
156 context: "S3: failed to get authentication token".to_owned(),
157 cause: Some(err.into()),
158 })?
159 .as_str(),
160 );
161 }
162 Ok(builder)
163 }
164
165 async fn request_object(
169 &self,
170 method: Method,
171 id: &ObjectId,
172 range: Option<ByteRange>,
173 ) -> Result<Option<(Metadata, Option<ContentRange>, Response)>> {
174 let object_url = self.object_url(id);
175
176 let mut builder = self.request(method, &object_url).await?;
177 if let Some(r) = range {
178 builder = builder.header(reqwest::header::RANGE, r.to_header_value());
179 }
180 let response = builder
181 .send_traced()
182 .await
183 .map_err(|cause| Error::Reqwest {
184 context: "S3: failed to send request".to_string(),
185 cause,
186 })?;
187
188 if response.status() == StatusCode::NOT_FOUND {
189 objectstore_log::debug!("Object not found");
190 response.drain_body().await;
191 return Ok(None);
192 }
193
194 if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
195 let raw = response
196 .headers()
197 .get(reqwest::header::CONTENT_RANGE)
198 .and_then(|v| v.to_str().ok());
199 let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
200 let err = match total {
201 Some(total) => Error::RangeNotSatisfiable { total },
202 None => Error::generic(format!(
203 "S3: 416 response with invalid Content-Range: {raw:?}"
204 )),
205 };
206 response.drain_body().await;
207 return Err(err);
208 }
209
210 let response = response.check_error("S3: failed to get object").await?;
211
212 let headers = response.headers();
213 let mut metadata = Metadata::from_headers(headers, GCS_CUSTOM_PREFIX)?;
214
215 let content_range = if response.status() == StatusCode::PARTIAL_CONTENT {
216 let range = headers
217 .get(reqwest::header::CONTENT_RANGE)
218 .and_then(|v| v.to_str().ok())
219 .and_then(|s| s.parse::<ContentRange>().ok())
220 .ok_or_else(|| Error::Generic {
221 context: "S3: 206 response missing valid Content-Range header".to_owned(),
222 cause: None,
223 })?;
224 metadata.size = Some(range.total as usize);
225 Some(range)
226 } else {
227 let size = headers
230 .get(reqwest::header::CONTENT_LENGTH)
231 .and_then(|value| value.to_str().ok())
232 .map(|value| value.parse::<usize>())
233 .transpose()
234 .map_err(|cause| Error::Generic {
235 context: "S3: failed to parse Content-Length from object response".to_string(),
236 cause: Some(Box::new(cause)),
237 })?;
238
239 if let Some(size) = size {
240 metadata.size = Some(size);
241 } else {
242 objectstore_log::warn!("S3: 200 response missing Content-Length header");
243 }
244 None
245 };
246
247 let access_time = SystemTime::now();
249
250 if metadata.expiration_policy.is_timeout()
252 && metadata.time_expires.is_some_and(|ts| ts < access_time)
253 {
254 objectstore_log::debug!("Object found but past expiry");
255 response.drain_body().await;
256 return Ok(None);
257 }
258
259 if let Some(new_expire_at) = metadata.check_tti_bump(access_time) {
262 let mut bumped = metadata.clone();
263 bumped.time_expires = Some(new_expire_at);
264 self.update_metadata(id, &bumped).await?;
265 }
266
267 Ok(Some((metadata, content_range, response)))
268 }
269
270 async fn update_metadata(&self, id: &ObjectId, metadata: &Metadata) -> Result<()> {
272 self.request(Method::PUT, self.object_url(id))
275 .await?
276 .header(
277 "x-goog-copy-source",
278 format!("/{}/{}", self.bucket, id.as_storage_path()),
279 )
280 .header("x-goog-metadata-directive", "REPLACE")
281 .headers(metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)?)
282 .send_traced()
283 .await
284 .check_error("S3: update expiration time")
285 .await?
286 .drain_body()
287 .await;
288
289 Ok(())
290 }
291}
292
293impl<T> fmt::Debug for S3CompatibleBackend<T> {
294 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295 f.debug_struct("S3Compatible")
296 .field("client", &self.client)
297 .field("endpoint", &self.endpoint)
298 .field("bucket", &self.bucket)
299 .finish_non_exhaustive()
300 }
301}
302
303impl S3CompatibleBackend<NoToken> {
304 pub fn without_token(config: S3CompatibleConfig) -> Self {
306 Self {
307 client: common::reqwest_client(),
308 endpoint: config.endpoint,
309 bucket: config.bucket,
310 token_provider: None,
311 }
312 }
313}
314
315#[async_trait::async_trait]
316impl<T: TokenProvider> Backend for S3CompatibleBackend<T> {
317 fn name(&self) -> &'static str {
318 "s3-compatible"
319 }
320
321 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
322 async fn put_object(
323 &self,
324 id: &ObjectId,
325 metadata: &Metadata,
326 stream: ClientStream,
327 ) -> Result<PutResponse> {
328 objectstore_log::debug!("Writing to s3_compatible backend");
329 self.request(Method::PUT, self.object_url(id))
330 .await?
331 .headers(metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)?)
332 .body(Body::wrap_stream(stream))
333 .send_traced()
334 .await
335 .check_error("S3: failed to put object")
336 .await?
337 .drain_body()
338 .await;
339
340 Ok(())
341 }
342
343 #[tracing::instrument(level = "debug", skip(self))]
344 async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
345 objectstore_log::debug!("Reading from s3_compatible backend");
346
347 let Some((metadata, content_range, response)) =
348 self.request_object(Method::GET, id, range).await?
349 else {
350 return Ok(None);
351 };
352
353 let stream = response.bytes_stream().map_err(io::Error::other);
354 Ok(Some((metadata, content_range, stream.boxed())))
355 }
356
357 #[tracing::instrument(level = "debug", skip(self))]
358 async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
359 objectstore_log::debug!("Reading metadata from s3_compatible backend");
360 let response = self.request_object(Method::HEAD, id, None).await?;
361 Ok(response.map(|(metadata, _, _)| metadata))
362 }
363
364 #[tracing::instrument(level = "debug", skip(self))]
365 async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
366 objectstore_log::debug!("Deleting from s3_compatible backend");
367 let response = self
368 .request(Method::DELETE, self.object_url(id))
369 .await?
370 .send_traced()
371 .await
372 .map_err(|cause| Error::Reqwest {
373 context: "S3: failed to send delete request".to_string(),
374 cause,
375 })?;
376
377 if response.status() == StatusCode::NOT_FOUND {
379 response.drain_body().await;
380 return Ok(());
381 }
382
383 response
384 .check_error("S3: failed to delete object")
385 .await?
386 .drain_body()
387 .await;
388
389 Ok(())
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use std::collections::BTreeMap;
396 use std::time::Duration;
397
398 use anyhow::Result;
399 use objectstore_types::metadata::ExpirationPolicy;
400 use objectstore_types::scope::{Scope, Scopes};
401
402 use super::*;
403 use crate::backend::common::Backend;
404 use crate::id::ObjectContext;
405 use crate::stream;
406
407 fn create_test_backend() -> S3CompatibleBackend<NoToken> {
413 S3CompatibleBackend::without_token(S3CompatibleConfig {
414 endpoint: "http://localhost:8089".into(),
415 bucket: "test-bucket".into(),
416 })
417 }
418
419 fn make_id() -> ObjectId {
420 ObjectId::random(ObjectContext {
421 usecase: "testing".into(),
422 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
423 })
424 }
425
426 #[test]
427 fn metadata_to_gcs_headers_omits_size() {
428 let metadata = Metadata {
429 size: Some(4096),
430 ..Default::default()
431 };
432
433 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
434
435 assert!(headers.get("x-goog-meta-x-sn-size").is_none());
437 }
438
439 #[test]
440 fn metadata_to_gcs_headers_uses_time_expires() {
441 let expires = SystemTime::now() + Duration::from_hours(1);
442 let metadata = Metadata {
443 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
444 time_expires: Some(expires),
445 ..Default::default()
446 };
447
448 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
449
450 let custom_time = headers.get(GCS_CUSTOM_TIME).unwrap().to_str().unwrap();
452 let expected = humantime::format_rfc3339_seconds(expires).to_string();
453 assert_eq!(custom_time, expected);
454 }
455
456 #[test]
457 fn metadata_to_gcs_headers_escapes_unicode() {
458 let metadata = Metadata {
459 filename: Some("réport-📄.pdf".into()),
460 custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
461 ..Default::default()
462 };
463
464 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
465 assert_eq!(
466 headers.get("x-goog-meta-x-sn-filename").unwrap(),
467 "r%C3%A9port-%F0%9F%93%84.pdf",
468 );
469 assert_eq!(
470 headers.get("x-goog-meta-x-snme-release").unwrap(),
471 "v%C3%A9rsion-1.0-%F0%9F%9A%80",
472 );
473
474 let roundtripped = Metadata::from_headers(&headers, GCS_CUSTOM_PREFIX).unwrap();
476 assert_eq!(roundtripped.filename, metadata.filename);
477 assert_eq!(roundtripped.custom, metadata.custom);
478 }
479
480 #[tokio::test]
481 async fn test_get_metadata_nonexistent() -> Result<()> {
482 let backend = create_test_backend();
483 let id = make_id();
484 let result = backend.get_metadata(&id).await?;
485 assert!(result.is_none());
486 Ok(())
487 }
488
489 #[tokio::test]
490 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
491 async fn test_get_metadata_reports_size() -> Result<()> {
492 let backend = create_test_backend();
493 let id = make_id();
494 let payload = "hello, world";
495
496 backend
497 .put_object(&id, &Metadata::default(), stream::single(payload))
498 .await?;
499
500 let metadata = backend.get_metadata(&id).await?.expect("object exists");
503 assert_eq!(metadata.size, Some(payload.len()));
504
505 Ok(())
506 }
507
508 #[tokio::test]
509 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
510 async fn test_ttl_immediate() -> Result<()> {
511 let backend = create_test_backend();
512 let id = make_id();
513 let metadata = Metadata {
514 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
515 time_expires: Some(SystemTime::now()),
516 ..Default::default()
517 };
518
519 backend
520 .put_object(&id, &metadata, stream::single("hello, world"))
521 .await?;
522
523 let get_result = backend.get_object(&id, None).await?;
524 assert!(get_result.is_none());
525
526 let head_result = backend.get_metadata(&id).await?;
527 assert!(head_result.is_none());
528
529 Ok(())
530 }
531
532 #[tokio::test]
533 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
534 async fn test_tti_immediate() -> Result<()> {
535 let backend = create_test_backend();
536 let id = make_id();
537 let metadata = Metadata {
538 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
539 time_expires: Some(SystemTime::now()),
540 ..Default::default()
541 };
542
543 backend
544 .put_object(&id, &metadata, stream::single("hello, world"))
545 .await?;
546
547 let get_result = backend.get_object(&id, None).await?;
548 assert!(get_result.is_none());
549
550 let head_result = backend.get_metadata(&id).await?;
551 assert!(head_result.is_none());
552
553 Ok(())
554 }
555}