1use crate::kernel::{FileKind, FilePayload, LogicalName, NameEncoding, NamespacePath};
7use crate::model::{CheckoutMode, GenerationSelector};
8use crate::{
9 AsyncAuthorityStore, AsyncObjectStore, ByteRange, CancellationToken, IdempotencyKey,
10 TransactionCommit, WorkBudget, Workspace, WorkspaceError,
11};
12use bytes::Bytes;
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Cursor;
15use thiserror::Error;
16
17const LIST_PAGE_ENTRIES: u32 = 256;
18const S3_ETAG_DOMAIN: &[u8] = b"acyclic-fs-s3-etag-v1\0";
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct S3ListOptions {
23 pub prefix: String,
25 pub delimiter: Option<char>,
27 pub start_after: Option<String>,
29 pub continuation: Option<S3ListCursor>,
32 pub maximum_keys: u32,
34 pub maximum_entries_examined: u32,
36}
37
38impl Default for S3ListOptions {
39 fn default() -> Self {
40 Self {
41 prefix: String::new(),
42 delimiter: Some('/'),
43 start_after: None,
44 continuation: None,
45 maximum_keys: 1_000,
46 maximum_entries_examined: 100_000,
47 }
48 }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct S3ObjectHead {
54 pub key: String,
56 pub content_length: u64,
58 pub etag: String,
60}
61
62pub type S3Object = S3ObjectHead;
64
65#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct S3List {
68 pub objects: Vec<S3Object>,
70 pub common_prefixes: Vec<String>,
72 pub next_continuation: Option<S3ListCursor>,
74 pub entries_examined: u32,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct S3ListCursor {
81 generation: crate::GenerationId,
82 after: String,
83 after_prefix: bool,
84 query: crate::Digest,
85}
86
87impl S3ListCursor {
88 #[must_use]
90 pub fn encode(&self) -> String {
91 format!(
92 "v2.{}.{}.{}.{}",
93 hex::encode(self.generation.digest().into_bytes()),
94 hex::encode(self.query.into_bytes()),
95 if self.after_prefix { "p" } else { "o" },
96 hex::encode(self.after.as_bytes())
97 )
98 }
99
100 pub fn decode(token: &str, maximum_key_bytes: u32) -> Result<Self, S3Error> {
107 let mut fields = token.split('.');
108 let version = fields.next().ok_or(S3Error::InvalidContinuation)?;
109 if version != "v2" {
110 return Err(S3Error::InvalidContinuation);
111 }
112 let generation = decode_fixed::<32>(fields.next())?;
113 let query = decode_fixed::<32>(fields.next())?;
114 let after_prefix = match fields.next() {
115 Some("o") => false,
116 Some("p") => true,
117 _ => return Err(S3Error::InvalidContinuation),
118 };
119 let after = fields.next().ok_or(S3Error::InvalidContinuation)?;
120 if fields.next().is_some() || after.len() > maximum_key_bytes as usize * 2 {
121 return Err(S3Error::InvalidContinuation);
122 }
123 let after = hex::decode(after).map_err(|_| S3Error::InvalidContinuation)?;
124 let after = String::from_utf8(after).map_err(|_| S3Error::InvalidContinuation)?;
125 if after.len() > maximum_key_bytes as usize {
126 return Err(S3Error::InvalidContinuation);
127 }
128 Ok(Self {
129 generation: crate::GenerationId::new(crate::Digest::from_bytes(generation)),
130 after,
131 after_prefix,
132 query: crate::Digest::from_bytes(query),
133 })
134 }
135}
136
137pub struct S3Workspace<A, O> {
139 workspace: Workspace<A, O>,
140}
141
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub struct S3MultipartOptions {
145 pub maximum_parts: u32,
147 pub maximum_part_bytes: u64,
149}
150
151impl Default for S3MultipartOptions {
152 fn default() -> Self {
153 Self {
154 maximum_parts: 10_000,
155 maximum_part_bytes: 5 * 1024 * 1024 * 1024,
156 }
157 }
158}
159
160pub struct S3MultipartUpload<A, O> {
163 transaction: crate::Transaction<A, O>,
164 key: String,
165 options: S3MultipartOptions,
166 parts: BTreeMap<u32, crate::StagedContent>,
167}
168
169impl<A, O> Clone for S3Workspace<A, O> {
170 fn clone(&self) -> Self {
171 Self {
172 workspace: self.workspace.clone(),
173 }
174 }
175}
176
177impl<A, O> Workspace<A, O> {
178 #[must_use]
180 pub fn s3(&self) -> S3Workspace<A, O> {
181 S3Workspace {
182 workspace: self.clone(),
183 }
184 }
185}
186
187impl<A: AsyncAuthorityStore, O: AsyncObjectStore> S3Workspace<A, O> {
188 pub async fn create_multipart_upload(
194 &self,
195 key: &str,
196 options: S3MultipartOptions,
197 idempotency_key: IdempotencyKey,
198 ) -> Result<S3MultipartUpload<A, O>, S3Error> {
199 validate_key(key)?;
200 if options.maximum_parts == 0 || options.maximum_part_bytes == 0 {
201 return Err(S3Error::InvalidRequest("multipart bounds must be positive"));
202 }
203 Ok(S3MultipartUpload {
204 transaction: self.workspace.begin_transaction(idempotency_key).await?,
205 key: key.to_owned(),
206 options,
207 parts: BTreeMap::new(),
208 })
209 }
210 pub async fn head_object(&self, key: &str) -> Result<S3ObjectHead, S3Error> {
216 let generation = self.workspace.head().await?;
217 self.head_object_in(generation, key).await
218 }
219
220 pub async fn head_object_at(
226 &self,
227 generation: &crate::Generation<A, O>,
228 key: &str,
229 ) -> Result<S3ObjectHead, S3Error> {
230 if generation.workspace_id() != self.workspace.id() {
231 return Err(S3Error::ForeignGeneration);
232 }
233 self.head_object_in(generation.clone(), key).await
234 }
235
236 async fn head_object_in(
237 &self,
238 generation: crate::Generation<A, O>,
239 key: &str,
240 ) -> Result<S3ObjectHead, S3Error> {
241 let mut checkout = self
242 .workspace
243 .engine_checkout(
244 GenerationSelector::Exact(generation.id()),
245 CheckoutMode::read_only_pinned(),
246 )
247 .await?;
248 let path = key_path(key, checkout.volume_config())?;
249 let record = checkout
250 .lookup_no_follow(&path, WorkBudget::UNBOUNDED, &CancellationToken::new())
251 .await
252 .map_err(WorkspaceError::engine)?
253 .value
254 .record
255 .ok_or(S3Error::NotFound)?;
256 let content_length = regular_bytes(&record.payload)?;
257 Ok(S3ObjectHead {
258 key: key.to_owned(),
259 content_length,
260 etag: etag(generation.id().digest().as_bytes(), key),
261 })
262 }
263
264 pub async fn get_object(&self, key: &str, maximum_bytes: u64) -> Result<Bytes, S3Error> {
270 validate_key(key)?;
271 self.workspace
272 .read(&absolute_key(key), maximum_bytes)
273 .await
274 .map_err(Into::into)
275 }
276
277 pub async fn get_object_range(&self, key: &str, range: ByteRange) -> Result<Bytes, S3Error> {
283 let generation = self.workspace.head().await?;
284 self.get_object_range_in(generation, key, range).await
285 }
286
287 pub async fn get_object_range_at(
293 &self,
294 generation: &crate::Generation<A, O>,
295 key: &str,
296 range: ByteRange,
297 ) -> Result<Bytes, S3Error> {
298 if generation.workspace_id() != self.workspace.id() {
299 return Err(S3Error::ForeignGeneration);
300 }
301 self.get_object_range_in(generation.clone(), key, range)
302 .await
303 }
304
305 async fn get_object_range_in(
306 &self,
307 generation: crate::Generation<A, O>,
308 key: &str,
309 range: ByteRange,
310 ) -> Result<Bytes, S3Error> {
311 let mut checkout = self
312 .workspace
313 .engine_checkout(
314 GenerationSelector::Exact(generation.id()),
315 CheckoutMode::read_only_pinned(),
316 )
317 .await?;
318 let path = key_path(key, checkout.volume_config())?;
319 checkout
320 .read_file_range(
321 &path,
322 range,
323 WorkBudget::UNBOUNDED,
324 &CancellationToken::new(),
325 )
326 .await
327 .map(|receipt| receipt.value.bytes)
328 .map_err(|failure| S3Error::Workspace(WorkspaceError::engine(failure)))
329 }
330
331 pub async fn put_object(
337 &self,
338 key: &str,
339 bytes: Bytes,
340 idempotency_key: IdempotencyKey,
341 ) -> Result<TransactionCommit<A, O>, S3Error> {
342 let mut transaction = self.workspace.begin_transaction(idempotency_key).await?;
343 create_parent_directories(&mut transaction, key).await?;
344 transaction.write(&absolute_key(key), bytes).await?;
345 transaction.commit().await.map_err(Into::into)
346 }
347
348 pub async fn write_staged(
357 &self,
358 transaction: &mut crate::Transaction<A, O>,
359 key: &str,
360 parts: &[crate::StagedContent],
361 ) -> Result<(), S3Error> {
362 if transaction.workspace_id() != self.workspace.id() {
363 return Err(S3Error::ForeignTransaction);
364 }
365 create_parent_directories(transaction, key).await?;
366 transaction.write_staged(&absolute_key(key), parts).await?;
367 Ok(())
368 }
369
370 pub async fn delete_object(
377 &self,
378 key: &str,
379 idempotency_key: IdempotencyKey,
380 ) -> Result<(), S3Error> {
381 validate_key(key)?;
382 let mut transaction = self.workspace.begin_transaction(idempotency_key).await?;
383 let mutated = match transaction.remove(&absolute_key(key)).await {
384 Ok(()) => true,
385 Err(WorkspaceError::NotFound) => false,
386 Err(error) => return Err(error.into()),
387 };
388 if mutated {
389 complete_delete(&transaction.commit().await?)
390 } else {
391 Ok(())
392 }
393 }
394
395 pub async fn delete_objects(
402 &self,
403 keys: &[String],
404 idempotency_key: IdempotencyKey,
405 ) -> Result<(), S3Error> {
406 if keys.is_empty() {
407 return Err(S3Error::InvalidRequest("delete set is empty"));
408 }
409 let mut transaction = self.workspace.begin_transaction(idempotency_key).await?;
410 let mut mutated = false;
411 for key in keys {
412 validate_key(key)?;
413 match transaction.remove(&absolute_key(key)).await {
414 Ok(()) => mutated = true,
415 Err(WorkspaceError::NotFound) => {}
416 Err(error) => return Err(error.into()),
417 }
418 }
419 if mutated {
420 complete_delete(&transaction.commit().await?)
421 } else {
422 Ok(())
423 }
424 }
425
426 pub async fn copy_object(
432 &self,
433 source: &str,
434 destination: &str,
435 idempotency_key: IdempotencyKey,
436 ) -> Result<TransactionCommit<A, O>, S3Error> {
437 validate_key(source)?;
438 let mut transaction = self.workspace.begin_transaction(idempotency_key).await?;
439 create_parent_directories(&mut transaction, destination).await?;
440 transaction
441 .copy(&absolute_key(source), &absolute_key(destination))
442 .await?;
443 transaction.commit().await.map_err(Into::into)
444 }
445
446 pub async fn list_objects(&self, options: S3ListOptions) -> Result<S3List, S3Error> {
453 self.list_objects_in(None, options).await
454 }
455
456 pub async fn list_objects_at(
462 &self,
463 generation: &crate::Generation<A, O>,
464 options: S3ListOptions,
465 ) -> Result<S3List, S3Error> {
466 if generation.workspace_id() != self.workspace.id() {
467 return Err(S3Error::ForeignGeneration);
468 }
469 self.list_objects_in(Some(generation.clone()), options)
470 .await
471 }
472
473 async fn list_objects_in(
474 &self,
475 selected: Option<crate::Generation<A, O>>,
476 options: S3ListOptions,
477 ) -> Result<S3List, S3Error> {
478 validate_list_options(&options)?;
479 let query = list_query_digest(&options);
480 let generation = match options.continuation.as_ref() {
481 Some(cursor) => {
482 if cursor.query != query {
483 return Err(S3Error::InvalidContinuation);
484 }
485 self.workspace.generation(cursor.generation).await?
486 }
487 None => match selected {
488 Some(generation) => generation,
489 None => self.workspace.head().await?,
490 },
491 };
492 let mut checkout = self
493 .workspace
494 .engine_checkout(
495 GenerationSelector::Exact(generation.id()),
496 CheckoutMode::read_only_pinned(),
497 )
498 .await?;
499 let limits = checkout.volume_config().limits;
500 let (frontier, frontier_key, frontier_name_prefix) =
501 listing_frontier(&options.prefix, checkout.volume_config())?;
502 if !frontier.components().is_empty() {
503 let record = checkout
504 .lookup_no_follow(&frontier, WorkBudget::UNBOUNDED, &CancellationToken::new())
505 .await
506 .map_err(|failure| S3Error::Workspace(WorkspaceError::engine(failure)))?
507 .value
508 .record;
509 if !record.is_some_and(|record| record.kind == FileKind::Directory) {
510 return Ok(empty_list());
511 }
512 }
513 list_objects_lazy(
514 &mut checkout,
515 &options,
516 limits,
517 generation.id(),
518 query,
519 frontier,
520 frontier_key,
521 (!frontier_name_prefix.is_empty()).then_some(frontier_name_prefix),
522 )
523 .await
524 }
525}
526
527enum ListingTask {
528 Directory {
529 path: NamespacePath,
530 key_prefix: String,
531 name_prefix: Option<String>,
532 after: Option<LogicalName>,
533 inclusive: bool,
534 },
535 Object {
536 key: String,
537 payload: FilePayload,
538 },
539}
540
541fn queue_listing_task(
542 pending: &mut BTreeMap<(String, u64), ListingTask>,
543 sequence: &mut u64,
544 lower_key: String,
545 task: ListingTask,
546) -> Result<(), S3Error> {
547 let next = sequence.checked_add(1).ok_or(S3Error::ListLimit)?;
548 pending.insert((lower_key, *sequence), task);
549 *sequence = next;
550 Ok(())
551}
552
553fn direct_child_seek(
557 options: &S3ListOptions,
558 limits: crate::model::VolumeLimits,
559 frontier_key: &str,
560) -> Option<(LogicalName, bool)> {
561 let Some(cursor) = &options.continuation else {
562 return None;
563 };
564 let relative = cursor.after.strip_prefix(frontier_key)?;
565 let name = if !cursor.after_prefix && !relative.contains('/') {
566 relative
567 } else if cursor.after_prefix && options.delimiter == Some('/') {
568 let name = relative.strip_suffix('/')?;
569 if name.contains('/') {
570 return None;
571 }
572 name
573 } else {
574 return None;
575 };
576 if name
577 .chars()
578 .skip(1)
579 .any(|character| (character as u32) < u32::from(b'/'))
580 {
581 return None;
582 }
583 LogicalName::new(
584 NameEncoding::Utf8,
585 name.as_bytes().to_vec(),
586 limits.maximum_component_bytes,
587 )
588 .ok()
589 .map(|name| (name, !cursor.after_prefix))
590}
591
592#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
596async fn list_objects_lazy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
597 checkout: &mut crate::Checkout<A, O>,
598 options: &S3ListOptions,
599 limits: crate::model::VolumeLimits,
600 generation: crate::GenerationId,
601 query: crate::Digest,
602 frontier: NamespacePath,
603 frontier_key: String,
604 frontier_name_prefix: Option<String>,
605) -> Result<S3List, S3Error> {
606 let mut pending = BTreeMap::new();
607 let mut sequence = 0_u64;
608 let seek = direct_child_seek(options, limits, &frontier_key);
609 queue_listing_task(
610 &mut pending,
611 &mut sequence,
612 frontier_key.clone(),
613 ListingTask::Directory {
614 path: frontier,
615 key_prefix: frontier_key,
616 name_prefix: frontier_name_prefix,
617 after: seek.as_ref().map(|(name, _)| name.clone()),
618 inclusive: seek.as_ref().is_some_and(|(_, inclusive)| *inclusive),
619 },
620 )?;
621 let maximum = usize::try_from(options.maximum_keys).map_err(|_| S3Error::ListLimit)?;
622 let mut result = empty_list();
623 let mut last: Option<(String, bool)> = None;
624 let mut examined = 0_u32;
625 while let Some((_, task)) = pending.pop_first() {
626 match task {
627 ListingTask::Directory {
628 path,
629 key_prefix,
630 name_prefix,
631 after,
632 inclusive,
633 } => {
634 let remaining = options.maximum_entries_examined - examined;
635 if remaining == 0 {
636 return Err(S3Error::ListLimit);
637 }
638 let page_limit = LIST_PAGE_ENTRIES
639 .min(remaining)
640 .min(options.maximum_keys.saturating_add(1));
641 let page = if inclusive {
642 checkout
643 .list_directory_records_at_or_after(
644 &path,
645 after.as_ref().ok_or(S3Error::InvalidContinuation)?,
646 page_limit,
647 WorkBudget::UNBOUNDED,
648 &CancellationToken::new(),
649 )
650 .await
651 } else {
652 checkout
653 .list_directory_records(
654 &path,
655 after.as_ref(),
656 page_limit,
657 WorkBudget::UNBOUNDED,
658 &CancellationToken::new(),
659 )
660 .await
661 }
662 .map_err(|failure| S3Error::Workspace(WorkspaceError::engine(failure)))?
663 .value;
664 examined = examined
665 .checked_add(u32::try_from(page.entries.len()).map_err(|_| S3Error::ListLimit)?)
666 .ok_or(S3Error::ListLimit)?;
667 let continuation = if page.has_more {
668 let last_name = page
669 .entries
670 .last()
671 .ok_or(S3Error::InvalidObject)?
672 .name
673 .clone();
674 let lower_key = format!("{key_prefix}{}", utf8_name(&last_name)?);
675 Some((lower_key, last_name))
676 } else {
677 None
678 };
679 for entry in page.entries {
680 let name = utf8_name(&entry.name)?;
681 if name_prefix
682 .as_ref()
683 .is_some_and(|prefix| !name.starts_with(prefix))
684 {
685 continue;
686 }
687 let key = format!("{key_prefix}{name}");
688 match entry.record.kind {
689 FileKind::Directory => {
690 let mut components = path.components().to_vec();
691 components.push(entry.name);
692 let child = NamespacePath::new(components, limits)
693 .map_err(WorkspaceError::path)?;
694 let child_key = format!("{key}/");
695 queue_listing_task(
696 &mut pending,
697 &mut sequence,
698 child_key.clone(),
699 ListingTask::Directory {
700 path: child,
701 key_prefix: child_key,
702 name_prefix: None,
703 after: None,
704 inclusive: false,
705 },
706 )?;
707 }
708 FileKind::Regular => queue_listing_task(
709 &mut pending,
710 &mut sequence,
711 key.clone(),
712 ListingTask::Object {
713 key,
714 payload: entry.record.payload,
715 },
716 )?,
717 _ => {}
718 }
719 }
720 if let Some((lower_key, last_name)) = continuation {
721 queue_listing_task(
722 &mut pending,
723 &mut sequence,
724 lower_key,
725 ListingTask::Directory {
726 path,
727 key_prefix,
728 name_prefix,
729 after: Some(last_name),
730 inclusive: false,
731 },
732 )?;
733 }
734 }
735 ListingTask::Object { key, payload } => {
736 let Some(selected) = select_key(&key, options) else {
737 continue;
738 };
739 let (selected_key, is_prefix) = match &selected {
740 SelectedKey::Object => (key.as_str(), false),
741 SelectedKey::Prefix(prefix) => (prefix.as_str(), true),
742 };
743 if is_prefix
744 && last.as_ref().is_some_and(|(last_key, last_prefix)| {
745 *last_prefix && last_key == selected_key
746 })
747 {
748 continue;
749 }
750 if result.objects.len() + result.common_prefixes.len() == maximum {
751 result.next_continuation = last.map(|(after, after_prefix)| S3ListCursor {
752 generation,
753 after,
754 after_prefix,
755 query,
756 });
757 break;
758 }
759 let selected_identity = (selected_key.to_owned(), is_prefix);
760 match selected {
761 SelectedKey::Object => result.objects.push(S3Object {
762 key: key.clone(),
763 content_length: regular_bytes(&payload)?,
764 etag: etag(generation.digest().as_bytes(), &key),
765 }),
766 SelectedKey::Prefix(prefix) => result.common_prefixes.push(prefix),
767 }
768 last = Some(selected_identity);
769 }
770 }
771 }
772 result.entries_examined = examined;
773 Ok(result)
774}
775
776fn empty_list() -> S3List {
777 S3List {
778 objects: Vec::new(),
779 common_prefixes: Vec::new(),
780 next_continuation: None,
781 entries_examined: 0,
782 }
783}
784
785fn listing_frontier(
786 prefix: &str,
787 config: crate::model::VolumeConfig,
788) -> Result<(NamespacePath, String, String), S3Error> {
789 let limits = config.limits;
790 let Some(separator) = prefix.rfind('/') else {
791 return Ok((
792 NamespacePath::new(Vec::new(), limits).map_err(WorkspaceError::path)?,
793 String::new(),
794 prefix.to_owned(),
795 ));
796 };
797 let directory = prefix.get(..separator).ok_or(S3Error::InvalidRequest(
798 "listing prefix split point is invalid",
799 ))?;
800 let path = if directory.is_empty() {
801 NamespacePath::new(Vec::new(), limits).map_err(WorkspaceError::path)?
802 } else {
803 key_path(directory, config)?
804 };
805 Ok((
806 path,
807 prefix
808 .get(..=separator)
809 .ok_or(S3Error::InvalidRequest(
810 "listing prefix split point is invalid",
811 ))?
812 .to_owned(),
813 prefix
814 .get(separator + 1..)
815 .ok_or(S3Error::InvalidRequest(
816 "listing prefix split point is invalid",
817 ))?
818 .to_owned(),
819 ))
820}
821
822impl<A: AsyncAuthorityStore, O: AsyncObjectStore> S3MultipartUpload<A, O> {
823 pub async fn upload_part(&mut self, part_number: u32, bytes: Bytes) -> Result<(), S3Error> {
829 if part_number == 0 {
830 return Err(S3Error::InvalidRequest("part number must be positive"));
831 }
832 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > self.options.maximum_part_bytes {
833 return Err(S3Error::MultipartLimit);
834 }
835 if !self.parts.contains_key(&part_number)
836 && u32::try_from(self.parts.len()).unwrap_or(u32::MAX) >= self.options.maximum_parts
837 {
838 return Err(S3Error::MultipartLimit);
839 }
840 let maximum = u64::try_from(bytes.len())
841 .unwrap_or(u64::MAX)
842 .saturating_add(1);
843 let mut source = Cursor::new(bytes);
844 let staged = self.transaction.stage_content(&mut source, maximum).await?;
845 self.parts.insert(part_number, staged);
846 Ok(())
847 }
848
849 pub async fn complete(
856 &mut self,
857 ordered_parts: &[u32],
858 ) -> Result<TransactionCommit<A, O>, S3Error> {
859 if ordered_parts.is_empty() {
860 return Err(S3Error::InvalidRequest("multipart completion is empty"));
861 }
862 let mut seen = BTreeSet::new();
863 let mut staged = Vec::new();
864 staged
865 .try_reserve_exact(ordered_parts.len())
866 .map_err(|_| S3Error::MultipartLimit)?;
867 for part_number in ordered_parts {
868 if !seen.insert(*part_number) {
869 return Err(S3Error::InvalidRequest("multipart part is duplicated"));
870 }
871 staged.push(
872 *self
873 .parts
874 .get(part_number)
875 .ok_or(S3Error::MissingPart(*part_number))?,
876 );
877 }
878 create_parent_directories(&mut self.transaction, &self.key).await?;
879 self.transaction
880 .write_staged(&absolute_key(&self.key), &staged)
881 .await?;
882 self.transaction.commit().await.map_err(Into::into)
883 }
884
885 pub fn abort(self) {}
887}
888
889enum SelectedKey {
890 Object,
891 Prefix(String),
892}
893
894fn select_key(key: &str, options: &S3ListOptions) -> Option<SelectedKey> {
895 let remainder = key.strip_prefix(options.prefix.as_str())?;
896 let selected = if options.delimiter == Some('/')
897 && let Some(index) = remainder.find('/')
898 {
899 SelectedKey::Prefix(format!("{}{}", options.prefix, remainder.get(..=index)?))
900 } else {
901 SelectedKey::Object
902 };
903 let (selected_key, selected_prefix) = match &selected {
904 SelectedKey::Object => (key, false),
905 SelectedKey::Prefix(prefix) => (prefix.as_str(), true),
906 };
907 if let Some(cursor) = &options.continuation {
908 if (selected_key, selected_prefix) <= (cursor.after.as_str(), cursor.after_prefix) {
909 return None;
910 }
911 } else if options
912 .start_after
913 .as_ref()
914 .is_some_and(|after| selected_key <= after.as_str())
915 {
916 return None;
917 }
918 Some(selected)
919}
920
921fn list_query_digest(options: &S3ListOptions) -> crate::Digest {
922 let mut hasher = blake3::Hasher::new();
923 hasher.update(b"acyclic-fs-s3-list-query-v1\0");
924 hasher.update(&(options.prefix.len() as u64).to_le_bytes());
925 hasher.update(options.prefix.as_bytes());
926 hasher.update(&[options.delimiter.map_or(0, |value| value as u8)]);
927 crate::Digest::from_bytes(*hasher.finalize().as_bytes())
928}
929
930fn decode_fixed<const N: usize>(value: Option<&str>) -> Result<[u8; N], S3Error> {
931 let value = value.ok_or(S3Error::InvalidContinuation)?;
932 if value.len() != N * 2 {
933 return Err(S3Error::InvalidContinuation);
934 }
935 let decoded = hex::decode(value).map_err(|_| S3Error::InvalidContinuation)?;
936 decoded.try_into().map_err(|_| S3Error::InvalidContinuation)
937}
938
939fn validate_list_options(options: &S3ListOptions) -> Result<(), S3Error> {
940 if options.maximum_keys == 0 || options.maximum_entries_examined == 0 {
941 return Err(S3Error::InvalidRequest("list bounds must be positive"));
942 }
943 if !matches!(options.delimiter, None | Some('/')) {
944 return Err(S3Error::InvalidRequest("only '/' is a supported delimiter"));
945 }
946 if options.prefix.starts_with('/') || options.prefix.contains("//") {
947 return Err(S3Error::InvalidKey);
948 }
949 if options
950 .start_after
951 .as_ref()
952 .is_some_and(|value| value.len() > 32 * 1024)
953 {
954 return Err(S3Error::InvalidRequest("start marker is too large"));
955 }
956 Ok(())
957}
958
959pub(crate) async fn create_parent_directories<A: AsyncAuthorityStore, O: AsyncObjectStore>(
960 transaction: &mut crate::Transaction<A, O>,
961 key: &str,
962) -> Result<(), S3Error> {
963 validate_key(key)?;
964 if let Some((parent, _)) = key.rsplit_once('/')
965 && !parent.is_empty()
966 {
967 transaction.create_dir_all(&format!("/{parent}")).await?;
968 }
969 Ok(())
970}
971
972fn key_path(key: &str, config: crate::model::VolumeConfig) -> Result<NamespacePath, S3Error> {
973 validate_key(key)?;
974 crate::workspace::customer_path(&absolute_key(key), config).map_err(Into::into)
975}
976
977fn validate_key(key: &str) -> Result<(), S3Error> {
978 if key.is_empty() || key.starts_with('/') || key.ends_with('/') || key.contains("//") {
979 return Err(S3Error::InvalidKey);
980 }
981 Ok(())
982}
983
984fn absolute_key(key: &str) -> String {
985 format!("/{key}")
986}
987
988fn complete_delete<A, O>(outcome: &TransactionCommit<A, O>) -> Result<(), S3Error> {
989 match outcome {
990 TransactionCommit::Committed(_) | TransactionCommit::AlreadyCommitted(_) => Ok(()),
991 TransactionCommit::Conflict { .. } | TransactionCommit::Fenced => {
992 Err(S3Error::WriteConflict)
993 }
994 TransactionCommit::IdempotencyConflict => Err(S3Error::IdempotencyConflict),
995 }
996}
997
998fn utf8_name(name: &LogicalName) -> Result<&str, S3Error> {
999 if name.encoding() != NameEncoding::Utf8 {
1000 return Err(S3Error::UnsupportedNamespace);
1001 }
1002 std::str::from_utf8(name.as_bytes()).map_err(|_| S3Error::UnsupportedNamespace)
1003}
1004
1005fn regular_bytes(payload: &FilePayload) -> Result<u64, S3Error> {
1006 match payload {
1007 FilePayload::InlineRegular(bytes) => {
1008 u64::try_from(bytes.as_bytes().len()).map_err(|_| S3Error::InvalidObject)
1009 }
1010 FilePayload::Regular { logical_bytes, .. } => Ok(*logical_bytes),
1011 _ => Err(S3Error::NotRegularFile),
1012 }
1013}
1014
1015fn etag(generation: &[u8; 32], key: &str) -> String {
1016 let mut hasher = blake3::Hasher::new();
1017 hasher.update(S3_ETAG_DOMAIN);
1018 hasher.update(generation);
1019 hasher.update(key.as_bytes());
1020 format!("\"{}\"", hasher.finalize().to_hex())
1021}
1022
1023#[derive(Debug, Error)]
1025pub enum S3Error {
1026 #[error(transparent)]
1028 Workspace(#[from] WorkspaceError),
1029 #[error("invalid S3 object key")]
1031 InvalidKey,
1032 #[error("invalid S3 request: {0}")]
1034 InvalidRequest(&'static str),
1035 #[error("S3 object does not exist")]
1037 NotFound,
1038 #[error("S3 path is not a regular file")]
1040 NotRegularFile,
1041 #[error("workspace namespace is not representable by the S3 view")]
1043 UnsupportedNamespace,
1044 #[error("authenticated S3 object shape is invalid")]
1046 InvalidObject,
1047 #[error("S3 listing exceeded its authenticated entry bound")]
1049 ListLimit,
1050 #[error("S3 multipart upload exceeded its bound")]
1052 MultipartLimit,
1053 #[error("S3 multipart part {0} is absent")]
1055 MissingPart(u32),
1056 #[error("S3 mutation conflicted with a concurrent writer")]
1058 WriteConflict,
1059 #[error("S3 idempotency key was reused for different input")]
1061 IdempotencyConflict,
1062 #[error("S3 generation belongs to another workspace")]
1064 ForeignGeneration,
1065 #[error("S3 transaction belongs to another workspace")]
1067 ForeignTransaction,
1068 #[error("invalid S3 listing continuation")]
1070 InvalidContinuation,
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use crate::Fs;
1077
1078 #[test]
1079 fn listing_cursor_retains_object_prefix_identity() -> Result<(), S3Error> {
1080 let generation = crate::GenerationId::new(crate::Digest::from_bytes([1; 32]));
1081 let query = crate::Digest::from_bytes([2; 32]);
1082 let token = S3ListCursor {
1083 generation,
1084 after: "a/".to_owned(),
1085 after_prefix: false,
1086 query,
1087 }
1088 .encode();
1089 let cursor = S3ListCursor::decode(&token, 1_024)?;
1090 let options = S3ListOptions {
1091 continuation: Some(cursor),
1092 ..S3ListOptions::default()
1093 };
1094 assert!(matches!(
1095 select_key("a/b", &options),
1096 Some(SelectedKey::Prefix(prefix)) if prefix == "a/"
1097 ));
1098 assert!(matches!(
1099 select_key("a//b", &options),
1100 Some(SelectedKey::Prefix(prefix)) if prefix == "a/"
1101 ));
1102 assert!(matches!(
1103 S3ListCursor::decode(
1104 &format!(
1105 "v1.{}.{}.{}",
1106 "01".repeat(32),
1107 "02".repeat(32),
1108 hex::encode("a/")
1109 ),
1110 1_024
1111 ),
1112 Err(S3Error::InvalidContinuation)
1113 ));
1114 Ok(())
1115 }
1116
1117 #[tokio::test]
1118 async fn s3_view_is_the_same_generation_and_copy_is_body_free()
1119 -> Result<(), Box<dyn std::error::Error>> {
1120 let fs = Fs::memory();
1121 let workspace = fs.create_workspace("s3").await?;
1122 let s3 = workspace.s3();
1123 let other = fs.create_workspace("another-s3-workspace").await?;
1124 let mut foreign = other.begin_transaction(IdempotencyKey::new()).await?;
1125 assert!(matches!(
1126 s3.write_staged(&mut foreign, "foreign", &[]).await,
1127 Err(S3Error::ForeignTransaction)
1128 ));
1129 s3.put_object(
1130 "src/a.bin",
1131 Bytes::from_static(b"abcdef"),
1132 IdempotencyKey::new(),
1133 )
1134 .await?;
1135 s3.put_object(
1136 "root.bin",
1137 Bytes::from_static(b"root"),
1138 IdempotencyKey::new(),
1139 )
1140 .await?;
1141 assert_eq!(
1142 s3.get_object_range(
1143 "src/a.bin",
1144 ByteRange {
1145 offset: 2,
1146 length: 3
1147 }
1148 )
1149 .await?,
1150 b"cde".as_slice()
1151 );
1152 s3.copy_object("src/a.bin", "copy/a.bin", IdempotencyKey::new())
1153 .await?;
1154 assert_eq!(
1155 workspace.read("/copy/a.bin", 16).await?,
1156 b"abcdef".as_slice()
1157 );
1158
1159 let listing = Box::pin(s3.list_objects(S3ListOptions::default())).await?;
1160 assert_eq!(listing.common_prefixes, vec!["copy/", "src/"]);
1161 assert_eq!(
1162 listing
1163 .objects
1164 .iter()
1165 .map(|object| object.key.as_str())
1166 .collect::<Vec<_>>(),
1167 vec!["root.bin"]
1168 );
1169 let flat = Box::pin(s3.list_objects(S3ListOptions {
1170 delimiter: None,
1171 ..S3ListOptions::default()
1172 }))
1173 .await?;
1174 assert_eq!(
1175 flat.objects
1176 .iter()
1177 .map(|object| object.key.as_str())
1178 .collect::<Vec<_>>(),
1179 vec!["copy/a.bin", "root.bin", "src/a.bin"]
1180 );
1181 s3.delete_objects(
1182 &["copy/a.bin".to_owned(), "root.bin".to_owned()],
1183 IdempotencyKey::new(),
1184 )
1185 .await?;
1186 assert!(matches!(
1187 s3.head_object("root.bin").await,
1188 Err(S3Error::NotFound)
1189 ));
1190 s3.delete_objects(
1191 &["copy/a.bin".to_owned(), "root.bin".to_owned()],
1192 IdempotencyKey::new(),
1193 )
1194 .await?;
1195 s3.delete_object("never-existed", IdempotencyKey::new())
1196 .await?;
1197 Ok(())
1198 }
1199
1200 #[tokio::test]
1201 async fn listing_is_bounded_and_pagination_is_stable() -> Result<(), Box<dyn std::error::Error>>
1202 {
1203 let fs = Fs::memory();
1204 let workspace = fs.create_workspace("pages").await?;
1205 let s3 = workspace.s3();
1206 for key in ["a", "b", "c"] {
1207 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1208 .await?;
1209 }
1210 let first = Box::pin(s3.list_objects(S3ListOptions {
1211 maximum_keys: 2,
1212 ..S3ListOptions::default()
1213 }))
1214 .await?;
1215 assert_eq!(
1216 first
1217 .next_continuation
1218 .as_ref()
1219 .map(|cursor| cursor.after.as_str()),
1220 Some("b")
1221 );
1222 let token = first
1223 .next_continuation
1224 .as_ref()
1225 .ok_or("first listing page has no continuation")?
1226 .encode();
1227 let continuation = S3ListCursor::decode(&token, 32 * 1024)?;
1228 s3.put_object("aa", Bytes::from_static(b"new"), IdempotencyKey::new())
1229 .await?;
1230 let second = Box::pin(s3.list_objects(S3ListOptions {
1231 continuation: Some(continuation),
1232 ..S3ListOptions::default()
1233 }))
1234 .await?;
1235 assert_eq!(
1236 second
1237 .objects
1238 .iter()
1239 .map(|object| object.key.as_str())
1240 .collect::<Vec<_>>(),
1241 vec!["c"]
1242 );
1243 assert!(matches!(
1244 S3ListCursor::decode("v1.invalid", 32 * 1024),
1245 Err(S3Error::InvalidContinuation)
1246 ));
1247 assert!(matches!(
1248 Box::pin(s3.list_objects(S3ListOptions {
1249 prefix: "different".to_owned(),
1250 continuation: first.next_continuation,
1251 ..S3ListOptions::default()
1252 }))
1253 .await,
1254 Err(S3Error::InvalidContinuation)
1255 ));
1256 assert!(matches!(
1257 Box::pin(s3.list_objects(S3ListOptions {
1258 maximum_entries_examined: 1,
1259 ..S3ListOptions::default()
1260 }))
1261 .await,
1262 Err(S3Error::ListLimit)
1263 ));
1264 Ok(())
1265 }
1266
1267 #[tokio::test]
1268 async fn delimiter_pages_preserve_order_and_exact_resume()
1269 -> Result<(), Box<dyn std::error::Error>> {
1270 let fs = Fs::memory();
1271 let workspace = fs.create_workspace("delimiter-pages").await?;
1272 let s3 = workspace.s3();
1273 for key in ["a/one", "a/two", "b/one", "c"] {
1274 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1275 .await?;
1276 }
1277 let mut options = S3ListOptions {
1278 delimiter: Some('/'),
1279 maximum_keys: 1,
1280 ..S3ListOptions::default()
1281 };
1282 let mut entries = Vec::new();
1283 let mut complete = false;
1284 for _ in 0..4 {
1285 let page = s3.list_objects(options.clone()).await?;
1286 entries.extend(page.objects.into_iter().map(|object| (object.key, false)));
1287 entries.extend(
1288 page.common_prefixes
1289 .into_iter()
1290 .map(|prefix| (prefix, true)),
1291 );
1292 let Some(cursor) = page.next_continuation else {
1293 complete = true;
1294 break;
1295 };
1296 options.continuation = Some(S3ListCursor::decode(&cursor.encode(), 1_024)?);
1297 }
1298 assert!(complete, "listing did not terminate within four pages");
1299 assert_eq!(
1300 entries,
1301 [
1302 ("a/".to_owned(), true),
1303 ("b/".to_owned(), true),
1304 ("c".to_owned(), false)
1305 ]
1306 );
1307 Ok(())
1308 }
1309
1310 #[tokio::test]
1311 async fn listing_pages_use_flat_key_order_without_scanning_every_entry()
1312 -> Result<(), Box<dyn std::error::Error>> {
1313 let fs = Fs::memory();
1314 let workspace = fs.create_workspace("lazy-listing").await?;
1315 let s3 = workspace.s3();
1316 for key in ["a/x", "a-", "a.", "a0", "b", "z"] {
1317 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1318 .await?;
1319 }
1320 for index in 0..32 {
1321 s3.put_object(
1322 &format!("zz{index:02}"),
1323 Bytes::from_static(b"x"),
1324 IdempotencyKey::new(),
1325 )
1326 .await?;
1327 }
1328 for (delimiter, expected) in [
1329 (None, ["a-", "a.", "a/x", "a0", "b", "z"]),
1330 (Some('/'), ["a-", "a.", "a/", "a0", "b", "z"]),
1331 ] {
1332 let mut options = S3ListOptions {
1333 delimiter,
1334 maximum_keys: 1,
1335 ..S3ListOptions::default()
1336 };
1337 let mut observed = Vec::new();
1338 for page_index in 0..6 {
1339 let page = s3.list_objects(options.clone()).await?;
1340 if page_index == 0 {
1341 assert!(page.entries_examined < 38);
1342 }
1343 observed.extend(page.objects.into_iter().map(|object| object.key));
1344 observed.extend(page.common_prefixes);
1345 options.continuation = page.next_continuation;
1346 }
1347 assert_eq!(observed, expected);
1348 }
1349 Ok(())
1350 }
1351
1352 #[tokio::test]
1353 async fn listing_matches_an_independent_key_model() -> Result<(), Box<dyn std::error::Error>> {
1354 let fs = Fs::memory();
1355 let workspace = fs.create_workspace("listing-model").await?;
1356 let s3 = workspace.s3();
1357 let keys = [
1358 "a/x",
1359 "a.",
1360 "a-",
1361 "a0",
1362 "b",
1363 "z",
1364 "zz/one",
1365 "zz/two",
1366 "ünicode/α",
1367 ];
1368 for key in keys {
1369 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1370 .await?;
1371 }
1372 for prefix in ["", "a", "a/", "zz", "ü"] {
1373 for delimiter in [None, Some('/')] {
1374 let expected = keys
1375 .iter()
1376 .filter_map(|key| {
1377 let suffix = key.strip_prefix(prefix)?;
1378 Some(match (delimiter, suffix.split_once('/')) {
1379 (Some('/'), Some((head, _))) => (format!("{prefix}{head}/"), true),
1380 _ => ((*key).to_owned(), false),
1381 })
1382 })
1383 .collect::<BTreeSet<_>>();
1384 for maximum_keys in [1, 2, 4] {
1385 let mut options = S3ListOptions {
1386 prefix: prefix.to_owned(),
1387 delimiter,
1388 maximum_keys,
1389 ..S3ListOptions::default()
1390 };
1391 let mut actual = Vec::new();
1392 for _ in 0..=expected.len() {
1393 let page = s3.list_objects(options.clone()).await?;
1394 let mut entries = page
1395 .objects
1396 .into_iter()
1397 .map(|object| (object.key, false))
1398 .chain(page.common_prefixes.into_iter().map(|name| (name, true)))
1399 .collect::<Vec<_>>();
1400 entries.sort();
1401 actual.extend(entries);
1402 let Some(cursor) = page.next_continuation else {
1403 break;
1404 };
1405 options.continuation = Some(S3ListCursor::decode(&cursor.encode(), 1_024)?);
1406 }
1407 assert_eq!(actual, expected.iter().cloned().collect::<Vec<_>>());
1408 }
1409 }
1410 }
1411 Ok(())
1412 }
1413
1414 #[tokio::test]
1415 async fn listing_uses_fetched_witness_before_requesting_another_page()
1416 -> Result<(), Box<dyn std::error::Error>> {
1417 let fs = Fs::memory();
1418 let workspace = fs.create_workspace("listing-witness").await?;
1419 let s3 = workspace.s3();
1420 for key in ["a", "b", "c", "d", "p/x"] {
1421 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1422 .await?;
1423 }
1424 let first = s3
1425 .list_objects(S3ListOptions {
1426 delimiter: None,
1427 maximum_keys: 1,
1428 maximum_entries_examined: 2,
1429 ..S3ListOptions::default()
1430 })
1431 .await?;
1432 assert_eq!(
1433 first
1434 .objects
1435 .iter()
1436 .map(|item| item.key.as_str())
1437 .collect::<Vec<_>>(),
1438 ["a"]
1439 );
1440 assert_eq!(first.entries_examined, 2);
1441 assert!(first.next_continuation.is_some());
1442 let two = s3
1443 .list_objects(S3ListOptions {
1444 delimiter: Some('/'),
1445 maximum_keys: 2,
1446 maximum_entries_examined: 3,
1447 ..S3ListOptions::default()
1448 })
1449 .await?;
1450 assert_eq!(
1451 two.objects
1452 .iter()
1453 .map(|item| item.key.as_str())
1454 .collect::<Vec<_>>(),
1455 ["a", "b"]
1456 );
1457 assert_eq!(two.entries_examined, 3);
1458 assert!(two.next_continuation.is_some());
1459
1460 let workspace = fs.create_workspace("listing-prefix-witness").await?;
1461 let s3 = workspace.s3();
1462 for key in ["a", "b/x", "c", "d"] {
1463 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1464 .await?;
1465 }
1466 let with_prefix = s3
1467 .list_objects(S3ListOptions {
1468 maximum_keys: 2,
1469 maximum_entries_examined: 4,
1470 ..S3ListOptions::default()
1471 })
1472 .await?;
1473 assert_eq!(
1474 with_prefix
1475 .objects
1476 .iter()
1477 .map(|item| item.key.as_str())
1478 .collect::<Vec<_>>(),
1479 ["a"]
1480 );
1481 assert_eq!(with_prefix.common_prefixes, ["b/"]);
1482 assert_eq!(with_prefix.entries_examined, 4);
1483 assert!(with_prefix.next_continuation.is_some());
1484 Ok(())
1485 }
1486
1487 #[tokio::test]
1488 async fn flat_continuations_seek_without_replaying_earlier_pages()
1489 -> Result<(), Box<dyn std::error::Error>> {
1490 let fs = Fs::memory();
1491 let workspace = fs.create_workspace("flat-seek").await?;
1492 let s3 = workspace.s3();
1493 for index in 0..32 {
1494 s3.put_object(
1495 &format!("key{index:03}"),
1496 Bytes::from_static(b"x"),
1497 IdempotencyKey::new(),
1498 )
1499 .await?;
1500 }
1501 let mut options = S3ListOptions {
1502 delimiter: None,
1503 maximum_keys: 1,
1504 maximum_entries_examined: 3,
1505 ..S3ListOptions::default()
1506 };
1507 for index in 0..32 {
1508 let page = s3.list_objects(options.clone()).await?;
1509 assert!(page.entries_examined <= 3);
1510 assert_eq!(page.objects.len(), 1);
1511 assert_eq!(page.objects[0].key, format!("key{index:03}"));
1512 options.continuation = page.next_continuation;
1513 }
1514 assert!(options.continuation.is_none());
1515 Ok(())
1516 }
1517
1518 #[tokio::test]
1519 async fn object_cursor_seeks_past_dense_predecessor_keys()
1520 -> Result<(), Box<dyn std::error::Error>> {
1521 let fs = Fs::memory();
1522 let workspace = fs.create_workspace("dense-list-cursor").await?;
1523 let s3 = workspace.s3();
1524 for index in 0..32 {
1525 s3.put_object(
1526 &format!("a9{index:03}"),
1527 Bytes::from_static(b"x"),
1528 IdempotencyKey::new(),
1529 )
1530 .await?;
1531 }
1532 for key in ["a:", "a;"] {
1533 s3.put_object(key, Bytes::from_static(b"x"), IdempotencyKey::new())
1534 .await?;
1535 }
1536 let first = s3
1537 .list_objects(S3ListOptions {
1538 delimiter: None,
1539 maximum_keys: 33,
1540 maximum_entries_examined: 34,
1541 ..S3ListOptions::default()
1542 })
1543 .await?;
1544 assert_eq!(
1545 first.objects.last().map(|object| object.key.as_str()),
1546 Some("a:")
1547 );
1548 let page = s3
1549 .list_objects(S3ListOptions {
1550 delimiter: None,
1551 maximum_keys: 1,
1552 maximum_entries_examined: 2,
1553 continuation: first.next_continuation,
1554 ..S3ListOptions::default()
1555 })
1556 .await?;
1557 assert_eq!(page.objects[0].key, "a;");
1558 assert!(page.entries_examined <= 2);
1559 Ok(())
1560 }
1561
1562 #[tokio::test]
1563 async fn object_cursor_at_a_directory_includes_its_descendants()
1564 -> Result<(), Box<dyn std::error::Error>> {
1565 let fs = Fs::memory();
1566 let workspace = fs.create_workspace("directory-marker").await?;
1567 let s3 = workspace.s3();
1568 s3.put_object("a/x", Bytes::from_static(b"x"), IdempotencyKey::new())
1569 .await?;
1570 for delimiter in [None, Some('/')] {
1571 let mut options = S3ListOptions {
1572 delimiter,
1573 maximum_keys: 1,
1574 maximum_entries_examined: 2,
1575 ..S3ListOptions::default()
1576 };
1577 options.continuation = Some(S3ListCursor {
1578 generation: workspace.head().await?.id(),
1579 after: "a".to_owned(),
1580 after_prefix: false,
1581 query: list_query_digest(&options),
1582 });
1583 let page = s3.list_objects(options).await?;
1584 if delimiter.is_some() {
1585 assert_eq!(page.common_prefixes, ["a/"]);
1586 } else {
1587 assert_eq!(page.objects[0].key, "a/x");
1588 }
1589 assert!(page.entries_examined <= 2);
1590 }
1591 Ok(())
1592 }
1593
1594 #[tokio::test]
1595 async fn listing_descends_to_the_exact_prefix_frontier_without_unrelated_subtrees()
1596 -> Result<(), Box<dyn std::error::Error>> {
1597 let fs = Fs::memory();
1598 let workspace = fs.create_workspace("prefix-frontier").await?;
1599 let s3 = workspace.s3();
1600 for index in 0..32 {
1601 s3.put_object(
1602 &format!("unrelated-{index:02}/value"),
1603 Bytes::from_static(b"x"),
1604 IdempotencyKey::new(),
1605 )
1606 .await?;
1607 }
1608 s3.put_object(
1609 "target/value",
1610 Bytes::from_static(b"wanted"),
1611 IdempotencyKey::new(),
1612 )
1613 .await?;
1614
1615 let selected = Box::pin(s3.list_objects(S3ListOptions {
1616 prefix: "target/".to_owned(),
1617 delimiter: None,
1618 maximum_entries_examined: 1,
1619 ..S3ListOptions::default()
1620 }))
1621 .await?;
1622 assert_eq!(selected.entries_examined, 1);
1623 assert_eq!(selected.objects.len(), 1);
1624 assert_eq!(selected.objects[0].key, "target/value");
1625
1626 let absent = Box::pin(s3.list_objects(S3ListOptions {
1627 prefix: "absent/".to_owned(),
1628 maximum_entries_examined: 1,
1629 ..S3ListOptions::default()
1630 }))
1631 .await?;
1632 assert_eq!(absent.entries_examined, 0);
1633 assert!(absent.objects.is_empty());
1634 Ok(())
1635 }
1636
1637 #[tokio::test]
1638 async fn multipart_stages_off_authority_and_publishes_one_ordered_generation()
1639 -> Result<(), Box<dyn std::error::Error>> {
1640 let fs = Fs::memory();
1641 let workspace = fs.create_workspace("multipart").await?;
1642 let before = workspace.head().await?.id();
1643 let mut upload = workspace
1644 .s3()
1645 .create_multipart_upload(
1646 "large/value.bin",
1647 S3MultipartOptions::default(),
1648 IdempotencyKey::new(),
1649 )
1650 .await?;
1651 upload.upload_part(2, Bytes::from_static(b"second")).await?;
1652 upload.upload_part(1, Bytes::from_static(b"first-")).await?;
1653 assert_eq!(workspace.head().await?.id(), before);
1654 assert!(matches!(
1655 upload.complete(&[1, 2]).await?,
1656 TransactionCommit::Committed(_)
1657 ));
1658 assert!(matches!(
1659 upload.complete(&[1, 2]).await?,
1660 TransactionCommit::AlreadyCommitted(_)
1661 ));
1662 assert_eq!(
1663 workspace.read("/large/value.bin", 64).await?,
1664 b"first-second".as_slice()
1665 );
1666
1667 let mut invalid = workspace
1668 .s3()
1669 .create_multipart_upload(
1670 "invalid.bin",
1671 S3MultipartOptions::default(),
1672 IdempotencyKey::new(),
1673 )
1674 .await?;
1675 invalid.upload_part(1, Bytes::from_static(b"part")).await?;
1676 assert!(matches!(
1677 invalid.complete(&[2]).await,
1678 Err(S3Error::MissingPart(2))
1679 ));
1680 assert!(matches!(
1681 workspace.s3().head_object("invalid.bin").await,
1682 Err(S3Error::NotFound)
1683 ));
1684 Ok(())
1685 }
1686}