Skip to main content

coven_storage/
s3_settings.rs

1//! The S3 behaviors a location uses (§20.7). Catalogued profiles fill them; a
2//! custom profile carries its own, so every device behaves identically.
3use coven_format::code_fields::{FieldReader, FieldWriter};
4use coven_format::Error as FieldError;
5use serde::{Deserialize, Serialize};
6
7/// An S3 location's behaviors: a catalogued service's, or its own.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub enum S3Profile {
10    /// The catalogued service's settings.
11    Named(S3ProfileName),
12    /// Settings detected at setup or chosen by the app; restore codes carry them.
13    Custom(S3Settings),
14}
15
16/// An S3 service whose catalogued profile a location names (§20.7).
17/// Self-hosted services have arbitrary endpoints, so no hostname identifies them.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub enum S3ProfileName {
20    /// Amazon S3.
21    Aws,
22    /// Google Cloud Storage through its XML API.
23    Gcs,
24    /// Cloudflare R2.
25    R2,
26    /// RustFS, self-hosted.
27    RustFs,
28    /// SeaweedFS's S3 gateway, self-hosted.
29    SeaweedFs,
30    /// Ceph RADOS Gateway, self-hosted.
31    CephRgw,
32    /// Versity Gateway, self-hosted.
33    VersityGw,
34}
35
36/// The bucket's versioning state, read through the service's XML API (§20.7).
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum S3BucketVersioning {
39    /// The bucket has never enabled versioning.
40    Disabled,
41    /// New writes retain earlier versions.
42    Enabled,
43    /// Versioning was suspended; earlier versions may remain.
44    Suspended,
45    /// The hosted service has no object-versioning feature (R2).
46    Unavailable,
47}
48
49/// Every S3 behavior the adapter varies, one value each (§20.7).
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct S3Settings {
53    /// How requests are authenticated.
54    pub signing: S3Signing,
55    /// The condition a create-once single PUT sends.
56    pub single_put: S3SinglePut,
57    /// Which publications proceed by a conditional copy of a staged temporary.
58    pub copy_publication: S3CopyPublication,
59    /// What the native multipart `Last-Modified` dates.
60    pub multipart_timestamp: S3MultipartTimestamp,
61    /// How returned checksums are read.
62    pub checksums: S3Checksums,
63    /// The listing protocol.
64    pub listing: S3Listing,
65    /// Where a replaceable object's revision comes from.
66    pub revision: S3Revision,
67    /// Which response headers carry metadata, generation and hashes.
68    pub response_headers: S3ResponseHeaders,
69    /// Whether HEAD and GET request checksum mode.
70    pub request_checksums: bool,
71}
72
73/// Request authentication.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75pub enum S3Signing {
76    /// AWS Signature Version 4.
77    AwsSigV4,
78    /// Google V4 HMAC with `x-goog-*` headers.
79    GoogleHmac,
80}
81
82/// The create-once condition on a single PUT.
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub enum S3SinglePut {
85    /// `If-None-Match: *`.
86    IfNoneMatch,
87    /// `x-goog-if-generation-match: 0`.
88    GenerationMatch,
89}
90
91/// Which publications proceed by a conditional copy of a reserved temporary (§20.6).
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub enum S3CopyPublication {
94    /// None: a single PUT and a multipart completion each carry the
95    /// destination condition (`If-None-Match` or `If-Match`).
96    Never,
97    /// Multipart uploads assemble a temporary and copy it with this
98    /// destination condition; a single PUT carries the destination condition.
99    Multipart(S3CopyCondition),
100    /// Every create or replacement, single-request or multipart, stages a temporary and
101    /// copies it with this destination condition.
102    Every(S3CopyCondition),
103}
104impl S3CopyPublication {
105    /// The condition of the copy that publishes a multipart upload, if any.
106    pub(crate) fn multipart(self) -> Option<S3CopyCondition> {
107        match self {
108            Self::Never => None,
109            Self::Multipart(condition) | Self::Every(condition) => Some(condition),
110        }
111    }
112    /// The condition of the copy that publishes a single-request body, if any.
113    pub(crate) fn single_request(self) -> Option<S3CopyCondition> {
114        match self {
115            Self::Never | Self::Multipart(_) => None,
116            Self::Every(condition) => Some(condition),
117        }
118    }
119}
120
121/// The destination condition on a publishing copy.
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub enum S3CopyCondition {
124    /// `If-None-Match: *` (AWS).
125    IfNoneMatch,
126    /// `x-goog-if-generation-match: 0` (GCS).
127    GenerationMatch,
128    /// `cf-copy-destination-if-none-match: *` (R2).
129    CloudflareIfNoneMatch,
130}
131
132/// What a native multipart object's `Last-Modified` dates.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
134pub enum S3MultipartTimestamp {
135    /// Completion of the upload.
136    Completion,
137    /// Initiation of the upload.
138    Start,
139}
140
141/// How returned checksums are read.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub enum S3Checksums {
144    /// Returned checksums state their scope; a missing scope is a protocol error.
145    ScopeStated,
146    /// Checksums without a stated scope are discarded.
147    UnstatedIgnored,
148    /// None are returned; any returned checksum is a protocol error.
149    Absent,
150}
151
152/// The listing protocol.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
154pub enum S3Listing {
155    /// ListObjectsV2 with continuation tokens.
156    V2,
157    /// ListObjects with key markers.
158    V1,
159}
160
161/// Where a replaceable object's revision comes from.
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub enum S3Revision {
164    /// Native versions, or fresh `coven-revision` metadata; immutable objects use the ETag.
165    ETag,
166    /// GCS's `x-goog-generation`.
167    Generation,
168}
169
170/// Which response headers carry metadata, generation and hashes.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
172pub enum S3ResponseHeaders {
173    /// `x-amz-*` headers.
174    S3,
175    /// `x-goog-*` headers, translated at the protocol boundary.
176    Google,
177}
178
179// A profile in a code's storage part (format.md D13): `0` and the catalogued
180// service's tag, or `1` and the custom settings, one bit field each.
181impl S3Profile {
182    pub(crate) fn pack(&self, out: &mut FieldWriter) -> Result<(), FieldError> {
183        match self {
184            Self::Named(name) => {
185                out.u8(0)?;
186                out.u8(name.tag())
187            }
188            Self::Custom(settings) => {
189                out.u8(1)?;
190                out.u16(settings.bits())
191            }
192        }
193    }
194
195    pub(crate) fn unpack(input: &mut FieldReader<'_>) -> Result<Self, FieldError> {
196        match input.u8()? {
197            0 => Ok(Self::Named(S3ProfileName::from_tag(input.u8()?)?)),
198            1 => Ok(Self::Custom(S3Settings::from_bits(input.u16()?)?)),
199            tag => Err(FieldError::UnknownTag { field: "S3 profile", tag }),
200        }
201    }
202}
203
204impl S3ProfileName {
205    const ALL: [Self; 7] = [
206        Self::Aws,
207        Self::Gcs,
208        Self::R2,
209        Self::RustFs,
210        Self::SeaweedFs,
211        Self::CephRgw,
212        Self::VersityGw,
213    ];
214
215    fn tag(self) -> u8 {
216        match self {
217            Self::Aws => 0,
218            Self::Gcs => 1,
219            Self::R2 => 2,
220            Self::RustFs => 3,
221            Self::SeaweedFs => 4,
222            Self::CephRgw => 5,
223            Self::VersityGw => 6,
224        }
225    }
226
227    fn from_tag(tag: u8) -> Result<Self, FieldError> {
228        Self::ALL
229            .into_iter()
230            .find(|name| name.tag() == tag)
231            .ok_or(FieldError::UnknownTag { field: "S3 profile name", tag })
232    }
233}
234
235// Custom settings' bits, from the lowest: signing (1 bit), single PUT (1),
236// copy publication (3, below),
237// multipart timestamp (1), checksums (2: 0-2), listing (1), revision (1),
238// response headers (1) and request checksums (1). The top four bits are zero.
239const SIGNING: u32 = 0;
240const SINGLE_PUT: u32 = 1;
241const COPY_PUBLICATION: u32 = 2;
242const MULTIPART_TIMESTAMP: u32 = 5;
243const CHECKSUMS: u32 = 6;
244const LISTING: u32 = 8;
245const REVISION: u32 = 9;
246const RESPONSE_HEADERS: u32 = 10;
247const REQUEST_CHECKSUMS: u32 = 11;
248const USED_BITS: u32 = 12;
249/// Copy publication's three bits are an index into this list; 7 is refused.
250const COPY_PUBLICATIONS: [S3CopyPublication; 7] = [
251    S3CopyPublication::Never,
252    S3CopyPublication::Multipart(S3CopyCondition::IfNoneMatch),
253    S3CopyPublication::Multipart(S3CopyCondition::GenerationMatch),
254    S3CopyPublication::Multipart(S3CopyCondition::CloudflareIfNoneMatch),
255    S3CopyPublication::Every(S3CopyCondition::IfNoneMatch),
256    S3CopyPublication::Every(S3CopyCondition::GenerationMatch),
257    S3CopyPublication::Every(S3CopyCondition::CloudflareIfNoneMatch),
258];
259
260impl S3Settings {
261    fn bits(&self) -> u16 {
262        // The index of this publication in `COPY_PUBLICATIONS`.
263        let copy_publication = match self.copy_publication {
264            S3CopyPublication::Never => 0,
265            S3CopyPublication::Multipart(S3CopyCondition::IfNoneMatch) => 1,
266            S3CopyPublication::Multipart(S3CopyCondition::GenerationMatch) => 2,
267            S3CopyPublication::Multipart(S3CopyCondition::CloudflareIfNoneMatch) => 3,
268            S3CopyPublication::Every(S3CopyCondition::IfNoneMatch) => 4,
269            S3CopyPublication::Every(S3CopyCondition::GenerationMatch) => 5,
270            S3CopyPublication::Every(S3CopyCondition::CloudflareIfNoneMatch) => 6,
271        };
272        let checksums = match self.checksums {
273            S3Checksums::ScopeStated => 0,
274            S3Checksums::UnstatedIgnored => 1,
275            S3Checksums::Absent => 2,
276        };
277        u16::from(self.signing == S3Signing::GoogleHmac) << SIGNING
278            | u16::from(self.single_put == S3SinglePut::GenerationMatch) << SINGLE_PUT
279            | copy_publication << COPY_PUBLICATION
280            | u16::from(self.multipart_timestamp == S3MultipartTimestamp::Start)
281                << MULTIPART_TIMESTAMP
282            | checksums << CHECKSUMS
283            | u16::from(self.listing == S3Listing::V1) << LISTING
284            | u16::from(self.revision == S3Revision::Generation) << REVISION
285            | u16::from(self.response_headers == S3ResponseHeaders::Google) << RESPONSE_HEADERS
286            | u16::from(self.request_checksums) << REQUEST_CHECKSUMS
287    }
288
289    fn from_bits(bits: u16) -> Result<Self, FieldError> {
290        let field = |shift: u32, width: u32| (bits >> shift) & ((1 << width) - 1);
291        let set = |shift: u32| field(shift, 1) == 1;
292        let unknown =
293            |field: &'static str, tag: u16| FieldError::UnknownTag { field, tag: tag as u8 };
294        if bits >> USED_BITS != 0 {
295            return Err(unknown("S3 settings", bits >> USED_BITS));
296        }
297        Ok(Self {
298            signing: if set(SIGNING) { S3Signing::GoogleHmac } else { S3Signing::AwsSigV4 },
299            single_put: if set(SINGLE_PUT) {
300                S3SinglePut::GenerationMatch
301            } else {
302                S3SinglePut::IfNoneMatch
303            },
304            copy_publication: *COPY_PUBLICATIONS
305                .get(usize::from(field(COPY_PUBLICATION, 3)))
306                .ok_or(unknown("S3 copy publication", field(COPY_PUBLICATION, 3)))?,
307            multipart_timestamp: if set(MULTIPART_TIMESTAMP) {
308                S3MultipartTimestamp::Start
309            } else {
310                S3MultipartTimestamp::Completion
311            },
312            checksums: match field(CHECKSUMS, 2) {
313                0 => S3Checksums::ScopeStated,
314                1 => S3Checksums::UnstatedIgnored,
315                2 => S3Checksums::Absent,
316                value => return Err(unknown("S3 checksums", value)),
317            },
318            listing: if set(LISTING) { S3Listing::V1 } else { S3Listing::V2 },
319            revision: if set(REVISION) { S3Revision::Generation } else { S3Revision::ETag },
320            response_headers: if set(RESPONSE_HEADERS) {
321                S3ResponseHeaders::Google
322            } else {
323                S3ResponseHeaders::S3
324            },
325            request_checksums: set(REQUEST_CHECKSUMS),
326        })
327    }
328}