Skip to main content

coven_storage/
config.rs

1use coven_format::code_fields::{FieldReader, FieldWriter};
2use coven_format::Error as FieldError;
3use serde::{Deserialize, Serialize};
4
5/// A provider supported by coven (§20.2).
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub enum CloudProvider {
8    /// S3, including compatible providers.
9    S3,
10    /// Dropbox.
11    Dropbox,
12    /// OneDrive.
13    OneDrive,
14    /// iCloud through coven-storage's native Apple-platform adapter.
15    CloudKit,
16}
17
18impl CloudProvider {
19    /// Instructions for cutting off a removed device's provider access (§14.2).
20    /// This depends on the provider kind, not on a connected account or request.
21    /// None on S3: the member's other devices share the key, which stays theirs.
22    pub fn sign_out(self) -> Option<crate::ProviderSignOut> {
23        match self {
24            Self::S3 => None,
25            Self::CloudKit => Some(crate::ProviderSignOut::RemoveFromAppleAccount),
26            provider => Some(crate::ProviderSignOut::RemoveAppAccess { provider }),
27        }
28    }
29
30    /// Whether a person signs in to it in a browser (OAuth): Dropbox and
31    /// OneDrive.
32    pub fn signs_in_with_oauth(self) -> bool {
33        matches!(self, Self::Dropbox | Self::OneDrive)
34    }
35}
36
37/// The store's location; credentials are kept separately in key custody.
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub enum StorageConfig {
41    /// An existing S3 bucket, which the store has to itself: coven can't
42    /// tell how far a member's key reaches, so it takes every key to reach
43    /// the whole bucket (§20.7). Paths start at the bucket's root.
44    S3 {
45        /// Bucket name.
46        bucket: String,
47        /// Signing region.
48        region: String,
49        /// None is AWS's own endpoint. Catalogued hosts (Appendix E.3) require https.
50        endpoint: Option<url::Url>,
51        /// A catalogued service's name or custom settings; either must agree
52        /// with a catalogued endpoint host. None selects by host:
53        /// storage.googleapis.com is GCS XML, hosts ending in
54        /// .r2.cloudflarestorage.com are R2, no endpoint or .amazonaws.com is
55        /// AWS, and setup detects any other endpoint's settings, recording
56        /// them as Custom. All run the setup checks.
57        profile: Option<crate::S3Profile>,
58    },
59    /// Dropbox's access and location, fixed when cloud storage is created.
60    Dropbox {
61        /// Required choice: not-shared App folder or a shared-capable namespace.
62        location: crate::DropboxLocation,
63        /// Signed-in member's display label; absent before setup. Never routes or authorizes.
64        account_label: Option<String>,
65        /// The shared folder's name and web address as the latest connection
66        /// read them; absent before setup and in the App folder (§20.2).
67        folder_label: Option<FolderLabel>,
68    },
69    /// A OneDrive folder; paths within it start with the store id.
70    OneDrive {
71        /// Drive containing the folder.
72        drive_id: String,
73        /// Folder shared with members' Microsoft accounts.
74        folder_id: String,
75        /// Signed-in member's display label; absent before setup. Never routes or authorizes.
76        account_label: Option<String>,
77        /// The folder's name and web address as the latest connection read
78        /// them; absent before setup (§20.2).
79        folder_label: Option<FolderLabel>,
80    },
81    /// The custom zone reached by the built-in CloudKit adapter.
82    CloudKit {
83        /// App's CloudKit container.
84        container: String,
85        /// Owner's CloudKit record name.
86        owner: String,
87        /// Custom zone containing the store.
88        zone: String,
89    },
90}
91
92/// A Dropbox shared folder's or a OneDrive folder's name and web address,
93/// as the member's account named them at this device's latest connection
94/// (§20.2). Display only: coven never reaches storage through them.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct FolderLabel {
98    /// The provider's name for the folder.
99    pub name: String,
100    /// Where the folder opens in a browser: an https address.
101    pub web_url: url::Url,
102}
103
104impl FolderLabel {
105    /// The label a provider answered with; one this refuses is a protocol
106    /// failure.
107    pub(crate) fn answered(name: &str, web_url: &str) -> Result<Self, crate::StorageError> {
108        let web_url = url::Url::parse(web_url)
109            .map_err(|error| crate::StorageFailure::Protocol.with_source(error))?;
110        let label = Self { name: name.into(), web_url };
111        label.validate().map_err(|error| crate::StorageFailure::Protocol.with_source(error))?;
112        Ok(label)
113    }
114
115    /// Refuse an empty name or an address a browser shouldn't be sent to.
116    fn validate(&self) -> Result<(), crate::StorageError> {
117        if self.name.trim().is_empty() || self.name.chars().any(char::is_control) {
118            return Err(crate::StorageFailure::InvalidConfiguration
119                .with_source("empty or invalid folder name"));
120        }
121        if self.web_url.scheme() != "https" || !crate::web_url::is_web_url(&self.web_url) {
122            return Err(crate::StorageFailure::InvalidConfiguration
123                .with_source("a folder's web address must be https"));
124        }
125        Ok(())
126    }
127}
128
129impl StorageConfig {
130    /// Whether these settings reach the same location, ignoring display labels.
131    pub fn same_location(&self, other: &Self) -> bool {
132        match (self, other) {
133            (Self::Dropbox { location: a, .. }, Self::Dropbox { location: b, .. }) => a == b,
134            (
135                Self::OneDrive { drive_id: ad, folder_id: af, .. },
136                Self::OneDrive { drive_id: bd, folder_id: bf, .. },
137            ) => ad == bd && af == bf,
138            // These providers carry no account label.
139            (Self::S3 { .. }, Self::S3 { .. }) | (Self::CloudKit { .. }, Self::CloudKit { .. }) => {
140                self == other
141            }
142            _ => false,
143        }
144    }
145
146    /// Whether both are S3 locations naming the same bucket, at one
147    /// endpoint and region, whatever profile reaches it.
148    pub fn same_s3_bucket(&self, other: &Self) -> bool {
149        match (self, other) {
150            (
151                Self::S3 { bucket: ab, region: ar, endpoint: ae, .. },
152                Self::S3 { bucket: bb, region: br, endpoint: be, .. },
153            ) => ab == bb && ar == br && ae == be,
154            _ => false,
155        }
156    }
157
158    /// Whether every member reaches the whole location, so it holds one
159    /// store, which the location marker at its root names (§20.3): a
160    /// Dropbox shared folder, a OneDrive folder, or an S3 bucket. Nobody else
161    /// reaches the Dropbox App folder, and a CloudKit store's zone is its own.
162    pub fn holds_one_store(&self) -> bool {
163        match self {
164            Self::S3 { .. } | Self::OneDrive { .. } => true,
165            Self::Dropbox { location, .. } => match location {
166                crate::DropboxLocation::SharedFolder { .. } => true,
167                crate::DropboxLocation::AppFolder => false,
168            },
169            Self::CloudKit { .. } => false,
170        }
171    }
172
173    /// Access required to open this location.
174    pub fn oauth_access(&self) -> Option<crate::OAuthAccess> {
175        match self {
176            Self::Dropbox { location, .. } => Some(crate::OAuthAccess::Dropbox(location.access())),
177            Self::OneDrive { .. } => Some(crate::OAuthAccess::OneDrive),
178            Self::S3 { .. } | Self::CloudKit { .. } => None,
179        }
180    }
181    /// The provider of this location.
182    pub fn provider(&self) -> CloudProvider {
183        match self {
184            Self::S3 { .. } => CloudProvider::S3,
185            Self::Dropbox { .. } => CloudProvider::Dropbox,
186            Self::OneDrive { .. } => CloudProvider::OneDrive,
187            Self::CloudKit { .. } => CloudProvider::CloudKit,
188        }
189    }
190
191    /// Reject missing location information before making a request.
192    pub fn validate(&self) -> Result<(), crate::StorageError> {
193        let fields: Vec<&str> = match self {
194            Self::S3 { bucket, region, endpoint, profile } => {
195                if let Some(endpoint) = endpoint {
196                    if !crate::web_url::is_web_url(endpoint)
197                        || endpoint.query().is_some()
198                        || endpoint.fragment().is_some()
199                    {
200                        return Err(
201                            crate::StorageFailure::InvalidConfiguration.with_source("S3 endpoint")
202                        );
203                    }
204                }
205                crate::providers::check_s3_profile(endpoint.as_ref(), profile.as_ref())?;
206                vec![bucket, region]
207            }
208            Self::Dropbox {
209                location: crate::DropboxLocation::AppFolder,
210                folder_label: Some(_),
211                ..
212            } => {
213                return Err(crate::StorageFailure::InvalidConfiguration
214                    .with_source("the Dropbox App folder has no folder label"));
215            }
216            Self::Dropbox { location: crate::DropboxLocation::AppFolder, .. } => vec![],
217            Self::Dropbox {
218                location: crate::DropboxLocation::SharedFolder { namespace_id },
219                folder_label,
220                ..
221            } => {
222                if let Some(label) = folder_label {
223                    label.validate()?;
224                }
225                vec![namespace_id]
226            }
227            Self::OneDrive { drive_id, folder_id, folder_label, .. } => {
228                if let Some(label) = folder_label {
229                    label.validate()?;
230                }
231                vec![drive_id, folder_id]
232            }
233            Self::CloudKit { container, owner, zone } => vec![container, owner, zone],
234        };
235        if fields.iter().any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
236        {
237            return Err(crate::StorageFailure::InvalidConfiguration
238                .with_source("empty or invalid location"));
239        }
240        Ok(())
241    }
242}
243
244// A location's fields in a code's storage part (format.md D13): the
245// provider's tag, then that provider's own fields.
246const S3: u8 = 0;
247const DROPBOX: u8 = 1;
248const ONEDRIVE: u8 = 2;
249const CLOUDKIT: u8 = 3;
250
251impl StorageConfig {
252    /// Pack this location's fields into a code's storage part.
253    pub(crate) fn pack(&self, out: &mut FieldWriter) -> Result<(), FieldError> {
254        match self {
255            Self::S3 { bucket, region, endpoint, profile } => {
256                out.u8(S3)?;
257                out.text(bucket)?;
258                out.text(region)?;
259                out.option(endpoint.as_ref(), |out, url| out.text(url.as_str()))?;
260                out.option(profile.as_ref(), |out, profile| profile.pack(out))
261            }
262            // A code carries no folder label (format.md D13).
263            Self::Dropbox { location, account_label, folder_label: _ } => {
264                out.u8(DROPBOX)?;
265                match location {
266                    crate::DropboxLocation::AppFolder => out.u8(0)?,
267                    crate::DropboxLocation::SharedFolder { namespace_id } => {
268                        out.u8(1)?;
269                        out.text(namespace_id)?;
270                    }
271                }
272                out.option(account_label.as_deref(), FieldWriter::text)
273            }
274            Self::OneDrive { drive_id, folder_id, account_label, folder_label: _ } => {
275                out.u8(ONEDRIVE)?;
276                out.text(drive_id)?;
277                out.text(folder_id)?;
278                out.option(account_label.as_deref(), FieldWriter::text)
279            }
280            Self::CloudKit { container, owner, zone } => {
281                out.u8(CLOUDKIT)?;
282                out.text(container)?;
283                out.text(owner)?;
284                out.text(zone)
285            }
286        }
287    }
288
289    /// Read a location packed by [`Self::pack`], checked as `validate` checks
290    /// one. A text written in a spelling `pack` never writes, such as an
291    /// endpoint URL that isn't canonical, is refused.
292    pub(crate) fn unpack(input: &mut FieldReader<'_>) -> Result<Self, crate::StorageError> {
293        let location = match input.u8()? {
294            S3 => Self::S3 {
295                bucket: input.text("bucket")?,
296                region: input.text("region")?,
297                endpoint: input
298                    .option(|input| input.text("endpoint"))?
299                    .map(|text| recorded_url(&text))
300                    .transpose()?,
301                profile: input.option(crate::S3Profile::unpack)?,
302            },
303            DROPBOX => Self::Dropbox {
304                location: match input.u8()? {
305                    0 => crate::DropboxLocation::AppFolder,
306                    1 => crate::DropboxLocation::SharedFolder {
307                        namespace_id: input.text("namespace id")?,
308                    },
309                    tag => {
310                        return Err(FieldError::UnknownTag { field: "Dropbox location", tag }.into())
311                    }
312                },
313                account_label: input.option(|input| input.text("account label"))?,
314                folder_label: None,
315            },
316            ONEDRIVE => Self::OneDrive {
317                drive_id: input.text("drive id")?,
318                folder_id: input.text("folder id")?,
319                account_label: input.option(|input| input.text("account label"))?,
320                folder_label: None,
321            },
322            CLOUDKIT => Self::CloudKit {
323                container: input.text("container")?,
324                owner: input.text("owner")?,
325                zone: input.text("zone")?,
326            },
327            tag => return Err(FieldError::UnknownTag { field: "provider", tag }.into()),
328        };
329        location.validate()?;
330        Ok(location)
331    }
332}
333
334/// An endpoint as `pack` writes it: the URL's own serialization, and no other.
335fn recorded_url(text: &str) -> Result<url::Url, crate::StorageError> {
336    let url = url::Url::parse(text)
337        .map_err(|error| crate::StorageFailure::Encoding.with_source(error))?;
338    if url.as_str() != text {
339        return Err(crate::StorageFailure::Encoding
340            .with_source("a recorded S3 endpoint is not in its canonical spelling"));
341    }
342    Ok(url)
343}
344
345#[cfg(test)]
346#[path = "config_tests.rs"]
347mod tests;