Skip to main content

coven_storage/
invitation.rs

1use crate::{CloudProvider, StorageConfig, StorageError, StorageFailure};
2use coven_crypto::{SecretBytes, SecretText};
3use coven_format::code_fields::{FieldReader, FieldWriter};
4use serde::{Deserialize, Serialize};
5
6/// Provider information carried inside coven's encrypted invite code. Native
7/// share tokens admit the recipient; they never identify grants for revocation.
8#[derive(Clone, Serialize, Deserialize)]
9#[serde(try_from = "RecordedInvitation")]
10pub struct StorageInvitation {
11    location: StorageConfig,
12    pub(crate) acceptance: InvitationAcceptance,
13}
14#[derive(Clone, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub(crate) enum InvitationAcceptance {
17    Granted,
18    DropboxMount,
19    OneDriveShare { token: SecretText },
20    CloudKitShare { url: SecretText },
21}
22#[derive(Deserialize)]
23#[serde(deny_unknown_fields)]
24struct RecordedInvitation {
25    location: StorageConfig,
26    acceptance: InvitationAcceptance,
27}
28impl TryFrom<RecordedInvitation> for StorageInvitation {
29    type Error = StorageError;
30    fn try_from(value: RecordedInvitation) -> Result<Self, Self::Error> {
31        Self::new(value.location, value.acceptance)
32    }
33}
34impl std::fmt::Debug for StorageInvitation {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("StorageInvitation")
37            .field("provider", &self.location.provider())
38            .finish_non_exhaustive()
39    }
40}
41impl StorageInvitation {
42    /// An already granted account location, or Dropbox's shared namespace to
43    /// mount. An S3 admin uses this after making the member's key in the console.
44    /// CloudKit instead needs the share URL returned by `grant_access`.
45    pub fn for_account(location: StorageConfig) -> Result<Self, StorageError> {
46        let acceptance = match location.provider() {
47            CloudProvider::Dropbox => InvitationAcceptance::DropboxMount,
48            CloudProvider::CloudKit => {
49                return Err(StorageFailure::InvalidConfiguration
50                    .with_source("CloudKit requires a share URL"))
51            }
52            _ => InvitationAcceptance::Granted,
53        };
54        Self::new(location, acceptance)
55    }
56    pub(crate) fn new(
57        location: StorageConfig,
58        acceptance: InvitationAcceptance,
59    ) -> Result<Self, StorageError> {
60        location.validate()?;
61        if matches!(
62            location,
63            StorageConfig::Dropbox { location: crate::DropboxLocation::AppFolder, .. }
64        ) {
65            return Err(StorageFailure::DropboxAppFolderNotShareable.into());
66        }
67        match (location.provider(), &acceptance) {
68            (CloudProvider::S3 | CloudProvider::OneDrive, InvitationAcceptance::Granted)
69            | (CloudProvider::Dropbox, InvitationAcceptance::DropboxMount) => {}
70            (CloudProvider::OneDrive, InvitationAcceptance::OneDriveShare { token })
71                if !token.as_str().is_empty() => {}
72            (CloudProvider::CloudKit, InvitationAcceptance::CloudKitShare { url }) => {
73                let url = url::Url::parse(url.as_str())
74                    .map_err(|error| StorageFailure::InvalidConfiguration.with_source(error))?;
75                if !crate::web_url::is_web_url(&url) || url.scheme() != "https" {
76                    return Err(StorageFailure::InvalidConfiguration
77                        .with_source("invalid CloudKit share URL"));
78                }
79            }
80            _ => {
81                return Err(StorageFailure::InvalidConfiguration
82                    .with_source("invitation does not match its provider"))
83            }
84        }
85        Ok(Self { location, acceptance })
86    }
87    /// Nonsecret location used to construct the joining account's adapter.
88    pub fn location(&self) -> &StorageConfig {
89        &self.location
90    }
91    /// Encode as the database records a granted invitation, with bytes erased
92    /// on drop. An invite code packs it instead (format.md D13).
93    pub fn encode(&self) -> Result<SecretBytes, StorageError> {
94        crate::secret_json::encode(self)
95    }
96    /// Decode and validate an invitation the database recorded.
97    pub fn decode(bytes: &[u8]) -> Result<Self, StorageError> {
98        crate::secret_json::decode(bytes)
99    }
100    /// Pack the location, then how the recipient accepts it, into an invite
101    /// code's storage part (format.md D13). The location's provider fixes
102    /// which acceptance follows: nothing on S3 and Dropbox, an optional
103    /// share token on OneDrive, the share URL on CloudKit.
104    pub(crate) fn pack(&self, out: &mut FieldWriter) -> Result<(), coven_format::Error> {
105        self.location.pack(out)?;
106        match &self.acceptance {
107            InvitationAcceptance::Granted
108                if self.location.provider() == CloudProvider::OneDrive =>
109            {
110                out.option(None::<&str>, FieldWriter::text)
111            }
112            InvitationAcceptance::Granted | InvitationAcceptance::DropboxMount => Ok(()),
113            InvitationAcceptance::OneDriveShare { token } => {
114                out.option(Some(token.as_str()), FieldWriter::text)
115            }
116            InvitationAcceptance::CloudKitShare { url } => out.text(url.as_str()),
117        }
118    }
119    /// Read an invitation packed by [`Self::pack`], checked as `new` checks one.
120    pub(crate) fn unpack(input: &mut FieldReader<'_>) -> Result<Self, StorageError> {
121        let location = StorageConfig::unpack(input)?;
122        let acceptance = match location.provider() {
123            CloudProvider::S3 => InvitationAcceptance::Granted,
124            CloudProvider::Dropbox => InvitationAcceptance::DropboxMount,
125            CloudProvider::OneDrive => {
126                match input.option(|input| input.secret_text("share token"))? {
127                    Some(token) => InvitationAcceptance::OneDriveShare { token },
128                    None => InvitationAcceptance::Granted,
129                }
130            }
131            CloudProvider::CloudKit => {
132                InvitationAcceptance::CloudKitShare { url: input.secret_text("share URL")? }
133            }
134        };
135        Self::new(location, acceptance)
136    }
137    pub(crate) fn check(&self, location: &StorageConfig) -> Result<(), StorageError> {
138        if !self.location.same_location(location) {
139            return Err(StorageFailure::InvitationMismatch.into());
140        }
141        Ok(())
142    }
143}
144
145#[cfg(test)]
146#[path = "invitation_tests.rs"]
147mod tests;