aven_core/sync/client/
invitation.rs1use std::fmt;
4
5use anyhow::Result;
6use zeroize::Zeroizing;
7
8use crate::sync::invitation_text::{self, Kind};
9use crate::sync::seed_claim::{Secret, membership::Invitation};
10
11pub struct SetupInvitation {
13 pub server: String,
14 pub setup_id: [u8; 32],
15 pub secret: Secret,
16}
17
18pub struct DeviceInvitation {
20 pub server: String,
21 pub invitation: Invitation,
22}
23
24impl fmt::Debug for SetupInvitation {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 f.write_str("SetupInvitation([REDACTED])")
27 }
28}
29
30impl fmt::Debug for DeviceInvitation {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 f.write_str("DeviceInvitation([REDACTED])")
33 }
34}
35
36impl SetupInvitation {
37 pub fn encode(&self) -> Result<Zeroizing<String>> {
38 let mut secret = Zeroizing::new(self.setup_id.to_vec());
39 secret.extend_from_slice(self.secret.expose());
40 invitation_text::encode(Kind::Setup, &self.server, &secret)
41 }
42
43 pub fn decode(text: &str) -> Result<Self> {
44 Self::decode_fields(text)
45 .ok_or_else(|| anyhow::anyhow!("error sync-setup-invitation-invalid"))
46 }
47
48 fn decode_fields(text: &str) -> Option<Self> {
49 let decoded = invitation_text::decode(text).filter(|d| d.kind == Kind::Setup)?;
50 Some(Self {
51 setup_id: decoded.secret[..32].try_into().ok()?,
52 secret: Secret::new(decoded.secret[32..].try_into().ok()?),
53 server: decoded.server,
54 })
55 }
56}
57
58impl DeviceInvitation {
59 pub fn encode(&self) -> Result<Zeroizing<String>> {
60 let fields = self.invitation.protected_storage_bytes();
63 invitation_text::encode(Kind::Device, &self.server, &fields)
64 }
65
66 pub fn decode(text: &str) -> Result<Self> {
67 Self::decode_fields(text)
68 .ok_or_else(|| anyhow::anyhow!("error sync-device-invitation-invalid"))
69 }
70
71 fn decode_fields(text: &str) -> Option<Self> {
72 let decoded = invitation_text::decode(text).filter(|d| d.kind == Kind::Device)?;
73 Some(Self {
74 invitation: Invitation::from_protected_storage(&decoded.secret).ok()?,
75 server: decoded.server,
76 })
77 }
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub enum InvitationCheck {
84 #[default]
85 Empty,
86 Setup(String),
87 Device(String),
88 Incomplete,
90 Unknown,
91}
92
93impl InvitationCheck {
94 pub fn of(text: &str) -> Self {
95 if text.trim().is_empty() {
96 return Self::Empty;
97 }
98 match invitation_text::decode(text) {
99 Some(decoded) if decoded.kind == Kind::Setup => Self::Setup(decoded.server),
100 Some(decoded) => Self::Device(decoded.server),
101 None if Kind::of_prefix(text).is_some() => Self::Incomplete,
102 None => Self::Unknown,
103 }
104 }
105}
106
107#[cfg(any(test, feature = "test-support"))]
110pub fn sample_invitations(server: &str) -> (Zeroizing<String>, Zeroizing<String>) {
111 let setup = SetupInvitation {
112 server: server.into(),
113 setup_id: [3; 32],
114 secret: Secret::new([4; 32]),
115 };
116 let mut storage = vec![5; 32];
117 storage.extend([6; 32]);
118 storage.extend([7; 32]);
119 let device = DeviceInvitation {
120 server: server.into(),
121 invitation: Invitation::from_protected_storage(&storage).expect("sample invitation"),
122 };
123 (
124 setup.encode().expect("sample invitation"),
125 device.encode().expect("sample invitation"),
126 )
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn invitations_round_trip_and_report_their_kind() {
135 let server = "https://sync.example.net";
136 let (setup_text, device_text) = sample_invitations(server);
137
138 assert!(setup_text.starts_with(invitation_text::SETUP_PREFIX));
139 let decoded = SetupInvitation::decode(&format!(" {}\n", setup_text.as_str())).unwrap();
140 assert_eq!(decoded.server, server);
141 assert_eq!(decoded.setup_id, [3; 32]);
142 assert_eq!(decoded.secret.expose(), &[4; 32]);
143 assert_eq!(format!("{decoded:?}"), "SetupInvitation([REDACTED])");
144 assert!(DeviceInvitation::decode(&setup_text).is_err());
145
146 assert!(device_text.starts_with(invitation_text::DEVICE_PREFIX));
147 let decoded = DeviceInvitation::decode(&device_text.to_ascii_lowercase()).unwrap();
148 assert_eq!(decoded.server, server);
149 assert_eq!(decoded.encode().unwrap(), device_text);
150 assert!(SetupInvitation::decode(&device_text).is_err());
151
152 assert_eq!(
153 InvitationCheck::of(&setup_text),
154 InvitationCheck::Setup(server.into())
155 );
156 assert_eq!(
157 InvitationCheck::of(&device_text),
158 InvitationCheck::Device(server.into())
159 );
160 assert_eq!(
161 InvitationCheck::of(&setup_text[..60]),
162 InvitationCheck::Incomplete
163 );
164 assert_eq!(
165 InvitationCheck::of(&device_text[..40].to_ascii_lowercase()),
166 InvitationCheck::Incomplete
167 );
168 assert_eq!(InvitationCheck::of("hello"), InvitationCheck::Unknown);
169 assert_eq!(InvitationCheck::of(" "), InvitationCheck::Empty);
170 }
171}