use serde::{Deserialize, Deserializer};
#[derive(Debug, Clone, Deserialize)]
pub struct ApiResponse {
#[serde(rename = "Code")]
pub code: ResponseCode,
#[serde(rename = "Error", default)]
pub error_message: Option<String>,
#[serde(rename = "Details", default)]
pub details: Option<serde_json::Value>,
}
impl ApiResponse {
pub fn is_success(&self) -> bool {
matches!(self.code, ResponseCode::Success)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HumanVerification {
#[serde(rename = "HumanVerificationToken")]
pub token: String,
#[serde(rename = "HumanVerificationMethods", default)]
pub methods: Vec<String>,
}
impl HumanVerification {
pub fn supports_captcha(&self) -> bool {
self.methods.iter().any(|m| m == "captcha")
}
pub fn verification_url(&self) -> String {
format!(
"https://verify.proton.me/?embed=1&methods={}&token={}",
self.methods.join(","),
urlencoding_minimal(&self.token),
)
}
}
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
}
#[derive(Debug, Clone)]
pub struct HumanVerificationCredential {
pub token: String,
pub method: String,
}
impl HumanVerificationCredential {
pub fn captcha(token: impl Into<String>) -> Self {
Self {
token: token.into(),
method: "captcha".to_owned(),
}
}
}
#[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,
HumanVerificationRequired = 9001,
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 {
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;
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());
}
#[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());
}
#[test]
fn other_errors_carry_no_challenge() {
let mut err = gated_error();
err.code = ResponseCode::AlreadyExists;
assert!(err.human_verification().is_none());
}
#[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());
}
}