1use serde::{Deserialize, Deserializer};
4
5#[derive(Debug, Clone, Deserialize)]
10pub struct ApiResponse {
11 #[serde(rename = "Code")]
12 pub code: ResponseCode,
13
14 #[serde(rename = "Error", default)]
15 pub error_message: Option<String>,
16
17 #[serde(rename = "Details", default)]
21 pub details: Option<serde_json::Value>,
22}
23
24impl ApiResponse {
25 pub fn is_success(&self) -> bool {
26 matches!(self.code, ResponseCode::Success)
27 }
28}
29
30#[derive(Debug, Clone, Deserialize)]
40pub struct HumanVerification {
41 #[serde(rename = "HumanVerificationToken")]
44 pub token: String,
45 #[serde(rename = "HumanVerificationMethods", default)]
48 pub methods: Vec<String>,
49}
50
51impl HumanVerification {
52 pub fn supports_captcha(&self) -> bool {
56 self.methods.iter().any(|m| m == "captcha")
57 }
58
59 pub fn verification_url(&self) -> String {
65 format!(
66 "https://verify.proton.me/?embed=1&methods={}&token={}",
67 self.methods.join(","),
68 urlencoding_minimal(&self.token),
69 )
70 }
71}
72
73fn urlencoding_minimal(s: &str) -> String {
77 let mut out = String::with_capacity(s.len());
78 for b in s.bytes() {
79 match b {
80 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
81 out.push(b as char)
82 }
83 _ => out.push_str(&format!("%{b:02X}")),
84 }
85 }
86 out
87}
88
89#[derive(Debug, Clone)]
94pub struct HumanVerificationCredential {
95 pub token: String,
98 pub method: String,
100}
101
102impl HumanVerificationCredential {
103 pub fn captcha(token: impl Into<String>) -> Self {
104 Self {
105 token: token.into(),
106 method: "captcha".to_owned(),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116#[repr(i64)]
117pub enum ResponseCode {
118 Unknown = 0,
119
120 Unauthorized = 401,
121 Forbidden = 403,
122 RequestTimeout = 408,
123 TooManyRequests = 429,
124 ServiceUnavailable = 503,
125
126 Success = 1000,
127 MultipleResponses = 1001,
128 InvalidRequirements = 2000,
129 InvalidValue = 2001,
130 NotEnoughPermissions = 2011,
131 NotEnoughPermissionsToGrantPermissions = 2026,
132 InvalidEncryptedIdFormat = 2061,
133 AlreadyExists = 2500,
134 DoesNotExist = 2501,
135 Timeout = 2503,
136 IncompatibleState = 2511,
137 InvalidApp = 5002,
138 OutdatedApp = 5003,
139 Offline = 7001,
140 IncorrectLoginCredentials = 8002,
141 HumanVerificationRequired = 9001,
145 InsufficientScope = 9101,
149 AccountDeleted = 10_002,
150 AccountDisabled = 10_003,
151 InvalidRefreshToken = 10_013,
152 NoActiveSubscription = 22_110,
153 AddressMissing = 33_102,
154 DomainExternal = 33_103,
155 ProtonDriveUnknown = 200_000,
156 InsufficientQuota = 200_001,
157 InsufficientSpace = 200_002,
158 InsufficientVolumeQuota = 200_100,
159 TooManyChildren = 200_300,
160 NestingTooDeep = 200_301,
161}
162
163impl ResponseCode {
164 pub fn from_raw(raw: i64) -> Self {
166 match raw {
167 401 => Self::Unauthorized,
168 403 => Self::Forbidden,
169 408 => Self::RequestTimeout,
170 429 => Self::TooManyRequests,
171 503 => Self::ServiceUnavailable,
172 1000 => Self::Success,
173 1001 => Self::MultipleResponses,
174 2000 => Self::InvalidRequirements,
175 2001 => Self::InvalidValue,
176 2011 => Self::NotEnoughPermissions,
177 2026 => Self::NotEnoughPermissionsToGrantPermissions,
178 2061 => Self::InvalidEncryptedIdFormat,
179 2500 => Self::AlreadyExists,
180 2501 => Self::DoesNotExist,
181 2503 => Self::Timeout,
182 2511 => Self::IncompatibleState,
183 5002 => Self::InvalidApp,
184 5003 => Self::OutdatedApp,
185 7001 => Self::Offline,
186 8002 => Self::IncorrectLoginCredentials,
187 9001 => Self::HumanVerificationRequired,
188 9101 => Self::InsufficientScope,
189 10_002 => Self::AccountDeleted,
190 10_003 => Self::AccountDisabled,
191 10_013 => Self::InvalidRefreshToken,
192 22_110 => Self::NoActiveSubscription,
193 33_102 => Self::AddressMissing,
194 33_103 => Self::DomainExternal,
195 200_000 => Self::ProtonDriveUnknown,
196 200_001 => Self::InsufficientQuota,
197 200_002 => Self::InsufficientSpace,
198 200_100 => Self::InsufficientVolumeQuota,
199 200_300 => Self::TooManyChildren,
200 200_301 => Self::NestingTooDeep,
201 _ => Self::Unknown,
202 }
203 }
204}
205
206impl<'de> Deserialize<'de> for ResponseCode {
207 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
208 where
209 D: Deserializer<'de>,
210 {
211 let raw = i64::deserialize(deserializer)?;
212 Ok(ResponseCode::from_raw(raw))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::error::ProtonApiError;
220
221 const GATED_LOGIN: &str = r#"{
225 "Code": 9001,
226 "Error": "For security reasons, please complete CAPTCHA.",
227 "Details": {
228 "HumanVerificationMethods": ["captcha", "email", "sms"],
229 "HumanVerificationToken": "abc-123_XYZ"
230 }
231 }"#;
232
233 fn gated_error() -> ProtonApiError {
234 let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap();
235 ProtonApiError {
236 code: envelope.code,
237 http_status: 422,
238 message: envelope.error_message.unwrap_or_default(),
239 details: envelope.details,
240 }
241 }
242
243 #[test]
244 fn a_gated_login_is_recognised_rather_than_unknown() {
245 let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap();
246 assert_eq!(envelope.code, ResponseCode::HumanVerificationRequired);
247 }
248
249 #[test]
250 fn the_challenge_is_read_off_the_details_object() {
251 let hv = gated_error()
252 .human_verification()
253 .expect("challenge present");
254 assert_eq!(hv.token, "abc-123_XYZ");
255 assert_eq!(hv.methods, ["captcha", "email", "sms"]);
256 assert!(hv.supports_captcha());
257 }
258
259 #[test]
262 fn a_challengeless_gate_yields_no_challenge() {
263 let mut err = gated_error();
264 err.details = None;
265 assert!(err.is_human_verification_required());
266 assert!(err.human_verification().is_none());
267 }
268
269 #[test]
272 fn other_errors_carry_no_challenge() {
273 let mut err = gated_error();
274 err.code = ResponseCode::AlreadyExists;
275 assert!(err.human_verification().is_none());
276 }
277
278 #[test]
281 fn the_challenge_token_is_escaped_into_the_url() {
282 let hv = HumanVerification {
283 token: "a+b/c=d".to_owned(),
284 methods: vec!["captcha".to_owned()],
285 };
286 let url = hv.verification_url();
287 assert!(url.contains("token=a%2Bb%2Fc%3Dd"), "{url}");
288 assert!(url.contains("methods=captcha"), "{url}");
289 }
290
291 #[test]
292 fn a_captchaless_challenge_is_not_solvable_in_app() {
293 let hv = HumanVerification {
294 token: "t".to_owned(),
295 methods: vec!["email".to_owned(), "sms".to_owned()],
296 };
297 assert!(!hv.supports_captcha());
298 }
299}