Skip to main content

acyclic_fs/
s3.rs

1//! Strongly consistent S3-shaped view over one canonical workspace.
2//!
3//! This module owns no object namespace or mutable state. Reads pin one
4//! immutable generation and writes use the ordinary workspace transaction.
5
6use 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/// Bounded stable `ListObjectsV2` request semantics.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct S3ListOptions {
23    /// Exact bytewise UTF-8 key prefix.
24    pub prefix: String,
25    /// Either no delimiter or the S3 path delimiter `/`.
26    pub delimiter: Option<char>,
27    /// Initial exclusive key marker. Ignored after a continuation is supplied.
28    pub start_after: Option<String>,
29    /// Opaque immutable-generation continuation returned by a prior page.
30    /// Callers should reuse the token returned by a prior page.
31    pub continuation: Option<S3ListCursor>,
32    /// Maximum combined objects and common prefixes returned.
33    pub maximum_keys: u32,
34    /// Hard authenticated namespace entries examined by this request.
35    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/// Complete metadata for one S3-visible regular file.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct S3ObjectHead {
54    /// Relative workspace key without a leading slash.
55    pub key: String,
56    /// Exact logical file length.
57    pub content_length: u64,
58    /// Strong validator bound to the immutable generation and key.
59    pub etag: String,
60}
61
62/// One listed regular-file object.
63pub type S3Object = S3ObjectHead;
64
65/// One stable bounded listing page.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct S3List {
68    /// Regular files selected in bytewise key order.
69    pub objects: Vec<S3Object>,
70    /// Delimiter-collapsed prefixes selected in bytewise key order.
71    pub common_prefixes: Vec<String>,
72    /// Immutable-generation continuation when more matching results exist.
73    pub next_continuation: Option<S3ListCursor>,
74    /// Exact authenticated namespace entries examined.
75    pub entries_examined: u32,
76}
77
78/// Opaque stable S3 listing continuation bound to one generation and query.
79#[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    /// Encodes this cursor for an S3 continuation-token field.
89    #[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    /// Decodes one bounded canonical continuation token. This checks syntax,
101    /// not the token's origin; the listing operation checks generation/query.
102    ///
103    /// # Errors
104    ///
105    /// Rejects malformed versions, identities, UTF-8, and oversized markers.
106    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
137/// A zero-state S3 mapping over one workspace.
138pub struct S3Workspace<A, O> {
139    workspace: Workspace<A, O>,
140}
141
142/// Hard multipart staging bounds.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub struct S3MultipartOptions {
145    /// Maximum distinct uploaded part numbers.
146    pub maximum_parts: u32,
147    /// Maximum bytes admitted for any one part.
148    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
160/// One unpublished multipart candidate. Dropping it aborts without authority
161/// mutation; staged immutable objects remain safe collectible orphans.
162pub 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    /// Returns the protocol-neutral S3 view of this workspace.
179    #[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    /// Starts one unpublished multipart object transaction.
189    ///
190    /// # Errors
191    ///
192    /// Rejects invalid keys or zero staging bounds.
193    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    /// Reads object metadata from one pinned generation.
211    ///
212    /// # Errors
213    ///
214    /// Rejects invalid keys, absent/non-regular paths, and backend failures.
215    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    /// Reads object metadata from one exact immutable generation.
221    ///
222    /// # Errors
223    ///
224    /// Rejects a foreign generation, invalid key, absent/non-regular path, or backend failure.
225    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    /// Reads one complete object under a caller byte bound.
265    ///
266    /// # Errors
267    ///
268    /// Returns key, kind, size, authentication, or storage failures.
269    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    /// Reads one exact object byte range without materializing the rest.
278    ///
279    /// # Errors
280    ///
281    /// Returns key, range, kind, authentication, or storage failures.
282    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    /// Reads one exact byte range from one immutable generation.
288    ///
289    /// # Errors
290    ///
291    /// Rejects a foreign generation, invalid key/range, or authenticated storage failure.
292    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    /// Atomically creates or replaces one object.
332    ///
333    /// # Errors
334    ///
335    /// Returns key, parent, transaction, or publication failures.
336    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    /// Prepares one S3 object from already staged immutable content in the
349    /// caller's transaction. The caller can validate its streaming checksums
350    /// before invoking this method and commits the transaction afterward.
351    ///
352    /// # Errors
353    ///
354    /// Rejects a foreign transaction, malformed key or staged content, or a
355    /// Filesystem mutation failure. No content is published on error.
356    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    /// Atomically removes one object.
371    ///
372    /// # Errors
373    ///
374    /// Returns key, transaction, or publication failures. An absent key is a
375    /// successful no-op, matching S3 delete semantics.
376    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    /// Atomically removes several objects through one workspace transaction.
396    ///
397    /// # Errors
398    ///
399    /// Returns request, key, transaction, or publication failures. Absent keys
400    /// are successful no-ops, including repeated bulk deletion.
401    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    /// Copies one object through immutable extent references.
427    ///
428    /// # Errors
429    ///
430    /// Returns key, kind, transaction, or publication failures.
431    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    /// Lists regular files and delimiter prefixes from one pinned generation.
447    ///
448    /// # Errors
449    ///
450    /// Returns malformed options, unsupported names, work exhaustion, or
451    /// authenticated storage failures.
452    pub async fn list_objects(&self, options: S3ListOptions) -> Result<S3List, S3Error> {
453        self.list_objects_in(None, options).await
454    }
455
456    /// Lists from one exact immutable generation with stable continuation.
457    ///
458    /// # Errors
459    ///
460    /// Rejects a foreign generation, malformed options/cursors, exhausted work, or storage failure.
461    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
553/// Seek only when every earlier child directory has a flattened `name/`
554/// bound below the marker. A marker such as `a.` is ambiguous because `a/x`
555/// sorts after it, so that case keeps the ordered walk.
556fn 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/// Merges directory-page lower bounds and object keys in flattened UTF-8
593/// order. A directory's `name/` bound is distinct from its component name:
594/// sibling `a.` must precede a descendant `a/x`.
595#[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    /// Stages or replaces one positive part number without publishing it.
824    ///
825    /// # Errors
826    ///
827    /// Rejects zero numbers, part/count bounds, or storage failures.
828    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    /// Publishes exactly the caller-selected ordered part set as one object.
850    ///
851    /// # Errors
852    ///
853    /// Rejects empty, duplicate, or missing part identities and returns the
854    /// ordinary workspace publication outcome.
855    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    /// Aborts without authority mutation. Staged objects are collectible.
886    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/// Typed S3 mapping failures.
1024#[derive(Debug, Error)]
1025pub enum S3Error {
1026    /// Workspace operation failed.
1027    #[error(transparent)]
1028    Workspace(#[from] WorkspaceError),
1029    /// Key is not one canonical relative file path.
1030    #[error("invalid S3 object key")]
1031    InvalidKey,
1032    /// Request options are unsupported or contradictory.
1033    #[error("invalid S3 request: {0}")]
1034    InvalidRequest(&'static str),
1035    /// Object is absent.
1036    #[error("S3 object does not exist")]
1037    NotFound,
1038    /// Selected path is not a regular file.
1039    #[error("S3 path is not a regular file")]
1040    NotRegularFile,
1041    /// Namespace cannot be represented as S3 UTF-8 keys.
1042    #[error("workspace namespace is not representable by the S3 view")]
1043    UnsupportedNamespace,
1044    /// Authenticated object shape is invalid.
1045    #[error("authenticated S3 object shape is invalid")]
1046    InvalidObject,
1047    /// Listing exhausted its caller-supplied work bound.
1048    #[error("S3 listing exceeded its authenticated entry bound")]
1049    ListLimit,
1050    /// Multipart staging exceeded an explicit count or byte bound.
1051    #[error("S3 multipart upload exceeded its bound")]
1052    MultipartLimit,
1053    /// Multipart completion referenced an absent part.
1054    #[error("S3 multipart part {0} is absent")]
1055    MissingPart(u32),
1056    /// A concurrent writer or authority epoch won before publication.
1057    #[error("S3 mutation conflicted with a concurrent writer")]
1058    WriteConflict,
1059    /// A retry identity was previously bound to different input.
1060    #[error("S3 idempotency key was reused for different input")]
1061    IdempotencyConflict,
1062    /// An exact-generation view was supplied for another workspace.
1063    #[error("S3 generation belongs to another workspace")]
1064    ForeignGeneration,
1065    /// A staged write transaction belongs to another workspace.
1066    #[error("S3 transaction belongs to another workspace")]
1067    ForeignTransaction,
1068    /// Listing continuation is malformed or belongs to another query.
1069    #[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}