Skip to main content

proton_sdk/
api.rs

1//! API response envelope and response codes shared across all Proton endpoints.
2
3use serde::{Deserialize, Deserializer};
4
5/// The common envelope every Proton API JSON response embeds.
6///
7/// Successful responses carry `Code == 1000` plus their endpoint-specific
8/// fields; failures carry a non-success code and an `Error` message.
9#[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    /// Endpoint-specific error detail object, when the API attaches one (e.g. a
18    /// revision-creation conflict names the existing draft here). Kept raw so
19    /// each caller can deserialize the shape it expects.
20    #[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/// The human-verification challenge attached to a [`ResponseCode::HumanVerificationRequired`]
31/// response's `Details`.
32///
33/// The server is not refusing the request outright: it is asking the user to
34/// prove they are human and then send the same request again, carrying the
35/// resulting token. Completing the challenge means loading Proton's hosted
36/// verification page (see [`Self::verification_url`]) and letting the user solve
37/// it; that page hands back a token which becomes a
38/// [`HumanVerificationCredential`].
39#[derive(Debug, Clone, Deserialize)]
40pub struct HumanVerification {
41    /// Opaque challenge token identifying this verification attempt. Not the
42    /// answer — it is the input to the verification page.
43    #[serde(rename = "HumanVerificationToken")]
44    pub token: String,
45    /// Verification methods the account is allowed to use, e.g. `captcha`,
46    /// `email`, `sms`. Order is the server's preference.
47    #[serde(rename = "HumanVerificationMethods", default)]
48    pub methods: Vec<String>,
49}
50
51impl HumanVerification {
52    /// Whether the challenge can be satisfied by a CAPTCHA, which is the only
53    /// method solvable entirely in-app: `email` and `sms` need a code delivered
54    /// out of band.
55    pub fn supports_captcha(&self) -> bool {
56        self.methods.iter().any(|m| m == "captcha")
57    }
58
59    /// The hosted verification page to present to the user.
60    ///
61    /// `embed=1` asks the page for the embedded presentation (no Proton chrome),
62    /// which is what a webview wants; the page reports completion by posting a
63    /// message back to its host rather than redirecting.
64    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
73/// Percent-encode the characters that actually occur in Proton's HV tokens and
74/// would otherwise terminate or corrupt the query string. Pulling in a URL
75/// dependency for one opaque token is not worth it.
76fn 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/// A solved human-verification challenge, replayed on the retried request.
90///
91/// The API expects the token and the method that produced it as a header pair;
92/// sending the token without its type is rejected.
93#[derive(Debug, Clone)]
94pub struct HumanVerificationCredential {
95    /// The token the verification page produced — the *answer*, not the
96    /// challenge token from [`HumanVerification::token`].
97    pub token: String,
98    /// The method that produced it, e.g. `captcha`.
99    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/// Application-level response codes returned in the `Code` field.
112///
113/// Mirrors `Proton.Sdk.Api.ResponseCode`. Unknown / future codes deserialize to
114/// [`ResponseCode::Unknown`] via [`ResponseCode::from_raw`] rather than failing.
115#[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    /// The request was gated behind human verification (typically a CAPTCHA on
142    /// login from an unfamiliar or low-reputation IP). `Details` carries the
143    /// challenge — see [`HumanVerification`]. Arrives with HTTP 422.
144    HumanVerificationRequired = 9001,
145    /// The access token lacks a scope the endpoint requires. Notably
146    /// `core/v4/keys/salts` requires `locked`, which only a
147    /// password-authenticated token carries — not one from `auth/v4/refresh`.
148    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    /// Map a raw integer code to a known variant, falling back to [`Self::Unknown`].
165    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    /// The envelope a gated login actually returns, as captured from the live
222    /// API. Before `9001` was mapped this deserialized to `Unknown`, which is
223    /// how a recoverable CAPTCHA prompt reached the user as an opaque failure.
224    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    /// The gate is only actionable when the server says how to satisfy it. A
260    /// 9001 with no `Details` must not present an empty verification page.
261    #[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    /// Only 9001 carries a challenge — a `Details` object on some other error
270    /// must not be mistaken for one.
271    #[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    /// The token is opaque and base64-ish: `+`, `/` and `=` are all plausible in
279    /// it and all break a raw query string.
280    #[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}