mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-04 08:57:00 +01:00
300 lines
11 KiB
Rust
300 lines
11 KiB
Rust
//! API response envelope and response codes shared across all Proton endpoints.
|
|
|
|
use serde::{Deserialize, Deserializer};
|
|
|
|
/// The common envelope every Proton API JSON response embeds.
|
|
///
|
|
/// Successful responses carry `Code == 1000` plus their endpoint-specific
|
|
/// fields; failures carry a non-success code and an `Error` message.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ApiResponse {
|
|
#[serde(rename = "Code")]
|
|
pub code: ResponseCode,
|
|
|
|
#[serde(rename = "Error", default)]
|
|
pub error_message: Option<String>,
|
|
|
|
/// Endpoint-specific error detail object, when the API attaches one (e.g. a
|
|
/// revision-creation conflict names the existing draft here). Kept raw so
|
|
/// each caller can deserialize the shape it expects.
|
|
#[serde(rename = "Details", default)]
|
|
pub details: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl ApiResponse {
|
|
pub fn is_success(&self) -> bool {
|
|
matches!(self.code, ResponseCode::Success)
|
|
}
|
|
}
|
|
|
|
/// The human-verification challenge attached to a [`ResponseCode::HumanVerificationRequired`]
|
|
/// response's `Details`.
|
|
///
|
|
/// The server is not refusing the request outright: it is asking the user to
|
|
/// prove they are human and then send the same request again, carrying the
|
|
/// resulting token. Completing the challenge means loading Proton's hosted
|
|
/// verification page (see [`Self::verification_url`]) and letting the user solve
|
|
/// it; that page hands back a token which becomes a
|
|
/// [`HumanVerificationCredential`].
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct HumanVerification {
|
|
/// Opaque challenge token identifying this verification attempt. Not the
|
|
/// answer — it is the input to the verification page.
|
|
#[serde(rename = "HumanVerificationToken")]
|
|
pub token: String,
|
|
/// Verification methods the account is allowed to use, e.g. `captcha`,
|
|
/// `email`, `sms`. Order is the server's preference.
|
|
#[serde(rename = "HumanVerificationMethods", default)]
|
|
pub methods: Vec<String>,
|
|
}
|
|
|
|
impl HumanVerification {
|
|
/// Whether the challenge can be satisfied by a CAPTCHA, which is the only
|
|
/// method solvable entirely in-app: `email` and `sms` need a code delivered
|
|
/// out of band.
|
|
pub fn supports_captcha(&self) -> bool {
|
|
self.methods.iter().any(|m| m == "captcha")
|
|
}
|
|
|
|
/// The hosted verification page to present to the user.
|
|
///
|
|
/// `embed=1` asks the page for the embedded presentation (no Proton chrome),
|
|
/// which is what a webview wants; the page reports completion by posting a
|
|
/// message back to its host rather than redirecting.
|
|
pub fn verification_url(&self) -> String {
|
|
format!(
|
|
"https://verify.proton.me/?embed=1&methods={}&token={}",
|
|
self.methods.join(","),
|
|
urlencoding_minimal(&self.token),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Percent-encode the characters that actually occur in Proton's HV tokens and
|
|
/// would otherwise terminate or corrupt the query string. Pulling in a URL
|
|
/// dependency for one opaque token is not worth it.
|
|
fn urlencoding_minimal(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for b in s.bytes() {
|
|
match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
out.push(b as char)
|
|
}
|
|
_ => out.push_str(&format!("%{b:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A solved human-verification challenge, replayed on the retried request.
|
|
///
|
|
/// The API expects the token and the method that produced it as a header pair;
|
|
/// sending the token without its type is rejected.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HumanVerificationCredential {
|
|
/// The token the verification page produced — the *answer*, not the
|
|
/// challenge token from [`HumanVerification::token`].
|
|
pub token: String,
|
|
/// The method that produced it, e.g. `captcha`.
|
|
pub method: String,
|
|
}
|
|
|
|
impl HumanVerificationCredential {
|
|
pub fn captcha(token: impl Into<String>) -> Self {
|
|
Self {
|
|
token: token.into(),
|
|
method: "captcha".to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Application-level response codes returned in the `Code` field.
|
|
///
|
|
/// Mirrors `Proton.Sdk.Api.ResponseCode`. Unknown / future codes deserialize to
|
|
/// [`ResponseCode::Unknown`] via [`ResponseCode::from_raw`] rather than failing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[repr(i64)]
|
|
pub enum ResponseCode {
|
|
Unknown = 0,
|
|
|
|
Unauthorized = 401,
|
|
Forbidden = 403,
|
|
RequestTimeout = 408,
|
|
TooManyRequests = 429,
|
|
ServiceUnavailable = 503,
|
|
|
|
Success = 1000,
|
|
MultipleResponses = 1001,
|
|
InvalidRequirements = 2000,
|
|
InvalidValue = 2001,
|
|
NotEnoughPermissions = 2011,
|
|
NotEnoughPermissionsToGrantPermissions = 2026,
|
|
InvalidEncryptedIdFormat = 2061,
|
|
AlreadyExists = 2500,
|
|
DoesNotExist = 2501,
|
|
Timeout = 2503,
|
|
IncompatibleState = 2511,
|
|
InvalidApp = 5002,
|
|
OutdatedApp = 5003,
|
|
Offline = 7001,
|
|
IncorrectLoginCredentials = 8002,
|
|
/// The request was gated behind human verification (typically a CAPTCHA on
|
|
/// login from an unfamiliar or low-reputation IP). `Details` carries the
|
|
/// challenge — see [`HumanVerification`]. Arrives with HTTP 422.
|
|
HumanVerificationRequired = 9001,
|
|
/// The access token lacks a scope the endpoint requires. Notably
|
|
/// `core/v4/keys/salts` requires `locked`, which only a
|
|
/// password-authenticated token carries — not one from `auth/v4/refresh`.
|
|
InsufficientScope = 9101,
|
|
AccountDeleted = 10_002,
|
|
AccountDisabled = 10_003,
|
|
InvalidRefreshToken = 10_013,
|
|
NoActiveSubscription = 22_110,
|
|
AddressMissing = 33_102,
|
|
DomainExternal = 33_103,
|
|
ProtonDriveUnknown = 200_000,
|
|
InsufficientQuota = 200_001,
|
|
InsufficientSpace = 200_002,
|
|
InsufficientVolumeQuota = 200_100,
|
|
TooManyChildren = 200_300,
|
|
NestingTooDeep = 200_301,
|
|
}
|
|
|
|
impl ResponseCode {
|
|
/// Map a raw integer code to a known variant, falling back to [`Self::Unknown`].
|
|
pub fn from_raw(raw: i64) -> Self {
|
|
match raw {
|
|
401 => Self::Unauthorized,
|
|
403 => Self::Forbidden,
|
|
408 => Self::RequestTimeout,
|
|
429 => Self::TooManyRequests,
|
|
503 => Self::ServiceUnavailable,
|
|
1000 => Self::Success,
|
|
1001 => Self::MultipleResponses,
|
|
2000 => Self::InvalidRequirements,
|
|
2001 => Self::InvalidValue,
|
|
2011 => Self::NotEnoughPermissions,
|
|
2026 => Self::NotEnoughPermissionsToGrantPermissions,
|
|
2061 => Self::InvalidEncryptedIdFormat,
|
|
2500 => Self::AlreadyExists,
|
|
2501 => Self::DoesNotExist,
|
|
2503 => Self::Timeout,
|
|
2511 => Self::IncompatibleState,
|
|
5002 => Self::InvalidApp,
|
|
5003 => Self::OutdatedApp,
|
|
7001 => Self::Offline,
|
|
8002 => Self::IncorrectLoginCredentials,
|
|
9001 => Self::HumanVerificationRequired,
|
|
9101 => Self::InsufficientScope,
|
|
10_002 => Self::AccountDeleted,
|
|
10_003 => Self::AccountDisabled,
|
|
10_013 => Self::InvalidRefreshToken,
|
|
22_110 => Self::NoActiveSubscription,
|
|
33_102 => Self::AddressMissing,
|
|
33_103 => Self::DomainExternal,
|
|
200_000 => Self::ProtonDriveUnknown,
|
|
200_001 => Self::InsufficientQuota,
|
|
200_002 => Self::InsufficientSpace,
|
|
200_100 => Self::InsufficientVolumeQuota,
|
|
200_300 => Self::TooManyChildren,
|
|
200_301 => Self::NestingTooDeep,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for ResponseCode {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let raw = i64::deserialize(deserializer)?;
|
|
Ok(ResponseCode::from_raw(raw))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::error::ProtonApiError;
|
|
|
|
/// The envelope a gated login actually returns, as captured from the live
|
|
/// API. Before `9001` was mapped this deserialized to `Unknown`, which is
|
|
/// how a recoverable CAPTCHA prompt reached the user as an opaque failure.
|
|
const GATED_LOGIN: &str = r#"{
|
|
"Code": 9001,
|
|
"Error": "For security reasons, please complete CAPTCHA.",
|
|
"Details": {
|
|
"HumanVerificationMethods": ["captcha", "email", "sms"],
|
|
"HumanVerificationToken": "abc-123_XYZ"
|
|
}
|
|
}"#;
|
|
|
|
fn gated_error() -> ProtonApiError {
|
|
let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap();
|
|
ProtonApiError {
|
|
code: envelope.code,
|
|
http_status: 422,
|
|
message: envelope.error_message.unwrap_or_default(),
|
|
details: envelope.details,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_gated_login_is_recognised_rather_than_unknown() {
|
|
let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap();
|
|
assert_eq!(envelope.code, ResponseCode::HumanVerificationRequired);
|
|
}
|
|
|
|
#[test]
|
|
fn the_challenge_is_read_off_the_details_object() {
|
|
let hv = gated_error()
|
|
.human_verification()
|
|
.expect("challenge present");
|
|
assert_eq!(hv.token, "abc-123_XYZ");
|
|
assert_eq!(hv.methods, ["captcha", "email", "sms"]);
|
|
assert!(hv.supports_captcha());
|
|
}
|
|
|
|
/// The gate is only actionable when the server says how to satisfy it. A
|
|
/// 9001 with no `Details` must not present an empty verification page.
|
|
#[test]
|
|
fn a_challengeless_gate_yields_no_challenge() {
|
|
let mut err = gated_error();
|
|
err.details = None;
|
|
assert!(err.is_human_verification_required());
|
|
assert!(err.human_verification().is_none());
|
|
}
|
|
|
|
/// Only 9001 carries a challenge — a `Details` object on some other error
|
|
/// must not be mistaken for one.
|
|
#[test]
|
|
fn other_errors_carry_no_challenge() {
|
|
let mut err = gated_error();
|
|
err.code = ResponseCode::AlreadyExists;
|
|
assert!(err.human_verification().is_none());
|
|
}
|
|
|
|
/// The token is opaque and base64-ish: `+`, `/` and `=` are all plausible in
|
|
/// it and all break a raw query string.
|
|
#[test]
|
|
fn the_challenge_token_is_escaped_into_the_url() {
|
|
let hv = HumanVerification {
|
|
token: "a+b/c=d".to_owned(),
|
|
methods: vec!["captcha".to_owned()],
|
|
};
|
|
let url = hv.verification_url();
|
|
assert!(url.contains("token=a%2Bb%2Fc%3Dd"), "{url}");
|
|
assert!(url.contains("methods=captcha"), "{url}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_captchaless_challenge_is_not_solvable_in_app() {
|
|
let hv = HumanVerification {
|
|
token: "t".to_owned(),
|
|
methods: vec!["email".to_owned(), "sms".to_owned()],
|
|
};
|
|
assert!(!hv.supports_captcha());
|
|
}
|
|
}
|