1use coven_format::code_fields::{FieldReader, FieldWriter};
2use coven_format::Error as FieldError;
3use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub enum CloudProvider {
8 S3,
10 Dropbox,
12 OneDrive,
14 CloudKit,
16}
17
18impl CloudProvider {
19 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 pub fn signs_in_with_oauth(self) -> bool {
33 matches!(self, Self::Dropbox | Self::OneDrive)
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub enum StorageConfig {
41 S3 {
45 bucket: String,
47 region: String,
49 endpoint: Option<url::Url>,
51 profile: Option<crate::S3Profile>,
58 },
59 Dropbox {
61 location: crate::DropboxLocation,
63 account_label: Option<String>,
65 folder_label: Option<FolderLabel>,
68 },
69 OneDrive {
71 drive_id: String,
73 folder_id: String,
75 account_label: Option<String>,
77 folder_label: Option<FolderLabel>,
80 },
81 CloudKit {
83 container: String,
85 owner: String,
87 zone: String,
89 },
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct FolderLabel {
98 pub name: String,
100 pub web_url: url::Url,
102}
103
104impl FolderLabel {
105 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 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 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 (Self::S3 { .. }, Self::S3 { .. }) | (Self::CloudKit { .. }, Self::CloudKit { .. }) => {
140 self == other
141 }
142 _ => false,
143 }
144 }
145
146 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 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 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 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 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
244const S3: u8 = 0;
247const DROPBOX: u8 = 1;
248const ONEDRIVE: u8 = 2;
249const CLOUDKIT: u8 = 3;
250
251impl StorageConfig {
252 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 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 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
334fn 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;