1use anyhow::{Result, anyhow};
10use aws_sdk_s3::Client;
11use aws_sdk_s3::error::ProvideErrorMetadata;
12use aws_sdk_s3::primitives::ByteStream;
13use bytes::Bytes;
14use futures::future::BoxFuture;
15use futures::stream::StreamExt;
16
17use crate::object::{DefaultKeyFormatter, KeyFormatter, LayoutConfigExt, ObjectBlockOps};
18use crate::{BlockId, SequenceHash};
19use kvbm_common::LogicalLayoutHandle;
20use kvbm_physical::transfer::PhysicalLayout;
21use std::sync::Arc;
22
23#[derive(Debug, Clone)]
25pub struct S3Config {
26 pub endpoint_url: Option<String>,
29
30 pub bucket: String,
32
33 pub region: String,
35
36 pub force_path_style: bool,
39
40 pub max_concurrent_requests: usize,
42}
43
44impl Default for S3Config {
45 fn default() -> Self {
47 Self {
48 endpoint_url: Some("http://localhost:9000".into()),
49 bucket: "kvbm-blocks".into(),
50 region: "us-east-1".into(),
51 force_path_style: true,
52 max_concurrent_requests: 16,
53 }
54 }
55}
56
57impl S3Config {
58 pub fn aws(bucket: String, region: String) -> Self {
60 Self {
61 endpoint_url: None,
62 bucket,
63 region,
64 force_path_style: false,
65 max_concurrent_requests: 16,
66 }
67 }
68
69 pub fn minio(endpoint_url: String, bucket: String) -> Self {
71 Self {
72 endpoint_url: Some(endpoint_url),
73 bucket,
74 region: "us-east-1".into(),
75 force_path_style: true,
76 max_concurrent_requests: 16,
77 }
78 }
79
80 pub fn from_object_config(config: &kvbm_config::S3ObjectConfig) -> Self {
82 Self {
83 endpoint_url: config.endpoint_url.clone(),
84 bucket: config.bucket.clone(),
85 region: config.region.clone(),
86 force_path_style: config.force_path_style,
87 max_concurrent_requests: config.max_concurrent_requests,
88 }
89 }
90
91 pub fn with_max_concurrent_requests(mut self, max: usize) -> Self {
93 self.max_concurrent_requests = max;
94 self
95 }
96}
97
98pub struct S3ObjectBlockClient {
108 client: Client,
110
111 config: S3Config,
113
114 key_formatter: Arc<dyn KeyFormatter>,
116}
117
118impl S3ObjectBlockClient {
119 pub async fn new(config: S3Config) -> Result<Self> {
127 let client = build_s3_client(&config).await?;
128 Ok(Self {
129 client,
130 config,
131 key_formatter: Arc::new(DefaultKeyFormatter),
132 })
133 }
134
135 pub async fn with_key_formatter(
144 config: S3Config,
145 key_formatter: Arc<dyn KeyFormatter>,
146 ) -> Result<Self> {
147 let client = build_s3_client(&config).await?;
148 Ok(Self {
149 client,
150 config,
151 key_formatter,
152 })
153 }
154
155 pub fn from_client(client: Client, config: S3Config) -> Self {
157 Self {
158 client,
159 config,
160 key_formatter: Arc::new(DefaultKeyFormatter),
161 }
162 }
163
164 pub fn from_client_with_formatter(
166 client: Client,
167 config: S3Config,
168 key_formatter: Arc<dyn KeyFormatter>,
169 ) -> Self {
170 Self {
171 client,
172 config,
173 key_formatter,
174 }
175 }
176
177 pub fn client(&self) -> &Client {
179 &self.client
180 }
181
182 pub fn config(&self) -> &S3Config {
184 &self.config
185 }
186
187 pub fn key_formatter(&self) -> &Arc<dyn KeyFormatter> {
189 &self.key_formatter
190 }
191
192 pub fn bucket(&self) -> &str {
194 &self.config.bucket
195 }
196
197 pub async fn ensure_bucket_exists(&self) -> Result<()> {
199 match self
200 .client
201 .head_bucket()
202 .bucket(&self.config.bucket)
203 .send()
204 .await
205 {
206 Ok(_) => Ok(()),
207 Err(_) => {
208 self.client
210 .create_bucket()
211 .bucket(&self.config.bucket)
212 .send()
213 .await
214 .map_err(|e| {
215 anyhow!("failed to create bucket '{}': {}", self.config.bucket, e)
216 })?;
217 Ok(())
218 }
219 }
220 }
221
222 pub async fn put_if_not_exists(&self, key: &str, data: Bytes) -> Result<bool> {
234 match self
235 .client
236 .put_object()
237 .bucket(&self.config.bucket)
238 .key(key)
239 .if_none_match("*")
240 .body(ByteStream::from(data))
241 .send()
242 .await
243 {
244 Ok(_) => Ok(true),
245 Err(e) => {
246 let service_error = e.into_service_error();
248 if service_error.code() == Some("PreconditionFailed") {
249 Ok(false)
250 } else {
251 Err(anyhow!(
252 "S3 conditional put failed for key '{}': {}",
253 key,
254 service_error
255 ))
256 }
257 }
258 }
259 }
260
261 pub async fn get_object(&self, key: &str) -> Result<Option<Bytes>> {
271 match self
272 .client
273 .get_object()
274 .bucket(&self.config.bucket)
275 .key(key)
276 .send()
277 .await
278 {
279 Ok(resp) => {
280 let data = resp
281 .body
282 .collect()
283 .await
284 .map_err(|e| anyhow!("failed to collect S3 response body: {}", e))?
285 .into_bytes();
286 Ok(Some(data))
287 }
288 Err(e) => {
289 let service_error = e.into_service_error();
290 if service_error.code() == Some("NoSuchKey") {
291 Ok(None)
292 } else {
293 Err(anyhow!(
294 "S3 get_object failed for key '{}': {}",
295 key,
296 service_error
297 ))
298 }
299 }
300 }
301 }
302
303 pub async fn delete_object(&self, key: &str) -> Result<bool> {
313 match self
314 .client
315 .delete_object()
316 .bucket(&self.config.bucket)
317 .key(key)
318 .send()
319 .await
320 {
321 Ok(_) => Ok(true),
322 Err(e) => {
323 let service_error = e.into_service_error();
324 if service_error.code() == Some("NoSuchKey") {
325 Ok(false)
326 } else {
327 Err(anyhow!(
328 "S3 delete_object failed for key '{}': {}",
329 key,
330 service_error
331 ))
332 }
333 }
334 }
335 }
336
337 pub async fn has_object(&self, key: &str) -> Result<bool> {
347 match self
348 .client
349 .head_object()
350 .bucket(&self.config.bucket)
351 .key(key)
352 .send()
353 .await
354 {
355 Ok(_) => Ok(true),
356 Err(e) => {
357 let service_error = e.into_service_error();
358 if service_error.code() == Some("NotFound") {
360 Ok(false)
361 } else {
362 Err(anyhow!(
363 "S3 head_object failed for key '{}': {}",
364 key,
365 service_error
366 ))
367 }
368 }
369 }
370 }
371
372 pub async fn put_object(&self, key: &str, data: Bytes) -> Result<()> {
378 self.client
379 .put_object()
380 .bucket(&self.config.bucket)
381 .key(key)
382 .body(ByteStream::from(data))
383 .send()
384 .await
385 .map_err(|e| anyhow!("S3 put_object failed for key '{}': {}", key, e))?;
386 Ok(())
387 }
388
389 pub fn put_blocks_with_layout(
403 &self,
404 keys: Vec<SequenceHash>,
405 layout: PhysicalLayout,
406 block_ids: Vec<BlockId>,
407 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
408 let config = layout.layout().config();
409 let block_size = config.block_size_bytes();
410 let region_size = config.region_size();
411 let is_contiguous = layout.layout().is_fully_contiguous();
412 let max_concurrent = self.config.max_concurrent_requests;
413 let client = self.client.clone();
414 let bucket = self.config.bucket.clone();
415 let formatter = self.key_formatter.clone();
416
417 Box::pin(async move {
418 let work_items: Vec<_> = keys.into_iter().zip(block_ids).collect();
419
420 let tasks = work_items.into_iter().map(|(key, block_id)| {
421 let client = client.clone();
422 let bucket = bucket.clone();
423 let key_str = formatter.format_key(&key);
424 let layout = layout.clone();
425
426 async move {
427 let result: Result<(), anyhow::Error> = async {
428 let data = tokio_rayon::spawn(move || {
430 copy_block_to_bytes(
431 &layout,
432 block_id,
433 block_size,
434 region_size,
435 is_contiguous,
436 )
437 })
438 .await?;
439
440 client
442 .put_object()
443 .bucket(&bucket)
444 .key(&key_str)
445 .body(ByteStream::from(data))
446 .send()
447 .await
448 .map_err(|e| anyhow!("S3 put_object failed: {}", e))?;
449
450 Ok(())
451 }
452 .await;
453
454 match result {
455 Ok(()) => Ok(key),
456 Err(e) => {
457 tracing::warn!(key = %key, error = %e, "put block transfer failed");
458 Err(key)
459 }
460 }
461 }
462 });
463
464 futures::stream::iter(tasks)
465 .buffer_unordered(max_concurrent)
466 .collect()
467 .await
468 })
469 }
470
471 pub fn get_blocks_with_layout(
485 &self,
486 keys: Vec<SequenceHash>,
487 layout: PhysicalLayout,
488 block_ids: Vec<BlockId>,
489 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
490 let config = layout.layout().config();
491 let block_size = config.block_size_bytes();
492 let region_size = config.region_size();
493 let is_contiguous = layout.layout().is_fully_contiguous();
494 let max_concurrent = self.config.max_concurrent_requests;
495 let client = self.client.clone();
496 let bucket = self.config.bucket.clone();
497 let formatter = self.key_formatter.clone();
498
499 Box::pin(async move {
500 let work_items: Vec<_> = keys.into_iter().zip(block_ids).collect();
501
502 let tasks = work_items.into_iter().map(|(key, block_id)| {
503 let client = client.clone();
504 let bucket = bucket.clone();
505 let key_str = formatter.format_key(&key);
506 let layout = layout.clone();
507
508 async move {
509 let result: Result<(), anyhow::Error> = async {
510 let resp = client
512 .get_object()
513 .bucket(&bucket)
514 .key(&key_str)
515 .send()
516 .await
517 .map_err(|e| anyhow!("S3 get_object failed: {}", e))?;
518
519 let data = resp
520 .body
521 .collect()
522 .await
523 .map_err(|e| anyhow!("failed to collect S3 response body: {}", e))?
524 .into_bytes();
525
526 tokio_rayon::spawn(move || {
528 copy_bytes_to_block(
529 &data,
530 &layout,
531 block_id,
532 block_size,
533 region_size,
534 is_contiguous,
535 )
536 })
537 .await?;
538
539 Ok(())
540 }
541 .await;
542
543 match result {
544 Ok(()) => Ok(key),
545 Err(e) => {
546 tracing::warn!(key = %key, error = %e, "get block transfer failed");
547 Err(key)
548 }
549 }
550 }
551 });
552
553 futures::stream::iter(tasks)
554 .buffer_unordered(max_concurrent)
555 .collect()
556 .await
557 })
558 }
559
560 pub async fn get_object_with_etag(&self, key: &str) -> Result<Option<(Bytes, Option<String>)>> {
570 match self
571 .client
572 .get_object()
573 .bucket(&self.config.bucket)
574 .key(key)
575 .send()
576 .await
577 {
578 Ok(resp) => {
579 let etag = resp.e_tag().map(|s| s.to_string());
580 let data = resp
581 .body
582 .collect()
583 .await
584 .map_err(|e| anyhow!("failed to collect S3 response body: {}", e))?
585 .into_bytes();
586 Ok(Some((data, etag)))
587 }
588 Err(e) => {
589 let service_error = e.into_service_error();
590 if service_error.code() == Some("NoSuchKey") {
591 Ok(None)
592 } else {
593 Err(anyhow!(
594 "S3 get_object failed for key '{}': {}",
595 key,
596 service_error
597 ))
598 }
599 }
600 }
601 }
602
603 pub async fn put_object_if_match(&self, key: &str, data: Bytes, etag: &str) -> Result<bool> {
613 match self
614 .client
615 .put_object()
616 .bucket(&self.config.bucket)
617 .key(key)
618 .if_match(etag)
619 .body(ByteStream::from(data))
620 .send()
621 .await
622 {
623 Ok(_) => Ok(true),
624 Err(e) => {
625 let service_error = e.into_service_error();
626 if service_error.code() == Some("PreconditionFailed") {
627 Ok(false)
628 } else {
629 Err(anyhow!(
630 "S3 conditional put (if-match) failed for key '{}': {}",
631 key,
632 service_error
633 ))
634 }
635 }
636 }
637 }
638}
639
640async fn build_s3_client(config: &S3Config) -> Result<Client> {
642 let sdk_config = aws_config::defaults(aws_config::BehaviorVersion::latest())
643 .region(aws_sdk_s3::config::Region::new(config.region.clone()))
644 .load()
645 .await;
646
647 let mut s3_config_builder = aws_sdk_s3::config::Builder::from(&sdk_config);
648
649 if let Some(endpoint) = &config.endpoint_url {
650 s3_config_builder = s3_config_builder.endpoint_url(endpoint);
651 }
652
653 if config.force_path_style {
654 s3_config_builder = s3_config_builder.force_path_style(true);
655 }
656
657 let s3_config = s3_config_builder.build();
658 Ok(Client::from_conf(s3_config))
659}
660
661fn copy_block_to_bytes(
666 layout: &PhysicalLayout,
667 block_id: BlockId,
668 block_size: usize,
669 region_size: usize,
670 is_contiguous: bool,
671) -> Result<Bytes> {
672 if is_contiguous {
673 let region = layout.memory_region(block_id, 0, 0)?;
676 let slice = unsafe { std::slice::from_raw_parts(region.addr() as *const u8, block_size) };
677 Ok(Bytes::copy_from_slice(slice))
678 } else {
679 let mut buf = Vec::with_capacity(block_size);
681 let inner_layout = layout.layout();
682 for layer_id in 0..inner_layout.num_layers() {
683 for outer_id in 0..inner_layout.outer_dim() {
684 let region = layout.memory_region(block_id, layer_id, outer_id)?;
685 if region.size() < region_size {
686 return Err(anyhow!(
687 "memory region too small: got {} bytes, need {}",
688 region.size(),
689 region_size
690 ));
691 }
692 let slice =
693 unsafe { std::slice::from_raw_parts(region.addr() as *const u8, region_size) };
694 buf.extend_from_slice(slice);
695 }
696 }
697 Ok(Bytes::from(buf))
698 }
699}
700
701fn copy_bytes_to_block(
706 data: &[u8],
707 layout: &PhysicalLayout,
708 block_id: BlockId,
709 block_size: usize,
710 region_size: usize,
711 is_contiguous: bool,
712) -> Result<()> {
713 if is_contiguous {
714 if data.len() < block_size {
717 return Err(anyhow!(
718 "S3 data too short: got {} bytes, expected {}",
719 data.len(),
720 block_size
721 ));
722 }
723 let region = layout.memory_region(block_id, 0, 0)?;
724 unsafe {
725 std::ptr::copy_nonoverlapping(data.as_ptr(), region.addr() as *mut u8, block_size);
726 }
727 } else {
728 let mut offset = 0;
730 let inner_layout = layout.layout();
731 for layer_id in 0..inner_layout.num_layers() {
732 for outer_id in 0..inner_layout.outer_dim() {
733 if offset + region_size > data.len() {
734 return Err(anyhow!(
735 "S3 data too short at offset {}: need {} more bytes, only {} remain",
736 offset,
737 region_size,
738 data.len().saturating_sub(offset)
739 ));
740 }
741 let region = layout.memory_region(block_id, layer_id, outer_id)?;
742 if region.size() < region_size {
743 return Err(anyhow!(
744 "memory region too small: got {} bytes, need {}",
745 region.size(),
746 region_size
747 ));
748 }
749 unsafe {
750 std::ptr::copy_nonoverlapping(
751 data[offset..].as_ptr(),
752 region.addr() as *mut u8,
753 region_size,
754 );
755 }
756 offset += region_size;
757 }
758 }
759 }
760 Ok(())
761}
762
763impl ObjectBlockOps for S3ObjectBlockClient {
764 fn has_blocks(
765 &self,
766 keys: Vec<SequenceHash>,
767 ) -> BoxFuture<'static, Vec<(SequenceHash, Option<usize>)>> {
768 let max_concurrent = self.config.max_concurrent_requests;
769 let client = self.client.clone();
770 let bucket = self.config.bucket.clone();
771 let formatter = self.key_formatter.clone();
772
773 Box::pin(async move {
774 let tasks = keys.into_iter().map(|key| {
775 let client = client.clone();
776 let bucket = bucket.clone();
777 let key_str = formatter.format_key(&key);
778
779 async move {
780 match client
781 .head_object()
782 .bucket(&bucket)
783 .key(&key_str)
784 .send()
785 .await
786 {
787 Ok(resp) => (key, resp.content_length().map(|l| l as usize)),
788 Err(e) => {
789 tracing::warn!(key = %key, error = %e, "head_object failed, treating as missing");
790 (key, None)
791 }
792 }
793 }
794 });
795
796 futures::stream::iter(tasks)
797 .buffer_unordered(max_concurrent)
798 .collect()
799 .await
800 })
801 }
802
803 fn put_blocks(
804 &self,
805 keys: Vec<SequenceHash>,
806 _src_layout: LogicalLayoutHandle,
807 _block_ids: Vec<BlockId>,
808 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
809 tracing::error!(
812 "S3ObjectBlockClient::put_blocks called with LogicalLayoutHandle - \
813 use put_blocks_with_layout() via DirectWorker instead"
814 );
815 Box::pin(async move { keys.into_iter().map(Err).collect() })
816 }
817
818 fn get_blocks(
819 &self,
820 keys: Vec<SequenceHash>,
821 _dst_layout: LogicalLayoutHandle,
822 _block_ids: Vec<BlockId>,
823 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
824 tracing::error!(
827 "S3ObjectBlockClient::get_blocks called with LogicalLayoutHandle - \
828 use get_blocks_with_layout() via DirectWorker instead"
829 );
830 Box::pin(async move { keys.into_iter().map(Err).collect() })
831 }
832
833 fn put_blocks_with_layout(
834 &self,
835 keys: Vec<SequenceHash>,
836 layout: PhysicalLayout,
837 block_ids: Vec<BlockId>,
838 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
839 S3ObjectBlockClient::put_blocks_with_layout(self, keys, layout, block_ids)
841 }
842
843 fn get_blocks_with_layout(
844 &self,
845 keys: Vec<SequenceHash>,
846 layout: PhysicalLayout,
847 block_ids: Vec<BlockId>,
848 ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
849 S3ObjectBlockClient::get_blocks_with_layout(self, keys, layout, block_ids)
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 #[test]
859 fn test_s3_config_default() {
860 let config = S3Config::default();
861 assert_eq!(config.endpoint_url, Some("http://localhost:9000".into()));
862 assert_eq!(config.bucket, "kvbm-blocks");
863 assert_eq!(config.region, "us-east-1");
864 assert!(config.force_path_style);
865 assert_eq!(config.max_concurrent_requests, 16);
866 }
867
868 #[test]
869 fn test_s3_config_aws() {
870 let config = S3Config::aws("my-bucket".into(), "us-west-2".into());
871 assert_eq!(config.endpoint_url, None);
872 assert_eq!(config.bucket, "my-bucket");
873 assert_eq!(config.region, "us-west-2");
874 assert!(!config.force_path_style);
875 }
876
877 #[test]
878 fn test_s3_config_minio() {
879 let config = S3Config::minio("http://minio:9000".into(), "test-bucket".into());
880 assert_eq!(config.endpoint_url, Some("http://minio:9000".into()));
881 assert_eq!(config.bucket, "test-bucket");
882 assert!(config.force_path_style);
883 }
884}
885
886#[cfg(all(test, feature = "testing"))]
887mod bounds_check_tests {
888 use super::*;
889 use crate::object::LayoutConfigExt;
890 use kvbm_physical::testing::{create_fc_layout, create_lw_layout, create_test_agent};
891 use kvbm_physical::transfer::StorageKind;
892
893 #[test]
894 fn test_copy_bytes_to_block_rejects_short_data_contiguous() {
895 let agent = create_test_agent("test_short_data_fc");
896 let layout = create_fc_layout(agent, StorageKind::System, 2);
897 let config = layout.layout().config();
898 let block_size = config.block_size_bytes();
899 let region_size = config.region_size();
900
901 let short_data = vec![0u8; block_size - 1];
903 let err = copy_bytes_to_block(&short_data, &layout, 0, block_size, region_size, true)
904 .expect_err("should reject short data");
905 assert!(
906 err.to_string().contains("S3 data too short"),
907 "unexpected error: {}",
908 err
909 );
910 }
911
912 #[test]
913 fn test_copy_bytes_to_block_rejects_short_data_non_contiguous() {
914 let agent = create_test_agent("test_short_data_lw");
915 let layout = create_lw_layout(agent, StorageKind::System, 2);
916 let config = layout.layout().config();
917 let block_size = config.block_size_bytes();
918 let region_size = config.region_size();
919
920 let short_data = vec![0u8; block_size - region_size];
922 let err = copy_bytes_to_block(&short_data, &layout, 0, block_size, region_size, false)
923 .expect_err("should reject short data in non-contiguous path");
924 assert!(
925 err.to_string().contains("S3 data too short"),
926 "unexpected error: {}",
927 err
928 );
929 }
930
931 #[test]
932 fn test_copy_bytes_to_block_accepts_exact_size() {
933 let agent = create_test_agent("test_exact_fc");
934 let layout = create_fc_layout(agent, StorageKind::System, 2);
935 let config = layout.layout().config();
936 let block_size = config.block_size_bytes();
937 let region_size = config.region_size();
938
939 let data = vec![42u8; block_size];
940 copy_bytes_to_block(&data, &layout, 0, block_size, region_size, true)
941 .expect("exact-size data should succeed");
942 }
943
944 #[test]
945 fn test_copy_block_to_bytes_roundtrip_contiguous() {
946 let agent = create_test_agent("test_roundtrip_fc");
947 let layout = create_fc_layout(agent, StorageKind::System, 2);
948 let config = layout.layout().config();
949 let block_size = config.block_size_bytes();
950 let region_size = config.region_size();
951
952 let data = vec![0xAB_u8; block_size];
954 copy_bytes_to_block(&data, &layout, 0, block_size, region_size, true).unwrap();
955
956 let out = copy_block_to_bytes(&layout, 0, block_size, region_size, true).unwrap();
958 assert_eq!(out.as_ref(), &data[..]);
959 }
960
961 #[test]
962 fn test_copy_block_to_bytes_roundtrip_non_contiguous() {
963 let agent = create_test_agent("test_roundtrip_lw");
964 let layout = create_lw_layout(agent, StorageKind::System, 2);
965 let config = layout.layout().config();
966 let block_size = config.block_size_bytes();
967 let region_size = config.region_size();
968
969 let data = vec![0xCD_u8; block_size];
970 copy_bytes_to_block(&data, &layout, 0, block_size, region_size, false).unwrap();
971
972 let out = copy_block_to_bytes(&layout, 0, block_size, region_size, false).unwrap();
973 assert_eq!(out.as_ref(), &data[..]);
974 }
975}
976
977#[cfg(all(test, feature = "testing-s3"))]
978pub mod s3_integration {
979 use super::*;
980
981 pub async fn create_test_client(bucket: &str) -> S3ObjectBlockClient {
986 let endpoint =
987 std::env::var("S3_TEST_ENDPOINT").unwrap_or_else(|_| "http://localhost:9876".into());
988 let config = S3Config::minio(endpoint, bucket.to_string());
989 let client = S3ObjectBlockClient::new(config).await.unwrap();
990 client.ensure_bucket_exists().await.unwrap();
991 client
992 }
993
994 #[tokio::test]
995 async fn test_put_get_roundtrip() {
996 let client = create_test_client("test-roundtrip").await;
997 let key = format!("roundtrip-{}", uuid::Uuid::new_v4());
998 let payload = Bytes::from("hello world");
999
1000 client.put_object(&key, payload.clone()).await.unwrap();
1001
1002 let result = client.get_object(&key).await.unwrap();
1003 assert_eq!(result, Some(payload));
1004
1005 client.delete_object(&key).await.unwrap();
1007 }
1008
1009 #[tokio::test]
1010 async fn test_put_object_if_match_rejects_stale_etag() {
1011 let client = create_test_client("test-if-match").await;
1012 let key = format!("if-match-{}", uuid::Uuid::new_v4());
1013
1014 client
1016 .put_object(&key, Bytes::from("version1"))
1017 .await
1018 .unwrap();
1019
1020 let (_, etag) = client
1022 .get_object_with_etag(&key)
1023 .await
1024 .unwrap()
1025 .expect("object should exist");
1026 let etag = etag.expect("should have etag");
1027
1028 client
1030 .put_object(&key, Bytes::from("version2"))
1031 .await
1032 .unwrap();
1033
1034 let won = client
1036 .put_object_if_match(&key, Bytes::from("version3"), &etag)
1037 .await
1038 .unwrap();
1039 assert!(!won, "conditional put with stale ETag should return false");
1040
1041 let data = client.get_object(&key).await.unwrap().unwrap();
1043 assert_eq!(data, Bytes::from("version2"));
1044
1045 client.delete_object(&key).await.unwrap();
1047 }
1048
1049 #[tokio::test]
1050 async fn test_put_object_if_match_accepts_current_etag() {
1051 let client = create_test_client("test-if-match-ok").await;
1052 let key = format!("if-match-ok-{}", uuid::Uuid::new_v4());
1053
1054 client
1055 .put_object(&key, Bytes::from("version1"))
1056 .await
1057 .unwrap();
1058
1059 let (_, etag) = client
1060 .get_object_with_etag(&key)
1061 .await
1062 .unwrap()
1063 .expect("object should exist");
1064 let etag = etag.expect("should have etag");
1065
1066 let won = client
1068 .put_object_if_match(&key, Bytes::from("version2"), &etag)
1069 .await
1070 .unwrap();
1071 assert!(won, "conditional put with current ETag should succeed");
1072
1073 let data = client.get_object(&key).await.unwrap().unwrap();
1074 assert_eq!(data, Bytes::from("version2"));
1075
1076 client.delete_object(&key).await.unwrap();
1078 }
1079}