Skip to main content

coven_sync/
codes.rs

1//! Checked code metadata used by bootstrap (E10).
2
3use coven_format::codes::{InviteCode, InviteKind, RestoreCode};
4use coven_foundation::id_source::{MemberId, StoreId};
5use coven_storage::{CloudProvider, InviteStorage, OAuthAccess, RestoreStorage, StorageConfig};
6
7/// The two codes that can open a store on a new device.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum CodeKind {
10    /// This person's member id and current private pair, store id and
11    /// storage location, and on S3 perhaps their key.
12    Restore,
13    /// A one-time request carrying the chosen member id, initial key,
14    /// inviting writer and its published key-copy number; approval is required.
15    Invite,
16}
17
18/// Code metadata safe for presentation before opening the store.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum CodeInfo {
21    /// A restore code reveals no store name before loading.
22    Restore(CodeMetadata),
23    /// An invite carries the store's name captured when the admin made it
24    /// and whether it adds a new person or recovers a member.
25    Invite(InviteInfo),
26}
27
28impl CodeInfo {
29    /// The store id, member and provider facts carried by either kind of code.
30    pub fn metadata(&self) -> &CodeMetadata {
31        match self {
32            Self::Restore(metadata) | Self::Invite(InviteInfo { metadata, .. }) => metadata,
33        }
34    }
35}
36
37/// What an invite code tells before connecting: as typed or scanned, and as a
38/// join under way saved it (§13.2).
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct InviteInfo {
41    /// The code's shared metadata, including the sign-in the join needs.
42    pub metadata: CodeMetadata,
43    /// The store name at invitation creation.
44    pub store_name: String,
45    /// The inviting admin's name, as their app gave it when it made the
46    /// invite, for the joining person's screens to say whom to tell the code
47    /// and whom they wait for (§13.2).
48    pub inviter: String,
49    /// Whether the invite adds a new person, whose join request carries the
50    /// name they type, or recovers a member, whose carries none.
51    pub kind: InviteKind,
52    /// When the invite ends, by the inviting device's clock, to the second:
53    /// the joining device sends no first request after it (§13.2).
54    pub expires_at: std::time::SystemTime,
55}
56
57/// Store, member and provider facts shared by both code kinds.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct CodeMetadata {
60    /// The shared store's identity.
61    pub store_id: StoreId,
62    /// The stable member id either kind of code fixes: the restoring
63    /// person's own, or the one the invite's admin chose (§13).
64    pub member: MemberId,
65    /// The storage provider.
66    pub cloud_provider: CloudProvider,
67    /// The code's provider location and account label, without credentials or share tokens.
68    pub location: StorageConfig,
69    /// The sign-in required by this code; S3 and CloudKit do not use OAuth.
70    pub oauth_access: Option<OAuthAccess>,
71    /// Whether the code carries an S3 key, so the device asks for none: an S3
72    /// invite always does, and a restore code when the person's own device
73    /// put the key it holds in it (§13.1).
74    pub carries_s3_key: bool,
75}
76
77/// A malformed code or one intended for the other operation.
78#[derive(Clone, Debug, thiserror::Error)]
79pub enum CodeError {
80    /// Encoding, checksum or provider payload validation failed.
81    #[error("invalid restore or invite code")]
82    Invalid,
83    /// A valid code names the other operation.
84    #[error("expected {expected:?} code, received {actual:?}")]
85    WrongKind {
86        /// The requested kind.
87        expected: CodeKind,
88        /// The actual kind.
89        actual: CodeKind,
90    },
91    /// A valid code names another store than the one it is used for: a
92    /// restore code for a store being restored in place, or an invite code
93    /// saved for another store's join.
94    #[error("code is for store {actual}, not {expected}")]
95    WrongStore {
96        /// The store the code is used for.
97        expected: StoreId,
98        /// The store the code names.
99        actual: StoreId,
100    },
101    /// A join's person is not the kind its invite is for: a name for a
102    /// recovery invite, or a recovery for a new person's.
103    #[error("invite is for {expected:?}, but the join is for {actual:?}")]
104    WrongJoiner {
105        /// The kind the invite is for.
106        expected: InviteKind,
107        /// The kind the join named.
108        actual: InviteKind,
109    },
110}
111
112impl CodeError {
113    /// A code naming `actual` can be used only for the `expected` store.
114    pub fn require_store(expected: StoreId, actual: StoreId) -> Result<(), Self> {
115        if actual == expected {
116            Ok(())
117        } else {
118            Err(Self::WrongStore { expected, actual })
119        }
120    }
121}
122
123/// Decode and validate the complete code before returning public metadata.
124pub fn decode_code_info(code: &str) -> Result<CodeInfo, CodeError> {
125    let (code, location, carries_s3_key) = decode(code)?;
126    let metadata = |store_id, member| CodeMetadata {
127        store_id,
128        member,
129        cloud_provider: location.provider(),
130        oauth_access: location.oauth_access(),
131        location,
132        carries_s3_key,
133    };
134    Ok(match code {
135        DecodedCode::Restore(code) => CodeInfo::Restore(metadata(code.store, code.member)),
136        DecodedCode::Invite(code) => CodeInfo::Invite(InviteInfo {
137            metadata: metadata(code.store, code.member),
138            store_name: code.name,
139            inviter: code.inviter,
140            kind: code.kind,
141            expires_at: std::time::UNIX_EPOCH + std::time::Duration::from_secs(code.expires_at),
142        }),
143    })
144}
145
146/// Decode a restore code for bootstrap, preserving a
147/// valid wrong-kind distinction. Its storage part has already been validated.
148pub fn read_restore_code(code: &str) -> Result<RestoreCode, CodeError> {
149    match decode(code)?.0 {
150        DecodedCode::Restore(code) => Ok(code),
151        DecodedCode::Invite(_) => {
152            Err(CodeError::WrongKind { expected: CodeKind::Restore, actual: CodeKind::Invite })
153        }
154    }
155}
156
157/// Decode an invitation for the joining side after validating provider admission.
158pub fn read_invite_code(code: &str) -> Result<InviteCode, CodeError> {
159    match decode(code)?.0 {
160        DecodedCode::Invite(code) => Ok(code),
161        DecodedCode::Restore(_) => {
162            Err(CodeError::WrongKind { expected: CodeKind::Invite, actual: CodeKind::Restore })
163        }
164    }
165}
166
167enum DecodedCode {
168    Restore(RestoreCode),
169    Invite(InviteCode),
170}
171
172#[expect(
173    clippy::map_err_ignore,
174    reason = "a code that does not decode is Invalid, whichever field failed"
175)]
176fn decode(text: &str) -> Result<(DecodedCode, StorageConfig, bool), CodeError> {
177    if text.starts_with(RestoreCode::TEXT_PREFIX) {
178        let code = RestoreCode::from_text(text).map_err(|_| CodeError::Invalid)?;
179        let storage =
180            RestoreStorage::decode(code.storage.as_bytes()).map_err(|_| CodeError::Invalid)?;
181        let carries_s3_key = storage.s3_key.is_some();
182        Ok((DecodedCode::Restore(code), storage.location, carries_s3_key))
183    } else if text.starts_with(InviteCode::TEXT_PREFIX) {
184        let code = InviteCode::from_text(text).map_err(|_| CodeError::Invalid)?;
185        // Decoding checks that an S3 invite, and only one, carries a whole key.
186        let (invitation, carries_s3_key) =
187            match InviteStorage::decode(code.storage.as_bytes()).map_err(|_| CodeError::Invalid)? {
188                InviteStorage::Account(invitation) => (invitation, false),
189                InviteStorage::S3 { invitation, .. } => (invitation, true),
190            };
191        Ok((DecodedCode::Invite(code), invitation.location().clone(), carries_s3_key))
192    } else {
193        Err(CodeError::Invalid)
194    }
195}
196
197#[cfg(test)]
198#[path = "codes_tests.rs"]
199mod tests;