1use std::convert::Infallible;
4use std::error::Error as StdError;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7use std::{fmt, io};
8
9use futures_util::{StreamExt, TryStreamExt};
10use objectstore_types::metadata::{HEADER_SIZE, Metadata};
11use objectstore_types::range::{ByteRange, ContentRange};
12use objectstore_types::time::Timestamp;
13use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
14use reqwest::{Body, IntoUrl, Method, RequestBuilder, Response, StatusCode};
15
16use super::extensions::{ResponseExt, SendTraced};
17use crate::backend::common::{
18 self, Backend, DeleteResponse, GetResponse, MetadataResponse, PutResponse,
19};
20use crate::backend::extensions::ReqwestResultExt;
21use crate::change_stream::{
22 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
23};
24use crate::error::{Error, ErrorKind, Result, ResultExt as _};
25use crate::id::ObjectId;
26use crate::stream::{ClientStream, counting_stream};
27
28#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
44pub struct S3CompatibleConfig {
45 pub endpoint: String,
54
55 pub bucket: String,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub cogs: Option<CostTrackerStreamConfig>,
76}
77
78const GCS_CUSTOM_PREFIX: &str = "x-goog-meta-";
82const GCS_CUSTOM_TIME: &str = "x-goog-custom-time";
87
88pub trait Token: Send + Sync {
90 fn as_str(&self) -> &str;
92}
93
94pub trait TokenProvider: Send + Sync + 'static {
96 type Error: StdError + Send + Sync + 'static;
98
99 fn get_token(
101 &self,
102 ) -> impl Future<Output = std::result::Result<impl Token, Self::Error>> + Send;
103}
104
105#[derive(Debug)]
107pub struct NoToken;
108
109impl TokenProvider for NoToken {
110 type Error = Infallible;
111
112 #[allow(refining_impl_trait)]
113 async fn get_token(&self) -> std::result::Result<NoToken, Infallible> {
114 unimplemented!()
115 }
116}
117impl Token for NoToken {
118 fn as_str(&self) -> &str {
119 unimplemented!()
120 }
121}
122
123pub struct S3CompatibleBackend<T> {
125 client: reqwest::Client,
126
127 endpoint: String,
128 bucket: String,
129
130 token_provider: Option<T>,
131
132 change_stream: Arc<dyn ChangeStream>,
133}
134
135impl<T> S3CompatibleBackend<T> {
136 pub fn new(
138 config: S3CompatibleConfig,
139 token_provider: T,
140 streams: &ChangeStreamFactory,
141 ) -> Self {
142 Self::build(config, Some(token_provider), streams)
143 }
144
145 fn build(
146 config: S3CompatibleConfig,
147 token_provider: Option<T>,
148 streams: &ChangeStreamFactory,
149 ) -> Self {
150 let S3CompatibleConfig {
151 endpoint,
152 bucket,
153 cogs,
154 } = config;
155 Self {
156 client: common::reqwest_client(),
157 endpoint,
158 bucket,
159 token_provider,
160 change_stream: streams.build(cogs.as_ref()),
161 }
162 }
163
164 fn object_url(&self, id: &ObjectId) -> String {
166 format!("{}/{}/{}", self.endpoint, self.bucket, id.as_storage_path())
167 }
168}
169
170fn headers_size(headers: &HeaderMap) -> u64 {
172 headers
173 .iter()
174 .map(|(name, value)| name.as_str().len() as u64 + value.len() as u64)
175 .sum()
176}
177
178fn metadata_to_gcs_headers(
180 metadata: &Metadata,
181 prefix: &str,
182) -> Result<HeaderMap, objectstore_types::metadata::Error> {
183 let mut headers = metadata.to_headers(prefix)?;
184
185 let size = HeaderName::try_from(format!("{prefix}{HEADER_SIZE}"))?;
189 headers.remove(&size);
190
191 if let Some(expires_at) = metadata.time_expires {
193 let expires_at = expires_at.as_rfc3339();
194 headers.append(GCS_CUSTOM_TIME, expires_at.to_string().parse()?);
195 }
196 Ok(headers)
197}
198
199impl<T> S3CompatibleBackend<T>
200where
201 T: TokenProvider,
202{
203 async fn request(&self, method: Method, url: impl IntoUrl) -> Result<RequestBuilder> {
205 let mut builder = self.client.request(method, url);
206 if let Some(provider) = &self.token_provider {
207 builder = builder.bearer_auth(
208 provider
209 .get_token()
210 .await
211 .context(ErrorKind::BackendFailure, "getting S3 authentication token")?
212 .as_str(),
213 );
214 }
215 Ok(builder)
216 }
217
218 async fn request_object(
221 &self,
222 method: Method,
223 id: &ObjectId,
224 access_time: Timestamp,
225 range: Option<ByteRange>,
226 ) -> Result<Option<(Metadata, Option<ContentRange>, Response)>> {
227 let object_url = self.object_url(id);
228
229 let mut builder = self.request(method, &object_url).await?;
230 if let Some(r) = range {
231 builder = builder.header(reqwest::header::RANGE, r.to_header_value());
232 }
233 let response = builder
234 .send_traced()
235 .await
236 .reqwest_context("sending an S3 object request")?;
237
238 if response.status() == StatusCode::NOT_FOUND {
239 objectstore_log::debug!("Object not found");
240 response.drain_body().await;
241 return Ok(None);
242 }
243
244 if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
245 let raw = response
246 .headers()
247 .get(reqwest::header::CONTENT_RANGE)
248 .and_then(|v| v.to_str().ok());
249 let total = raw.and_then(ContentRange::parse_unsatisfiable_total);
250 let err = match total {
251 Some(total) => ErrorKind::RangeNotSatisfiable { total }.into(),
252 None => Error::new(ErrorKind::BackendFailure, "invalid S3 416 Content-Range"),
253 };
254 response.drain_body().await;
255 return Err(err);
256 }
257
258 let response = response.check_error("getting an S3 object").await?;
259
260 let headers = response.headers();
261 let mut metadata = Metadata::from_headers(headers, GCS_CUSTOM_PREFIX)
262 .context(ErrorKind::CorruptData, "decoding S3 object metadata")?;
263
264 let content_range = if response.status() == StatusCode::PARTIAL_CONTENT {
265 let range = headers
266 .get(reqwest::header::CONTENT_RANGE)
267 .and_then(|v| v.to_str().ok())
268 .and_then(|s| s.parse::<ContentRange>().ok())
269 .ok_or_else(|| {
270 Error::new(ErrorKind::BackendFailure, "missing S3 206 Content-Range")
271 })?;
272 metadata.size = Some(range.total as usize);
273 Some(range)
274 } else {
275 let size = headers
278 .get(reqwest::header::CONTENT_LENGTH)
279 .and_then(|value| value.to_str().ok())
280 .map(|value| value.parse::<usize>())
281 .transpose()
282 .context(ErrorKind::CorruptData, "decoding S3 Content-Length")?;
283
284 if let Some(size) = size {
285 metadata.size = Some(size);
286 } else {
287 objectstore_log::warn!("S3: 200 response missing Content-Length header");
288 }
289 None
290 };
291
292 if metadata.is_expired(access_time) {
294 objectstore_log::debug!("Object found but past expiry");
295 response.drain_body().await;
296 return Ok(None);
297 }
298
299 Ok(Some((metadata, content_range, response)))
300 }
301
302 async fn update_metadata(
304 &self,
305 id: &ObjectId,
306 metadata: &Metadata,
307 etag: &HeaderValue,
308 ) -> Result<bool> {
309 let request = self
312 .request(Method::PUT, self.object_url(id))
313 .await?
314 .header(
315 "x-amz-copy-source",
316 format!("/{}/{}", self.bucket, id.as_storage_path()),
317 )
318 .header("x-amz-metadata-directive", "REPLACE")
319 .header("x-amz-copy-source-if-match", etag.clone())
320 .headers(
321 metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
322 .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?,
323 );
324
325 let response = request.send_traced().await;
326 let response = response.reqwest_context("updating S3 expiration")?;
327 if matches!(
328 response.status(),
329 StatusCode::NOT_FOUND | StatusCode::CONFLICT | StatusCode::PRECONDITION_FAILED
330 ) {
331 response.drain_body().await;
332 return Ok(false);
333 }
334 response
335 .check_error("updating S3 expiration")
336 .await?
337 .drain_body()
338 .await;
339
340 Ok(true)
341 }
342}
343
344impl<T> fmt::Debug for S3CompatibleBackend<T> {
345 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346 f.debug_struct("S3Compatible")
347 .field("client", &self.client)
348 .field("endpoint", &self.endpoint)
349 .field("bucket", &self.bucket)
350 .finish_non_exhaustive()
351 }
352}
353
354impl S3CompatibleBackend<NoToken> {
355 pub fn without_token(config: S3CompatibleConfig, streams: &ChangeStreamFactory) -> Self {
357 Self::build(config, None, streams)
358 }
359}
360
361#[async_trait::async_trait]
362impl<T: TokenProvider> Backend for S3CompatibleBackend<T> {
363 fn name(&self) -> &'static str {
364 "s3-compatible"
365 }
366
367 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
368 async fn put_object(
369 &self,
370 id: &ObjectId,
371 metadata: &Metadata,
372 stream: ClientStream,
373 _access_time: Timestamp,
374 ) -> Result<PutResponse> {
375 objectstore_log::debug!("Writing to s3_compatible backend");
376 let headers = metadata_to_gcs_headers(metadata, GCS_CUSTOM_PREFIX)
377 .context(ErrorKind::InvalidMetadata, "encoding S3 object metadata")?;
378 let metadata_size = headers_size(&headers);
379
380 let (payload_size, counted) = counting_stream(stream);
382
383 self.request(Method::PUT, self.object_url(id))
384 .await?
385 .headers(headers)
386 .body(Body::wrap_stream(counted))
387 .send_traced()
388 .await
389 .check_error("uploading an S3 object")
390 .await?
391 .drain_body()
392 .await;
393
394 self.change_stream.write(
395 id,
396 metadata_size + payload_size.load(Ordering::Relaxed),
397 metadata.time_expires,
398 );
399
400 Ok(())
401 }
402
403 #[tracing::instrument(level = "debug", skip(self))]
404 async fn get_object(
405 &self,
406 id: &ObjectId,
407 access_time: Timestamp,
408 range: Option<ByteRange>,
409 ) -> Result<GetResponse> {
410 objectstore_log::debug!("Reading from s3_compatible backend");
411
412 let Some((metadata, content_range, response)) = self
413 .request_object(Method::GET, id, access_time, range)
414 .await?
415 else {
416 return Ok(None);
417 };
418
419 let stream = response.bytes_stream().map_err(io::Error::other);
420 Ok(Some((metadata, content_range, stream.boxed())))
421 }
422
423 #[tracing::instrument(level = "debug", skip(self))]
424 async fn get_metadata(
425 &self,
426 id: &ObjectId,
427 access_time: Timestamp,
428 ) -> Result<MetadataResponse> {
429 objectstore_log::debug!("Reading metadata from s3_compatible backend");
430 let response = self
431 .request_object(Method::HEAD, id, access_time, None)
432 .await?;
433 Ok(response.map(|(metadata, _, _)| metadata))
434 }
435
436 #[tracing::instrument(level = "debug", skip(self))]
437 async fn set_expiry(
438 &self,
439 id: &ObjectId,
440 expire_at: Timestamp,
441 access_time: Timestamp,
442 ) -> Result<bool> {
443 let Some((mut metadata, _, response)) = self
444 .request_object(Method::HEAD, id, access_time, None)
445 .await?
446 else {
447 return Ok(false);
448 };
449 let Some(current_expiry) = metadata.time_expires else {
450 response.drain_body().await;
451 return Ok(false);
452 };
453 if current_expiry >= expire_at {
454 response.drain_body().await;
455 return Ok(true); }
457
458 let etag = response.headers().get(reqwest::header::ETAG).cloned();
459 response.drain_body().await;
460 let etag = etag.ok_or_else(|| {
461 Error::new(ErrorKind::BackendFailure, "S3 HEAD response missing ETag")
462 })?;
463
464 metadata.time_expires = Some(expire_at);
465 let applied = self.update_metadata(id, &metadata, &etag).await?;
466 if applied {
467 self.change_stream.update(id, Some(expire_at));
468 }
469
470 Ok(applied)
471 }
472
473 #[tracing::instrument(level = "debug", skip(self))]
474 async fn delete_object(
475 &self,
476 id: &ObjectId,
477 _access_time: Timestamp,
478 ) -> Result<DeleteResponse> {
479 objectstore_log::debug!("Deleting from s3_compatible backend");
480 let response = self
481 .request(Method::DELETE, self.object_url(id))
482 .await?
483 .send_traced()
484 .await
485 .reqwest_context("sending an S3 delete request")?;
486
487 if response.status() == StatusCode::NOT_FOUND {
490 response.drain_body().await;
491 return Ok(());
492 }
493
494 response
495 .check_error("deleting an S3 object")
496 .await?
497 .drain_body()
498 .await;
499
500 self.change_stream.delete(id);
503
504 Ok(())
505 }
506
507 async fn join(&self) {
508 flush_change_stream(&self.change_stream).await;
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use std::collections::BTreeMap;
515 use std::io::{Read, Write};
516 use std::net::{TcpListener, TcpStream};
517 use std::sync::mpsc;
518 use std::thread;
519 use std::time::Duration;
520
521 use anyhow::Result;
522 use objectstore_types::metadata::ExpirationPolicy;
523 use objectstore_types::scope::{Scope, Scopes};
524
525 use super::*;
526 use crate::backend::common::Backend;
527 use crate::id::ObjectContext;
528 use crate::stream;
529
530 fn create_test_backend() -> S3CompatibleBackend<NoToken> {
536 S3CompatibleBackend::without_token(
537 S3CompatibleConfig {
538 endpoint: "http://localhost:8089".into(),
539 bucket: "test-bucket".into(),
540 cogs: None,
541 },
542 &ChangeStreamFactory::default(),
543 )
544 }
545
546 fn make_id() -> ObjectId {
547 ObjectId::random(ObjectContext {
548 usecase: "testing".into(),
549 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
550 })
551 }
552
553 fn read_http_request(connection: &mut TcpStream) -> String {
554 let mut bytes = Vec::new();
555 let mut byte = [0];
556 while !bytes.ends_with(b"\r\n\r\n") {
557 connection.read_exact(&mut byte).unwrap();
558 bytes.push(byte[0]);
559 }
560 String::from_utf8(bytes).unwrap()
561 }
562
563 fn start_copy_server(
564 copy_status: &'static str,
565 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
566 let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
567 let endpoint = format!("http://{}", listener.local_addr().unwrap());
568 let (request_tx, request_rx) = mpsc::channel();
569 let server = thread::spawn(move || {
570 let (mut copy, _) = listener.accept().unwrap();
571 request_tx.send(read_http_request(&mut copy)).unwrap();
572 write!(
573 copy,
574 "HTTP/1.1 {copy_status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
575 )
576 .unwrap();
577 });
578 (endpoint, request_rx, server)
579 }
580
581 #[tokio::test]
582 async fn update_metadata_uses_conditional_s3_copy() {
583 for (status, expected) in [
584 ("200 OK", true),
585 ("404 Not Found", false),
586 ("409 Conflict", false),
587 ("412 Precondition Failed", false),
588 ] {
589 let (endpoint, request_rx, server) = start_copy_server(status);
590 let backend = S3CompatibleBackend::without_token(
591 S3CompatibleConfig {
592 endpoint,
593 bucket: "bucket".into(),
594 cogs: None,
595 },
596 &ChangeStreamFactory::default(),
597 );
598
599 assert_eq!(
600 backend
601 .update_metadata(
602 &make_id(),
603 &Metadata::default(),
604 &HeaderValue::from_static("\"etag\""),
605 )
606 .await
607 .unwrap(),
608 expected
609 );
610 let request = request_rx.recv().unwrap().to_ascii_lowercase();
611 assert!(request.contains("x-amz-copy-source: /bucket/"));
612 assert!(request.contains("x-amz-metadata-directive: replace"));
613 assert!(request.contains("x-amz-copy-source-if-match: \"etag\""));
614 server.join().unwrap();
615 }
616 }
617
618 #[test]
619 fn metadata_to_gcs_headers_omits_size() {
620 let metadata = Metadata {
621 size: Some(4096),
622 ..Default::default()
623 };
624
625 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
626
627 assert!(headers.get("x-goog-meta-x-sn-size").is_none());
629 }
630
631 #[test]
632 fn metadata_to_gcs_headers_uses_time_expires() {
633 let expires = Timestamp::now() + Duration::from_hours(1);
634 let metadata = Metadata {
635 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
636 time_expires: Some(expires),
637 ..Default::default()
638 };
639
640 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
641 let custom_time = headers.get(GCS_CUSTOM_TIME).unwrap().to_str().unwrap();
642 let expected = expires.as_rfc3339().to_string();
643 assert_eq!(custom_time, expected);
644 }
645
646 #[test]
647 fn metadata_to_gcs_headers_escapes_unicode() {
648 let metadata = Metadata {
649 filename: Some("réport-📄.pdf".into()),
650 custom: BTreeMap::from_iter([("release".into(), "vérsion-1.0-🚀".into())]),
651 ..Default::default()
652 };
653
654 let headers = metadata_to_gcs_headers(&metadata, GCS_CUSTOM_PREFIX).unwrap();
655 assert_eq!(
656 headers.get("x-goog-meta-x-sn-filename").unwrap(),
657 "r%C3%A9port-%F0%9F%93%84.pdf",
658 );
659 assert_eq!(
660 headers.get("x-goog-meta-x-snme-release").unwrap(),
661 "v%C3%A9rsion-1.0-%F0%9F%9A%80",
662 );
663
664 let roundtripped = Metadata::from_headers(&headers, GCS_CUSTOM_PREFIX).unwrap();
666 assert_eq!(roundtripped.filename, metadata.filename);
667 assert_eq!(roundtripped.custom, metadata.custom);
668 }
669
670 #[test]
671 fn headers_size_counts_names_and_values() {
672 let mut headers = HeaderMap::new();
673 headers.insert("x-goog-meta-a", "1".parse().unwrap());
674 headers.insert("x-goog-meta-bb", "22".parse().unwrap());
675
676 assert_eq!(
677 headers_size(&headers),
678 ("x-goog-meta-a".len() + 1 + "x-goog-meta-bb".len() + 2) as u64
679 );
680 }
681
682 #[tokio::test]
683 async fn test_get_metadata_nonexistent() -> Result<()> {
684 let backend = create_test_backend();
685 let id = make_id();
686 let result = backend.get_metadata(&id, Timestamp::now()).await?;
687 assert!(result.is_none());
688 Ok(())
689 }
690
691 #[tokio::test]
692 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
693 async fn test_get_metadata_reports_size() -> Result<()> {
694 let backend = create_test_backend();
695 let id = make_id();
696 let payload = "hello, world";
697
698 backend
699 .put_object(
700 &id,
701 &Metadata::default(),
702 stream::single(payload),
703 Timestamp::now(),
704 )
705 .await?;
706
707 let metadata = backend
710 .get_metadata(&id, Timestamp::now())
711 .await?
712 .expect("object exists");
713 assert_eq!(metadata.size, Some(payload.len()));
714
715 Ok(())
716 }
717
718 #[tokio::test]
719 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
720 async fn test_ttl_immediate() -> Result<()> {
721 let backend = create_test_backend();
722 let id = make_id();
723 let metadata = Metadata {
724 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
725 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
726 ..Default::default()
727 };
728
729 backend
730 .put_object(
731 &id,
732 &metadata,
733 stream::single("hello, world"),
734 Timestamp::now(),
735 )
736 .await?;
737
738 let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
739 assert!(get_result.is_none());
740
741 let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
742 assert!(head_result.is_none());
743
744 Ok(())
745 }
746
747 #[tokio::test]
748 #[ignore = "MinIO does not support streaming bodies (requires Content-Length)"]
749 async fn test_tti_immediate() -> Result<()> {
750 let backend = create_test_backend();
751 let id = make_id();
752 let metadata = Metadata {
753 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
754 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
755 ..Default::default()
756 };
757
758 backend
759 .put_object(
760 &id,
761 &metadata,
762 stream::single("hello, world"),
763 Timestamp::now(),
764 )
765 .await?;
766
767 let get_result = backend.get_object(&id, Timestamp::now(), None).await?;
768 assert!(get_result.is_none());
769
770 let head_result = backend.get_metadata(&id, Timestamp::now()).await?;
771 assert!(head_result.is_none());
772
773 Ok(())
774 }
775}